使用以太坊进行 web2 身份验证
简介
SAMLopens in a new tab 是 web2 中使用的一种标准,允许身份提供者 (IdP)opens in a new tab 为服务提供者 (SP)opens in a new tab 提供用户信息。
在本教程中,您将学习如何将以太坊签名与 SAML 集成,以允许用户使用其以太坊钱包向尚不支持原生以太坊的 web2 服务进行身份验证。
请注意,本教程面向两类不同的受众:
- 了解以太坊并需要学习 SAML 的以太坊人士
- 了解 SAML 和 web2 身份验证并需要学习以太坊的 Web2 人士
因此,本教程会包含许多您已了解的介绍性材料。 您可以随意跳过。
面向以太坊人士的 SAML
SAML 是一种中心化协议。 服务提供者 (SP) 仅在与身份提供者 (IdP) 或签署 IdP 证书的证书颁发机构opens in a new tab存在预先信任关系时,才会接受来自 IdP 的断言(例如“这是我的用户 John,他应有权执行 A、B 和 C”)。
例如,SP 可以是为公司提供差旅服务的旅行社,而 IdP 可以是公司的内部网站。 当员工需要预订商务差旅时,旅行社会先将他们发送至公司进行身份验证,然后才允许他们实际预订差旅。
这就是浏览器、SP 和 IdP 这三个实体协商访问权限的方式。 SP 无需提前了解任何有关使用浏览器的用户的信息,只需信任 IdP 即可。
面向 SAML 人士的以太坊
以太坊是一个去中心化系统。
用户拥有私钥(通常保存在浏览器扩展程序中)。 您可以从私钥派生出公钥,再从公钥派生出 20 字节的地址。 当用户需要登录系统时,系统会要求他们使用随机数(一次性值)签署一条信息。 服务器可以验证签名是由该地址创建的。
签名仅验证以太坊地址。 要获取其他用户属性,您通常会使用认证opens in a new tab。 一份认证通常包含以下字段:
- 认证者,进行认证的地址
- 接收者,认证适用的地址
- 数据,被认证的数据,例如姓名、权限等。
- 模式,用于解释数据的模式 ID。
由于以太坊的去中心化特性,任何用户都可以创建认证。 认证者的身份对于确定哪些认证是可靠的至关重要。
设置
第一步是让 SAML SP 和 SAML IdP 能够相互通信。
-
下载软件。 本文的示例软件在 Github 上opens in a new tab。 不同阶段存储在不同分支中,此阶段您需要
saml-only1git clone https://github.com/qbzzt/250420-saml-ethereum -b saml-only2cd 250420-saml-ethereum3pnpm install -
使用自签名证书创建密钥。 这意味着该密钥本身就是其证书颁发机构,需要手动将其导入服务提供者。 有关详细信息,请参阅 OpenSSL 文档opens in a new tab。
1mkdir keys2cd keys3openssl req -new -x509 -days 365 -nodes -sha256 -out saml-sp.crt -keyout saml-sp.pem -subj /CN=sp/4openssl req -new -x509 -days 365 -nodes -sha256 -out saml-idp.crt -keyout saml-idp.pem -subj /CN=idp/5cd .. -
启动服务器(SP 和 IdP)
1pnpm start -
浏览到 SP 的 URL http://localhost:3000/opens in a new tab,然后单击按钮重定向到 IdP(端口 3001)。
-
向 IdP 提供您的电子邮件地址,然后单击登录服务提供者。 您会看到您被重定向回服务提供者(端口 3000),并且它通过您的电子邮件地址识别了您。
详细说明
以下是分步说明:
src/config.mts
此文件包含身份提供者和服务提供者的配置。 通常这两者是不同的实体,但为了简单起见,我们可以在这里共享代码。
1const fs = await import("fs")23const protocol="http"我们目前只是在测试,所以使用 HTTP 没问题。
1export const spCert = fs.readFileSync("keys/saml-sp.crt").toString()2export const idpCert = fs.readFileSync("keys/saml-idp.crt").toString()读取公钥,公钥通常对两个组件都可用(可以直接信任,也可以由受信任的证书颁发机构签名)。
1export const spPort = 30002export const spHostname = "localhost"3export const spDir = "sp"45export const idpPort = 30016export const idpHostname = "localhost"7export const idpDir = "idp"89export const spUrl = `${protocol}://${spHostname}:${spPort}/${spDir}`10export const idpUrl = `${protocol}://${idpHostname}:${idpPort}/${idpDir}`显示全部两个组件的 URL。
1export const spPublicData = {服务提供者的公开数据。
1 entityID: `${spUrl}/metadata`,按照惯例,在 SAML 中,entityID 是实体的元数据所在的 URL。 此元数据对应于此处的公共数据,但其格式为 XML。
1 wantAssertionsSigned: true,2 authnRequestsSigned: false,3 signingCert: spCert,4 allowCreate: true,5 assertionConsumerService: [{6 Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',7 Location: `${spUrl}/assertion`,8 }]9 }显示全部对我们来说,最重要的定义是 assertionConsumerServer。 它意味着,要向服务提供者断言某事(例如,“向您发送此信息的用户是 somebody@example.comopens email client”),我们需要使用 HTTP POSTopens in a new tab 到 URL http://localhost:3000/sp/assertion。
1export const idpPublicData = {2 entityID: `${idpUrl}/metadata`,3 signingCert: idpCert,4 wantAuthnRequestsSigned: false,5 singleSignOnService: [{6 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",7 Location: `${idpUrl}/login`8 }],9 singleLogoutService: [{10 Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",11 Location: `${idpUrl}/logout`12 }],13 }显示全部身份提供者的公共数据是类似的。 它指定要登录用户,您需要 POST 到 http://localhost:3001/idp/login,要注销用户,则 POST 到 http://localhost:3001/idp/logout。
src/sp.mts
这是实现服务提供者的代码。
1import * as config from "./config.mts"2const fs = await import("fs")3const saml = await import("samlify")我们使用 samlifyopens in a new tab 程序库来实现 SAML。
1import * as validator from "@authenio/samlify-node-xmllint"2saml.setSchemaValidator(validator)samlify 程序库需要一个包来验证 XML 是否正确、是否使用预期的公钥签名等。 为此,我们使用 @authenio/samlify-node-xmllintopens in a new tab。
1const express = (await import("express")).default2const spRouter = express.Router()3const app = express()express](https://expressjs.com/opens in a new tab) Routeropens in a new tab 是一个可以挂载在网站内部的“迷你网站”。 在本例中,我们用它将所有服务提供者的定义组合在一起。
1const spPrivateKey = fs.readFileSync("keys/saml-sp.pem").toString()23const sp = saml.ServiceProvider({4 privateKey: spPrivateKey, 5 ...config.spPublicData6})服务提供者自身的表示是所有的公共数据以及用于签署信息的私钥。
1const idp = saml.IdentityProvider(config.idpPublicData);公共数据包含服务提供者需要了解的关于身份提供者的一切信息。
1spRouter.get(`/metadata`, 2 (req, res) => res.header("Content-Type", "text/xml").send(sp.getMetadata())3)为了实现与其他 SAML 组件的互操作性,服务和身份提供者应在 /metadata 中以 XML 格式提供其公共数据(称为元数据)。
1spRouter.post(`/assertion`,这是浏览器用来标识自己的页面。 断言包括用户标识符(此处我们使用电子邮件地址),并且可以包括其他属性。 这是上述序列图中步骤 7 的处理程序。
1 async (req, res) => {2 // console.log(`SAML 响应:\n${Buffer.from(req.body.SAMLResponse, 'base64').toString('utf-8')}`)您可以使用注释掉的命令来查看断言中提供的 XML 数据。 它经过 base64 编码opens in a new tab。
1 try {2 const loginResponse = await sp.parseLoginResponse(idp, 'post', req);解析来自身份服务器的登录请求。
1 res.send(`2 <html>3 <body>4 <h2>你好 ${loginResponse.extract.nameID}</h2>5 </body>6 </html>7 `)8 res.send();发送 HTML 响应,只是为了向用户显示我们已成功登录。
1 } catch (err) {2 console.error('处理 SAML 响应时出错:', err);3 res.status(400).send('SAML 身份验证失败');4 }5 }6)如果失败,通知用户。
1spRouter.get('/login',当浏览器尝试获取此页面时,创建一个登录请求。 这是上述序列图中步骤 1 的处理程序。
1 async (req, res) => {2 const loginRequest = await sp.createLoginRequest(idp, "post")获取信息以发布登录请求。
1 res.send(`2 <html>3 <body>4 <script>5 window.onload = function () { document.forms[0].submit(); } 6 </script>此页面会自动提交表单(见下文)。 这样,用户无需执行任何操作即可被重定向。 这是上述序列图中的步骤 2。
1 <form method="post" action="${loginRequest.entityEndpoint}">POST 到 loginRequest.entityEndpoint(身份提供者端点的 URL)。
1 <input type="hidden" name="${loginRequest.type}" value="${loginRequest.context}" />输入名称为 loginRequest.type (SAMLRequest)。 该字段的内容是 loginRequest.context,它也是经过 base64 编码的 XML。
1 </form>2 </body>3 </html>4 `) 5 }6)78app.use(express.urlencoded({extended: true}))此中间件opens in a new tab 读取 HTTP 请求opens in a new tab的正文。 默认情况下,express 会忽略它,因为大多数请求都不需要它。 我们需要它,因为 POST 会使用正文。
1app.use(`/${config.spDir}`, spRouter)将路由器挂载在服务提供者目录 (/sp) 中。
1app.get("/", (req, res) => {2 res.send(`3 <html>4 <body>5 <button onClick="document.location.href='${config.spUrl}/login'">6 点击此处登录7 </button>8 </body>9 </html>10 `)11})显示全部如果浏览器尝试获取根目录,请为其提供一个指向登录页面的链接。
1app.listen(config.spPort, () => {2 console.log(`服务提供者正在运行于 http://${config.spHostname}:${config.spPort}`)3})使用此 express 应用监听 spPort。
src/idp.mts
这是身份提供者。 它与服务提供者非常相似,下面的解释针对的是不同的部分。
1const xmlParser = new (await import("fast-xml-parser")).XMLParser(2 {3 ignoreAttributes: false, // 保留属性4 attributeNamePrefix: "@_", // 属性前缀5 }6)我们需要读取并理解从服务提供者收到的 XML 请求。
1const getLoginPage = requestId => `此函数创建带有自动提交表单的页面,该页面在上述序列图的步骤 4 中返回。
1<html>2 <head>3 <title>登录页面</title>4 </head>5 <body>6 <h2>登录页面</h2>7 <form method="post" action="./loginSubmitted">8 <input type="hidden" name="requestId" value="${requestId}" />9 电子邮件地址: <input name="email" />10 <br />11 <button type="Submit">12 登录到服务提供者13 </button>显示全部我们发送给服务提供者两个字段:
- 我们正在响应的
requestId。 - 用户标识符(我们目前使用用户提供的电子邮件地址)。
1 </form>2 </body>3</html>45const idpRouter = express.Router()67idpRouter.post("/loginSubmitted", async (req, res) => {8 const loginResponse = await idp.createLoginResponse(这是上述序列图中步骤 5 的处理程序。 idp.createLoginResponseopens in a new tab 创建登录响应。
1 sp, 2 {3 authnContextClassRef: 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',4 audience: sp.entityID,受众是服务提供者。
1 extract: {2 request: {3 id: req.body.requestId4 }5 },从请求中提取的信息。 我们在请求中关心的唯一参数是 requestId,它让服务提供者能够匹配请求及其响应。
1 signingKey: { privateKey: idpPrivateKey, publicKey: config.idpCert } // 确保签名我们需要 signingKey 来获取签署响应的数据。 服务提供者不信任未签名的请求。
1 },2 "post",3 {4 email: req.body.email这是我们发送回服务提供者的包含用户信息的字段。
1 }2 );34 res.send(`5 <html>6 <body>7 <script>8 window.onload = function () { document.forms[0].submit(); }9 </script>10 11 <form method="post" action="${loginResponse.entityEndpoint}">12 <input type="hidden" name="${loginResponse.type}" value="${loginResponse.context}" />13 </form>14 </body>15 </html>16 `)17})显示全部同样,使用自动提交的表单。 这是上述序列图中的步骤 6。
12// 用于登录请求的 IdP 端点3idpRouter.post(`/login`,这是接收来自服务提供者的登录请求的端点。 这是上述序列图中的步骤 3 的处理程序。
1 async (req, res) => {2 try {3 // 权宜之计,因为我无法让 parseLoginRequest 工作。4 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)5 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))6 res.send(getLoginPage(samlRequest["samlp:AuthnRequest"]["@_ID"]))我们应该能够使用 idp.parseLoginRequestopens in a new tab 来读取身份验证请求的 ID。 然而,我无法使其正常工作,也不值得花太多时间在这上面,所以我只是用了一个通用的 XML 解析器opens in a new tab。 我们需要的信息是 <samlp:AuthnRequest> 标签内的 ID 属性,它位于 XML 的顶层。
使用以太坊签名
既然我们能将用户身份发送到服务提供者,下一步就是以可信的方式获取用户身份。 Viem 允许我们直接向钱包请求用户地址,但这相当于向浏览器请求信息。 我们无法控制浏览器,所以我们不能自动信任从它那里得到的响应。
因此,IdP 将向浏览器发送一个字符串以供签名。 如果浏览器中的钱包签署了这个字符串,就意味着它确实是那个地址(也就是说,它知道与该地址对应的私钥)。
要看到此操作的实际效果,请停止现有的 IdP 和 SP,然后运行以下命令:
1git checkout eth-signatures2pnpm install3pnpm start然后浏览到 SPopens in a new tab 并按照指示操作。
请注意,此时我们不知道如何从以太坊地址获取电子邮件地址,因此我们向 SP 报告 <ethereum address>@bad.email.address。
详细说明
更改在前一个图中的步骤 4-5。
我们唯一更改的文件是 idp.mts。 以下是更改的部分。
1import { v4 as uuidv4 } from 'uuid'2import { verifyMessage } from 'viem'我们需要这两个额外的程序库。 我们使用 uuidopens in a new tab 来创建随机数opens in a new tab值。 值本身不重要,重要的是它只使用一次。
viemopens in a new tab 程序库让我们能够使用以太坊定义。 这里我们需要它来验证签名是否确实有效。
1const loginPrompt = "要访问服务提供者,请签署此随机数:"钱包会请求用户允许签署该信息。 只包含随机数的信息可能会让用户感到困惑,所以我们加入了这个提示。
1// 在此处保留 requestID2let nonces = {}我们需要请求信息才能对其做出响应。 我们可以在请求时发送它(步骤 4),然后在响应时接收它(步骤 5)。 然而,我们不能信任从浏览器获得的信息,因为它可能受恶意用户的控制。 所以最好将其存储在这里,以随机数作为密钥。
请注意,为简单起见,我们在此将其作为变量。 然而,这有几个缺点:
- 我们容易受到拒绝服务攻击。 恶意用户可以多次尝试登录,从而耗尽我们的内存。
- 如果 IdP 进程需要重新启动,我们就会丢失现有的值。
- 我们无法在多个进程之间进行负载均衡,因为每个进程都有自己的变量。
在生产系统上,我们会使用数据库并实现某种过期机制。
1const getSignaturePage = requestId => {2 const nonce = uuidv4()3 nonces[nonce] = requestId创建一个随机数,并存储 requestId 以备将来使用。
1 return `2<html>3 <head>4 <script type="module">此 JavaScript 在页面加载时自动执行。
1 import { createWalletClient, custom, getAddress } from 'https://esm.sh/viem'我们需要 viem 中的几个函数。
1 if (!window.ethereum) {2 alert("请安装 MetaMask 或兼容的钱包,然后重新加载")3 }我们只有在浏览器上有钱包的情况下才能工作。
1 const [account] = await window.ethereum.request({method: 'eth_requestAccounts'})向钱包 (window.ethereum) 请求帐户列表。 假设至少有一个,并且只存储第一个。
1 const walletClient = createWalletClient({2 account,3 transport: custom(window.ethereum)4 })创建一个钱包客户端opens in a new tab以与浏览器钱包交互。
1 window.goodSignature = () => {2 walletClient.signMessage({3 message: "${loginPrompt}${nonce}"请求用户签署一条信息。 因为整个 HTML 都在一个模板字符串opens in a new tab中,我们可以使用在 idp 进程中定义的变量。 这是序列图中的步骤 4.5。
1 }).then(signature => {2 const path= "/${config.idpDir}/signature/${nonce}/" + account + "/" + signature3 window.location.href = path4 })5 }重定向到 /idp/signature/<nonce>/<address>/<signature>。 这是序列图中的步骤 5。
1 window.badSignature = () => {2 const path= "/${config.idpDir}/signature/${nonce}/" + 3 getAddress("0x" + "BAD060A7".padEnd(40, "0")) + 4 "/0x" + "BAD0516".padStart(130, "0")5 window.location.href = path6 }签名由浏览器发回,而浏览器可能是恶意的(没有什么能阻止你在浏览器中直接打开 http://localhost:3001/idp/signature/bad-nonce/bad-address/bad-signature)。 因此,验证 IdP 进程能否正确处理不良签名非常重要。
1 </script>2 </head>3 <body>4 <h2>请签名</h2>5 <button onClick="window.goodSignature()">6 提交一个好的(有效的)签名7 </button>8 <br/>9 <button onClick="window.badSignature()">10 提交一个坏的(无效的)签名11 </button>12 </body>13</html> 14`15}显示全部其余的只是标准 HTML。
1idpRouter.get("/signature/:nonce/:account/:signature", async (req, res) => {这是序列图中的步骤 5 的处理程序。
1 const requestId = nonces[req.params.nonce]2 if (requestId === undefined) {3 res.send("错误的随机数")4 return ;5 } 6 7 nonces[req.params.nonce] = undefined获取请求 ID,并从 nonces 中删除该随机数以确保其不能被重用。
1 try {因为签名无效的方式有很多,所以我们将此包装在 try ... 中。 catch 区块以捕获任何抛出的错误。
1 const validSignature = await verifyMessage({2 address: req.params.account,3 message: `${loginPrompt}${req.params.nonce}`,4 signature: req.params.signature5 })使用 verifyMessageopens in a new tab 实现序列图中的步骤 5.5。
1 if (!validSignature)2 throw("签名无效")3 } catch (err) {4 res.send("错误:" + err)5 return ;6 }处理程序的其余部分与我们之前在 /loginSubmitted 处理程序中所做的相同,只有一个小改动。
1 const loginResponse = await idp.createLoginResponse(2 .3 .4 .5 {6 email: req.params.account + "@bad.email.address"7 }8 );我们没有实际的电子邮件地址(我们将在下一节中获取),因此暂时返回以太坊地址,并明确标记它不是电子邮件地址。
1// 用于登录请求的 IdP 端点2idpRouter.post(`/login`,3 async (req, res) => {4 try {5 // 权宜之计,因为我无法让 parseLoginRequest 工作。6 // const loginRequest = await idp.parseLoginRequest(sp, 'post', req)7 const samlRequest = xmlParser.parse(Buffer.from(req.body.SAMLRequest, 'base64').toString('utf-8'))8 res.send(getSignaturePage(samlRequest["samlp:AuthnRequest"]["@_ID"]))9 } catch (err) {10 console.error('处理 SAML 响应时出错:', err);11 res.status(400).send('SAML 身份验证失败');12 }13 }14)显示全部在步骤 3 的处理程序中,现在使用 getSignaturePage 代替 getLoginPage。
获取电子邮件地址
下一步是获取服务提供者请求的标识符,即电子邮件地址。 为此,我们使用以太坊认证服务 (EAS)opens in a new tab。
获取认证的最简单方法是使用 GraphQL 应用程序接口opens in a new tab。 我们使用以下查询:
1query GetAttestationsByRecipient {2 attestations(3 where: { 4 recipient: { equals: "${getAddress(ethAddr)}" }5 schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }6 }7 take: 18 ) { 9 data10 id11 attester12 }13}显示全部此 schemaIdopens in a new tab 仅包含一个电子邮件地址。 此查询请求此模式的认证。 认证的主题称为 recipient。 它总是一个以太坊地址。
警告:我们在这里获取认证的方式存在两个安全问题。
-
我们访问的 API 端点
https://optimism.easscan.org/graphql是一个中心化组件。 我们可以获取id属性,然后在链上进行查找以验证认证是否真实,但 API 端点仍然可以通过不告知我们某些认证来对其进行审查。这个问题并非无法解决,我们可以运行自己的 GraphQL 端点,并从链上日志中获取认证,但这对于我们的目的来说过于繁琐。
-
我们不查看认证者身份。 任何人都可以向我们提供虚假信息。 在实际实现中,我们会有一组受信任的认证者,并且只查看他们的认证。
要看到此操作的实际效果,请停止现有的 IdP 和 SP,然后运行以下命令:
1git checkout email-address2pnpm install3pnpm start然后提供您的电子邮件地址。 您有两种方法可以做到这一点:
-
使用私钥导入钱包,并使用测试私钥
0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80。 -
为您的电子邮件地址添加认证:
-
在认证浏览器中浏览到该模式opens in a new tab。
-
单击用模式认证。
-
输入您的以太坊地址作为接收者,您的电子邮件地址作为 email address,并选择链上。 然后单击创建认证。
-
在您的钱包中批准交易。 您需要在 Optimism 区块链opens in a new tab上有一些 ETH 来支付燃料费。
-
无论哪种方式,完成后请浏览至 http://localhost:3000opens in a new tab 并按照指示操作。 如果您导入了测试私钥,您收到的电子邮件将是 test_addr_0@example.com。 如果您使用自己的地址,它应该是您认证的任何内容。
详细说明
新的步骤是 GraphQL 通信,即步骤 5.6 和 5.7。
同样,以下是 idp.mts 的更改部分。
1import { GraphQLClient } from 'graphql-request'2import { SchemaEncoder } from '@ethereum-attestation-service/eas-sdk'导入我们需要的程序库。
1const graphqlEndpointUrl = "https://optimism.easscan.org/graphql"每个区块链都有一个单独的端点opens in a new tab。
1const graphqlClient = new GraphQLClient(graphqlEndpointUrl, { fetch })创建一个新的 GraphQLClient 客户端,我们可以用它来查询端点。
1const graphqlSchema = 'string emailAddress'2const graphqlEncoder = new SchemaEncoder(graphqlSchema)GraphQL 只给我们一个包含字节的不透明数据对象。 为了理解它,我们需要模式。
1const ethereumAddressToEmail = async ethAddr => {一个从以太坊地址转换到电子邮件地址的函数。
1 const query = `2 query GetAttestationsByRecipient {这是一个 GraphQL 查询。
1 认证(我们正在寻找认证。
1 where: { 2 recipient: { equals: "${getAddress(ethAddr)}" }3 schemaId: { equals: "0xfa2eff59a916e3cc3246f9aec5e0ca00874ae9d09e4678e5016006f07622f977" }4 }我们想要的认证是那些在我们模式中,接收者是 getAddress(ethAddr) 的认证。 getAddressopens in a new tab 函数确保我们的地址具有正确的校验和opens in a new tab。 这对于 GraphQL 来说是必要的,因为它是大小写敏感的。 "0xBAD060A7"、"0xBad060A7" 和 "0xbad060a7" 是不同的值。
1 take: 1无论我们找到多少认证,我们都只想要第一个。
1 ) {2 data3 id4 attester5 }6 }`我们想要接收的字段。
attester:提交认证的地址。 通常这用于决定是否信任认证。id:认证 ID。 您可以使用此值在链上读取认证opens in a new tab以验证 GraphQL 查询中的信息是否正确。data:模式数据(在本例中为电子邮件地址)。
1 const queryResult = await graphqlClient.request(query)23 if (queryResult.attestations.length == 0)4 return "no_address@available.is"如果没有认证,则返回一个明显不正确但对服务提供者来说看似有效的值。
1 const attestationDataFields = graphqlEncoder.decodeData(queryResult.attestations[0].data)2 return attestationDataFields[0].value.value3}如果存在值,则使用 decodeData 解码数据。 我们不需要它提供的元数据,只需要值本身。
1 const loginResponse = await idp.createLoginResponse(2 sp, 3 {4 .5 .6 .7 },8 "post",9 {10 email: await ethereumAddressToEmail(req.params.account)11 }12 );显示全部使用新函数获取电子邮件地址。
去中心化呢?
在这种配置下,只要我们依赖可信的认证者进行以太坊到电子邮件地址的映射,用户就无法冒充他们不是的人。 然而,我们的身份提供者仍然是一个中心化组件。 任何拥有身份提供者私钥的人都可以向服务提供者发送虚假信息。
可能有一个使用多方计算 (MPC)opens in a new tab 的解决方案。 我希望在未来的教程中写到它。
总结
采用像以太坊签名这样的登录标准面临着一个“先有鸡还是先有蛋”的问题。 服务提供商希望吸引尽可能广泛的市场。 用户希望能够访问服务,而不必担心支持他们的登录标准。 创建适配器,例如以太坊 IdP,可以帮助我们克服这个障碍。
点击此处查看我的更多作品opens in a new tab。
页面最后更新: 2025年11月23日





