Web3 时代:如何使用 MetaMask 钱包实现一键登录

April 30, 2022

前言

在 web2 时代,几乎所有的网站和应用都需要自行保存用户的账户信息,且每家的账号不通用,用户需要记住所有的这些账号信息,为了管理这些账号密码信息,业界甚至诞生了很多类似 1Password 这样的密码管理器帮助用户管理繁多的账号信息。即使后来大多数网站都接入了基于 OAuth2 的社交媒体账号登录功能,但本质上依然存在上述问题,且最重要的是这些账号和账号产生的数据"可以不属于"用户所有。

到了目前的 web3 时代就明显不一样了,如果你经常使用 web3 相关的产品,可能已经发现这些 web3 的网站和应用基本都不提供"注册"功能,且登录的时候都是通过连接加密钱包来完成的,用户不需要输入用户名密码。下图是一个典型的 web2 应用和 web3 应用的登录页面对比图。

web2时代的登录页面对比web3时代的登录页面

以太坊钱包登录的原理

现如今的加密钱包帐户主要是基于公私钥加密机制,用户的帐户地址是公开的,这相当于公钥,钱包可以使用用户的私钥对一串数据进行签名从而拿到一串签名数据,之后其他用户可以在没有你的私钥的情况下通过密码学方式验证某一串数据是否和指定的签名相匹配。如果匹配则表示该用户是帐户的持有者。

我们用 Ethers.js 做个简单的代码示例:

本文使用的 ethers 版本是 ^5

import { ethers } from 'ethers'

// 这里省略了 web3Modal 的配置相关代码
const provider = await web3Modal.connect()
await provider.enable() // 连接到钱包,这一步钱包会弹出对话框向用户询问是否连接到当前站点

// The "any" network will allow spontaneous network changes
const web3Provider = new ethers.providers.Web3Provider(provider, 'any')

// The MetaMask plugin also allows signing transactions to
// send ether and pay to change state within the blockchain.
// For this, you need the account signer...
const signer = web3Provider.getSigner()

const message = `请签名证明你是钱包账户的拥有者\n\nNonce\n${Date.now()}`

// signMessage 方法会让钱包弹出对话框询问用户是否同意签名,用户同意后我们就可以拿到签名
const signature = await signer.signMessage(message)

// 调用后端登录 API,把当前用户的钱包地址以及上面的 message 和 signature 传给后端
loginApi({ signature, message, address })

有了签名后,我们在服务端使用 verifyMessage 方法验证 message 是否和签名匹配,如果签名是匹配的,则 verifyMessage 方法返回的地址肯定会和用户的钱包地址一致(比较过程需要忽略大小写)

// ethers 可以在 Node.js 和浏览器环境运行
import { ethers } from 'ethers'

function loaginHandler(req, res, next) {
  const { message, signature, address } = req.body

  const recoveredAddress = ethers.utils.verifyMessage(message, signature)
  if (recoveredAddress.toLowerCase() === address.toLoawerCase()) {
    res.json({ message: '登录成功了' })
  }
}

以上代码我们通过 Ethers.js 提供的 signer.signMessage()ethers.utils.verifyMessage() 这两个方法验证了用户是否为某个地址的持有者。对于验证通过的用户,我们就可以用 JWT Token 之类的方法给用户颁发鉴权信息,从而实现登录功能。

更详细的流程如下:

  1. 连接钱包
  2. 前端从服务端获取 nonce,或者自己生成
  3. 前端生成要进行签名的 message,这个 message 里要包含上一步生成的 nonce
  4. 前端调用 signMessage(message) 方法把 message 传进去让用户签名
  5. 前端拿到签名后,把签名、message、钱包地址这三项提交给登录 API
  6. 后端先检查 message 里面 nonce 是否有效
  7. nonce 检查通过后,后端调用 verifyMessage(message, signature) 方法检查此方法返回的地址是否和用户提供的一致
  8. 如果地址一致,则说明用户是此地址的持有者,此时就登录成功了,给前端返回鉴权 token

在生产环境下调用 signMessage 时你应该在 message 中包含一个一次性的 Nonce 进去,并在服务端检查 Nonce 是否被使用,从而避免重放攻击。
当你比较两个钱包地址是否相等或者将钱包地址保存在数据库之前,不要忘记将地址全部转为小写,避免大小写不一致导致意外情况。

使用 web3Modal 和 WalletConnect 兼容更多钱包

MetaMask 是目前用户最多的钱包软件,不过像 Coinbase Wallet、Trust Wallet、imToken、Zerion 也有不小的用户群体。总之目前的钱包软件非常的多。作为开发者我们不需要一个个去适配这些钱包。大部分钱包都实现了 WalletConnect 协议,我们只要兼容 WalletConnect 就可以变相的兼容大部分钱包。再配合 web3Modal 这个 NPM 包,简单的配置一下参数就能兼容市面上几乎所有的钱包。

接入 WalletConnect 必须要提供一个 infuraId,在 infura.io 免费创建一个项目即可拿到 infuraId,免费版本每天限制调用次数不超过10万次,超出需要付费,用户量大的话接入 WalletConnect 会多一些额外成本。如果用户选择用 MetaMask 连接就不消耗你的 infura 额度了。

import { useEffect } from 'react'
import { ethers } from 'ethers'
import Web3Modal, { CHAIN_DATA_LIST, ChainData } from 'web3modal'
import WalletConnect from '@walletconnect/web3-provider'
import CoinbaseWalletSDK from '@coinbase/wallet-sdk'
import { CoinbaseWalletSDKOptions } from '@coinbase/wallet-sdk/dist/CoinbaseWalletSDK'

// 只有 walletconnect 和 coinbase wallet 需要提供 infuraId
const infuraId = '替换为你自己的 infuraId'

const providerOptions = {
  walletconnect: {
    package: WalletConnect,
    options: {
      infuraId,
      rpc: {
        56: 'https://bscrpc.com', // Binance Smart Chain
        97: 'https://data-seed-prebsc-2-s3.binance.org:8545', // Binance Smart Chain Testnet
        137: 'https://rpc-mainnet.matic.network', // Polygon
        42161: 'https://arb1.arbitrum.io/rpc', // Arbitrum One
        421611: 'https://rinkeby.arbitrum.io/rpc', // Arbitrum Rinkeby
        43114: 'https://api.avax.network/ext/bc/C/rpc', // Avalanche C-Chain
      },
    },
  },
  // 这个是 Coinbase Wallet
  walletlink: {
    package: CoinbaseWalletSDK,
    options: {
      appName: '你的应用名称',
      appLogoUrl: '',
      infuraId,
    } as CoinbaseWalletSDKOptions,
  },
}

function App() {
  const signerRef = useRef<ethers.Signer | null>(null)
  const web3ModalRef = useRef<Web3Modal | null>(null)

  // 初始化 Web3Modal
  useEffect(() => {
    const web3Modal = new Web3Modal({
      network: '', // optional
      cacheProvider: true, // optional
      providerOptions, // required
    })
    web3ModalRef.current = web3Modal
    if (web3Modal.cachedProvider) {
      // 如果有上次使用的钱包,自动尝试连接钱包
      onConnectWallet()
    } else {
      // 如果没有上次使用的钱包,则让用户手动连接钱包
    }
  }, [])

  // 连接到钱包,拿到用户钱包地址和当前选择的网络
  async function connectWallet() {
    const web3Modal = web3ModalRef.current!
    if (!web3Modal) {
      // web3Modal 不存在
      return
    }

    const provider = await web3Modal.connect()
    await provider.enable() // 调用此方法后会唤起钱包弹框询问用户是否要连接到当前站点
    
    const web3Provider = new ethers.providers.Web3Provider(provider, 'any')
    const signer = web3Provider.getSigner()
    signerRef.current = signer

    const [publicAddress, chainId] = await Promise.all([signer.getAddress(), signer.getChainId()])

    return { publicAddress, chainId }
  }

  function onConnectWallet() {
    return connectWallet()
      .then((meta) => {
        if (meta) {
          console.log('拿到的钱包信息', meta)
        } else {
          // 没有获取到钱包信息
        }
      })
      .catch((err) => {
        localStorage.removeItem('walletconnect')
        web3ModalRef.current?.clearCachedProvider()
        console.error('连接钱包出错', err)
      })
  }

  return (
    <div>
      <button onClick={onConnectWallet}>连接到钱包</button>
    </div>
  )
}

总结

基于钱包登录的方案,用户的帐户完全由用户自己掌控,一个账户可以在多个平台使用。用户不用再担心自己的密码是否被网站泄露,帐户安全完全由自己掌控,且注册和登录过程变得异常简单。需要注意的是,自动化批量创建钱包帐户的成本非常低,必要时网站可以在登录时配合 reCAPTCHA 之类的服务过滤机器人用户,或者要求用户绑定 Gmail 之类的邮箱,以此降低垃圾账户的数量。

参考

如果你喜欢我的内容,请考虑请我喝杯咖啡☕吧,非常感谢🥰 。

If you like my contents, please support me via BuyMeCoffee, Thanks a lot.