概述 在目前的网站用户体系搭建中,社会化登录主要依赖于Google、QQ等服务商,中心化趋势较强。在Web3中,作为网站建设者的我们应该考虑使用去中心化的登录方式。在此篇博客中,我们将以使用MetaMask 钱包中的API为例介绍去中心化登录的基本方式。
我会介绍前端页面的搭建和后端服务的设计。我选择了Vue
作为前端页面的框架,同时使用了MetaMask
插件提供的API接口。在后端为降低成本,我采用了CloudFlare Worker 作为后端,主要使用Worker
和KV
服务。
本文的主要思路来自这篇文章 ,但根据最新的MetaMsk的API和cloudflare worker的新特性,我对此文章的内容进行了一些改进,但总体思路是相同的。
登录流程 由于以太坊等加密货币自身建立在非对称加密基础上,我们应该考虑使用使用非对称加密的功能来实现登录。登录的本质是用户对个人身份的证明,在过去的登录方式中,我们采用密码、手机或邮箱验证码实现。而在Web3中,以太坊等区块链天然的提供了一种工具实现这一过程,即签名。签名 是指用户使用私钥对数据进行签名,签名后的数据可以用公钥来验证。我们可以使用MetaMask中的签名API实现此流程,您可以查阅此页面 来查看所有属于MetaMask的签名API,但在此教程中我们会选择最新的signTypedData_v4
API,因为此签名方法更加安全且对用户友好。
用户对什么数据进行签名?这应该由开发者决定,签名内容应该在后端生成后发给前端,之后前端用户对其进行签名,再将签名后的数据与个人以太坊账户地址一同回传给后端,后端对数据进行验签,判断数据是否与以太坊账户地址相同。如果相同,则返回登录凭证,在此教程中,我们将返回JWT。如果不同,则返回登录失败消息。总体流程如下图所示:
登录按钮实现(前端) 我们首先实现登录的第一步,实现请求后端获取签名内容和获取用户的以太坊账户地址。至于后端如何实现签名内容生成我们将在后文介绍。
首先给出Vue的基本框架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 <template> <div> <button @click="login" v-if="metaMaskSupport"> Login </button> </div> </template> <script> import axios from "axios"; export default { data() { return { metaMaskSupport: false, ethAccount: null, sign: null, nonces: null, } }, mounted() { this.metaMaskSupport = window.ethereum && window.ethereum.isMetaMask; }, methods: { login() { //具体实现方式将在下文给出 } } } </script>
为了方便后续代码编写,我们导入了知名网络请求库axios
,您需要使用以下命令安装:
我们在data()
中定义了一些数据,其中包括:
在mounted()
中,我们完成了基本的初始化,使用window.ethereum && window.ethereum.isMetaMask
赋值给metaMaskSupport
,这段代码用于判断用户是否安装了MetaMask插件。
接下来,我们会在完善login()
方法的第一部分,获取用户的以太坊账户地址。
1 2 3 4 5 6 window .ethereum .request ({ method : 'eth_requestAccounts' }).then ( accounts => { this .ethAccount = accounts[0 ] console .log (this .ethAccount ); } )
该段代码主要基于MetaMask文档中的此部分 。在此处,我们使用了JavaScript中的异步请求,并且使用MetaMask APIwindow.ethereum.request
方法获取用户的以太坊账户地址并将其赋值给this.ethAccount
,最终在console
中输出用户的以太坊账户地址。
以上基本完成了登录的第一步,接下来我们会介绍后端实现签名内容生成的部分。
签名内容生成(后端) 基础环境搭建 为了代码内容的简单化,我们在此处假设您已经安装了Cloudflare Wrangler并已经搭建了基本的CloudFlare Worker
的开发环境。如果您对此不熟悉,您可以查阅此文档 。
下列代码的前提是您已经完成了wrangler的登录,具体内容可以参考此文档 。
首先使用此命令创建开发环境:
在完成项目初始化后,您获得的目录结构应该如下图所示:
以下内容主要关于kv
的绑定问题,如果您认为我的表述较为奇怪,您可以自行查阅cloudflare的kv文档
然后您需要绑定您的kv
到wrangler
代码中使用kv
数据库,可以使用下述命令:
1 wrangler kv:namespace create web3login
根据提示,将此代码运行后的结果添加到wrangler.toml
中,如下:
1 2 3 4 5 name = "bloguse" main = "src/index.js" compatibility_date = "2022-06-08" kv_namespaces = [{ binding = "web3login" , id = "自行替换" }]
由于Worker的自身的限制,为了在dev中使用kv
,我们需要在终端键入以下命令:
1 wrangler kv:namespace create web3login --preview
根据提示,将此代码运行后的结果添加到wrangler.toml
中,如下:
1 2 3 4 5 name = "bloguse" main = "src/index.js" compatibility_date = "2022-06-08" kv_namespaces = [{ binding = "web3login" , id = "自行替换" , preview_id = "自行替换" }]
我们也需要安装一些必要的npm包,主要需要eth-sig-util
,您可以通过此链接 查阅它的开源仓库,你也可以通过这个链接 查阅它的文档。
使用以下命令,您可以安装此库:
1 npm install @metamask/eth-sig-util
或
1 yarn add @metamask/eth-sig-util
nonces生成及存储 在前述内容中,我们已经得到了用户的ethAccount
和基础开发环境的搭建,接下来我们考虑如何生成签名所需要的nonces
。为了验证用户签名是否正确,我们需要存储用户的签名nonces
和以太坊地址,这是一个简单的key-value数据,我们可以直接使用CloudFlare
开发的kv
数据库。
此处我们假设前端返回的数据结构如下:
1 2 3 { "from" : this.ethAccount }
即仅向后端返回ethAccount
字段。
以此数据结构为基础,我们给出后端的实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 addEventListener ("fetch" , event => { event.respondWith (handleRequest (event.request )) }) async function handleRequest (request ) { if (request.method === "PUT" ) { let data = await request.json (); let key = data.from ; let nonces = Math .floor (Math .random () * 1000000 ) await loginKV.put (key, nonces, { expirationTtl : 120 }) console .log (`${key} has been logged in` ) return new Response (JSON .stringify ({ "nonces" : nonces, "key" : key }), { headers : { 'content-type' : 'application/json;charset=UTF-8' , 'Access-Control-Allow-Origin' : '*' }, }); } else if (request.method === "OPTIONS" ) { const responseHeaders = new Headers (); responseHeaders.set ('Access-Control-Allow-Origin' , '*' ); responseHeaders.set ('Access-Control-Allow-Methods' , 'GET, POST, PUT, DELETE, OPTIONS' ); responseHeaders.set ('Access-Control-Allow-Headers' , 'Origin, X-Requested-With, Content-Type, Accept' ); responseHeaders.set ('Access-Control-Max-Age' , '86400' ); return new Response ("" , { headers : responseHeaders }) } }
addEventListener
功能为接受前端的请求,并将其转交给handleRequest
函数进行处理
handleRequest
函数的主要功能是:
接受PUT
请求返回nonces
并将其存储在KV
中,nonces
使用随机数生成,如果您需要严格的密码学保证,您可以选择密码学随机数生成的crypto.getRandomValues
函数,具体调用发送可以参考CloudflareWorker文档 。然后使用Worker
直接调用kv
的函数将此值直接推进数据库中,具体函数可参考文档 ,值得注意的是此处expirationTtl
(过期删除时间)设置为120秒。
接受OPTIONS
请求处理跨域请求,跨域请求是个较为复杂的主题,您可以参考此链接 来了解更多关于跨域请求的内容。
完成代码编写后,我们需要进行一次测试以保证代码的正确运行,在此处我选择使用Postman
作为调试工具,您可以选择其他工具进行调试。
首先,启动wrangler
的开发功能,使用wrangler dev
启动测试环境。使用Postman向http://localhost:8787
发送PUT
请求,要求body
符合上述数据结构。
Postman截图如下:
wrangler终端输出如下
通过Postman或wrangler控制台输出,我们可以判断此段代码是可以正常运行的。
请求nonces与签名(前端) 在上述内容中,我们完成了后端nonces
的生成,同时要求前端想后端发送PUT
请求并规定了数据结构,在此处我们将实现该功能。
获取nonces
我们会在前述login()
函数后进一步增加功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 window .ethereum .request ({ method : 'eth_requestAccounts' }).then ( accounts => { this .ethAccount = accounts[0 ] console .log (this .ethAccount ); } ).then ( () => { axios.put ("http://localhost:8787" , { "from" : this .ethAccount }).then (res => { this .nonces = res.data .nonces ; }).then (() => { console .log (this .nonces ); ) } )
由于window.ethereumh
和axios
中的所有函数均为异步调用,此处为了确保登录逻辑的正常进行使用了大量的Promise
内容,您可以参阅 《JavaScript权威指南》第13章 了解更多关于期约调用的内容。如果您对axios.put
函数不熟悉,您可以参阅此文档 。此处代码较为简单,不再给出详细解释。
进行签名 我们使用MetaMask
的signTypedData_v4
对数据进行签名,在此处我们给出简单的API解释,如果您需要更加详细的内容请参阅MetaMask signTypedData_v4文档
首先,我们需要知道signTypedData_v4
的签名数据的定义来自EIP-712
以太坊提案,详细内容可以参考此文档 。在此处我们仅仅指出本项目所需要的内容。由于使用json-schema
解释较为抽象,此处我们给出MetaMask文档同时也是以太坊EIP-712文档中给出的一个签名信息示例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 { domain : { chainId : 1 , name : 'Ether Mail' , verifyingContract : '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC' , version : '1' , }, message : { contents : 'Hello, Bob!' , attachedMoneyInEth : 4.2 , from : { name : 'Cow' , wallets : [ '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826' , '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF' , ], }, to : [ { name : 'Bob' , wallets : [ '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB' , '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57' , '0xB0B0b0b0b0b0B000000000000000000000000000' , ], }, ], }, primaryType : 'Mail' , types : { EIP712Domain : [ { name : 'name' , type : 'string' }, { name : 'version' , type : 'string' }, { name : 'chainId' , type : 'uint256' }, { name : 'verifyingContract' , type : 'address' }, ], Group : [ { name : 'name' , type : 'string' }, { name : 'members' , type : 'Person[]' }, ], Mail : [ { name : 'from' , type : 'Person' }, { name : 'to' , type : 'Person[]' }, { name : 'contents' , type : 'string' }, ], Person : [ { name : 'name' , type : 'string' }, { name : 'wallets' , type : 'address[]' }, ], }, }
domain
字段中需要包含以下内容:
message
字段可以自行定义,我们在此处仅对以下内容签名:
1 2 3 4 { contents : "Login" , nonces : this .nonces , }
primaryType
该字段可以简单理解为message
字段的名字,可以进行自定义,此处我们将其命名为Login
。该字段的具体功能未找到权威解释,但此字段必须存在。
types
规定各个字段的具体类型,这些类型与一般编程语言的数据结构不太相同,您可以参考Soildty
语言的数据类型,可以参考此文档 。该字段要求对上述所有字段的类型进行定义。
最终,我们给出在我的代码中的签名内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 { domain : { chainId : window .ethereum .chainId , name : 'Login' , version : '1' }, message : { contents : 'Login' , nonces : this .nonces , }, primaryType : 'Login' , types : { EIP712Domain : [ { name : 'name' , type : 'string' }, { name : 'version' , type : 'string' }, { name : 'chainId' , type : 'uint256' }, ], Login : [ { name : 'contents' , type : 'string' }, { name : 'nonces' , type : 'uint256' }, ], }, }
获得此签名内容后,我们可以通过API十分简单的进行签名,具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 window .ethereum .request ({ method : 'eth_requestAccounts' }).then ( accounts => { this .ethAccount = accounts[0 ] console .log (this .ethAccount ); } ).then ( () => { axios.put ("http://localhost:8787" , { "from" : this .ethAccount }).then (res => { this .nonces = res.data .nonces ; }).then (() => { console .log (this .nonces ); const msgParams = { domain : { chainId : window .ethereum .chainId , name : 'Login' , version : '1' }, message : { contents : 'Login' , nonces : this .nonces , }, primaryType : 'Login' , types : { EIP712Domain : [ { name : 'name' , type : 'string' }, { name : 'version' , type : 'string' }, { name : 'chainId' , type : 'uint256' }, ], Login : [ { name : 'contents' , type : 'string' }, { name : 'nonces' , type : 'uint256' }, ], }, }; const from = this .ethAccount ethereum.request ({ method : 'eth_signTypedData_v4' , params : [from , JSON .stringify (msgParams)], }).then ((res ) => { this .sign = res; console .log (res); }) }) })
如前所述,在此代码中存在着丑陋的期约调用,如何您有兴趣可以将其更改为async/await
结构。
1 2 3 4 5 6 7 8 ethereum.request ({ method : 'eth_signTypedData_v4' , params : [from , JSON .stringify (msgParams)], }).then ((res ) => { this .sign = res; console .log (res); })
上述代码是签名计算的核心,较为简单。
回传签名内容 当完成前端签名后,我们需要将签名结果回传给后端,此处我们暂时不加解释的给出回传签名内容的具体结构(在下一节内容中,我们将解释为什么需要这些字段)。
1 2 3 4 5 { "chainId" : window .ethereum .chainId , "from" : this .ethAccount , "signature" : this .sign }
为了与之前的PUT
方法有所区分,此处使用POST
方法作为回传的方法。在此给出完整的前端代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 window .ethereum .request ({ method : 'eth_requestAccounts' }).then ( accounts => { this .ethAccount = accounts[0 ] console .log (this .ethAccount ); } ).then ( () => { axios.put ("http://localhost:8787" , { "from" : this .ethAccount }).then (res => { this .nonces = res.data .nonces ; }).then (() => { console .log (this .nonces ); const msgParams = { domain : { chainId : window .ethereum .chainId , name : 'Login' , version : '1' }, message : { contents : 'Login' , nonces : this .nonces , }, primaryType : 'Login' , types : { EIP712Domain : [ { name : 'name' , type : 'string' }, { name : 'version' , type : 'string' }, { name : 'chainId' , type : 'uint256' }, ], Login : [ { name : 'contents' , type : 'string' }, { name : 'nonces' , type : 'uint256' }, ], }, }; const from = this .ethAccount ethereum.request ({ method : 'eth_signTypedData_v4' , params : [from , JSON .stringify (msgParams)], }).then ((res ) => { this .sign = res; console .log (res); }).then ( () => { axios.post ("http://localhost:8787" , { "chainId" : window .ethereum .chainId , "from" : this .ethAccount , "signature" : this .sign }).then ( res => { if (res.data .verify ) { localStorage .setItem ("token" , res.data .token ); localStorage .setItem ("expire" , Date .now () + 3600000 ); localStorage .setItem ("userName" , this .ethAccount ); console .log ("登录成功" ); } else { console .log ("登录失败,请重新登录" ); } } ) }) }) } )
核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 axios.post ("http://localhost:8787" , { "chainId" : window .ethereum .chainId , "from" : this .ethAccount , "signature" : this .sign }).then ( res => { if (res.data .verify ) { localStorage .setItem ("token" , res.data .token ); localStorage .setItem ("expire" , Date .now () + 3600000 ); localStorage .setItem ("userName" , this .ethAccount ); console .log ("登录成功" ); } else { console .log ("登录失败,请重新登录" ); } } )
向后端使用POST
方法回传数据,同时接受后端数据,此处依旧不加解释的给出后端回传数据的结构:
登录成功的结果如下:
1 2 3 4 { "verify" : true , "token" : tokenLogin }
登录失败的结果如下:
同时本段代码也实现了在localStorage
中设置userName
、token
、expire
等字段,具体含义如下:
userName, 用户名。在此处为用户的以太坊地址
token,登录凭证,在下一节内容中我们将使用JWT
实现此功能
expire,登录过期时间,与token
类似将使用JWT
在后端实现
上述内容将直接存储在localStorage
中,也可以根据您的需求自行更改。
在下一节中,我们将处理客户端发送的数据并返回认证后的数据
后端验证签名并生成token 验证签名 在此过程中主要使用了recoverTypedSignature
函数,该函数的文档在这 。其主要作用是接受数据结构、签名、版本号三个参数返回用户的地址。
在此我们直接给出该部分的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import { recoverTypedSignature } from '@metamask/eth-sig-util' ;const data = await request.json ();const chainId = data.chainId ;const from = data.from ;const nonces = await loginKV.get (from );const msgParams = { domain : { chainId : chainId, name : 'Login' , version : '1' }, message : { contents : 'Login' , nonces : nonces, }, primaryType : 'Login' , types : { EIP712Domain : [ { name : 'name' , type : 'string' }, { name : 'version' , type : 'string' }, { name : 'chainId' , type : 'uint256' }, ], Login : [ { name : 'contents' , type : 'string' }, { name : 'nonces' , type : 'uint256' }, ], }, }; const signature = data.signature ;const version = "V4" ;const recoveredAddr = recoverTypedSignature ({ data : msgParams, signature : signature, version : version, });
该部分代码首先使用await request.json()
获取前端回传数据,并将数据赋值给变量。然后,使用kv
中自带的函数get
在键值数据库获取到该用户所签名的nonces
值。最后,根据前端定义的结构化数据形式编写后端数据,并直接调用recoverTypedSignature
函数获得用户的地址。
生成JWT
凭证 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import { toChecksumAddress } from 'ethereumjs-util' ;import { SignJWT } from 'jose' ;if (toChecksumAddress (recoveredAddr) === toChecksumAddress (from )) { const tokenLogin = await new SignJWT ({ "user_id" : from }) .setProtectedHeader ({ alg : 'HS256' , typ : 'JWT' }) .setExpirationTime ('1h' ) .sign (Buffer .from (SECRET_KEY , "utf8" )); console .log (`${from } has been ${tokenLogin} ` ) return new Response (JSON .stringify ({ "verify" : true , "token" : tokenLogin }), { headers : { 'content-type' : 'application/json;charset=UTF-8' , 'Access-Control-Allow-Origin' : '*' }, }); } else { return new Response (JSON .stringify ({ "verify" : false }), { headers : { 'content-type' : 'application/json;charset=UTF-8' , 'Access-Control-Allow-Origin' : '*' }, }); }
上述代码展示了获取用户的地址地址后,我们可以通过ethereumjs-util
中的toChecksumAddress
计算通过签名获得的地址与用户回传的地址是否相同。由于worker此类serverless
应用的无状态性,我们采用了一种无状态的登录授权方式,即JWT
。此处使用了jose
库中的SignJWT
函数,使用了HS256
作为签名方式。如果您想了解更多关于SignJWT
函数的相关内容,您可以参考文档 。
此处使用的SECRET_KEY
应存储在worker
的系统变量中,您可以在wrangler.toml
增加下列内容:
上述代码也显示了对前端的回传结果,直接使用if-else逻辑可以简单的完成此项工作。
总结 通过上述内容,您基本可以完成一个简单的metamask
登录系统,较为简单,如果您有任何不了解的内容,可以向我发送邮件。此项目的所有代码可以在这里 找到。