Web3表单签名验证:我用 wagmi 和 ethers 给 DApp 加了一个“免密登录”,踩坑记录全在这了

用 wagmi v2 踩坑两天,我终于搞懂了 Web3 表单签名验证

摘要

在做一个 DeFi 管理后台时,我需要让用户通过连接钱包签名一条消息来验证身份,代替传统的用户名密码。自己折腾了两天,遇到了签名数据格式不对、验签地址不匹配、前端状态管理混乱等问题。本文完整记录了我的实现方案和踩坑修复过程,代码可直接复用。

背景

上个月我在做一个 DeFi 项目的管理后台,功能是让用户查看自己的质押记录和收益。这个后台需要用户登录才能访问,但我不希望用传统的邮箱+密码,因为 Web3 用户更习惯用钱包来证明身份。我想实现一个"免密登录":用户连接钱包后,后端生成一个随机数(nonce),用户用私钥签名这个 nonce,前端把签名和钱包地址发给后端,后端验签通过后返回 JWT token。

听起来很简单对吧?但实际做起来,我遇到了好几个坑,尤其是前端签名和后端验签的格式不匹配,折腾了我整整两天。这篇文章就是我把整个过程复盘后写下来的,希望能帮到同样在做 Web3 登录的兄弟。

问题分析

我的思路很直接:用户连接钱包 → 前端请求后端获取 nonce → 用户用钱包签名 nonce → 前端把签名和地址发给后端 → 后端验签。

最初我用的是 ethers.js 的 signMessage 方法,前端签名后用 recoverAddress 验签。但问题来了:我用 MetaMask 签名后,后端验签一直返回地址不匹配。我打印了签名和地址,看起来都没问题,但就是验不过。

排查过程很痛苦。我先怀疑是后端验签逻辑错了,但用 ethers 的 verifyMessage 写了个本地测试脚本,同样的签名数据,本地验签能过,后端验签就不过。后来发现是后端用的验签库(比如 web3.js 或 ethers v5)对签名格式的处理和我前端不一致。更坑的是,有的签名会带 0x 前缀,有的不带;有的签名是 vrs 格式,有的是 r,s,v 分开的。这些细节差异直接导致验签失败。

还有一个坑:用户切换钱包后,我之前的签名状态没有清空,导致旧的签名被发到后端,验签当然失败。这些问题让我意识到,Web3 签名验证不是简单的"签名→验签"两步走,中间有很多细节需要处理。

核心实现

第一步:搭建签名上下文(使用 wagmi 管理钱包状态)

我选择用 wagmi v2 来管理钱包连接,因为它提供了 useSignMessageuseAccount 等 hooks,比我直接用 ethers.js 方便很多。但注意,wagmi v2 的 API 和 v1 有些不同,我刚升级时踩了坑。

首先安装依赖:

bash 复制代码
npm install wagmi viem @tanstack/react-query

然后创建一个 Web3Provider 包裹整个应用:

typescript 复制代码
// providers/Web3Provider.tsx
import { WagmiProvider, createConfig, http } from 'wagmi'
import { mainnet, sepolia } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { injected } from 'wagmi/connectors'

const config = createConfig({
  chains: [mainnet, sepolia],
  connectors: [injected()], // 支持 MetaMask 等注入钱包
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http(),
  },
})

const queryClient = new QueryClient()

export function Web3Provider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  )
}

这里有个坑:wagmi v2 不再默认包含 @wagmi/coresignMessage 方法,而是通过 useSignMessage hook 来调用。如果你没安装 @tanstack/react-queryuseSignMessage 会报错。我一开始没装,直接用了 useSignMessage,结果控制台报 No QueryClient set,找了半天才发现。

第二步:获取 nonce 并签名(核心逻辑)

接下来是签名逻辑。我创建了一个 useAuth hook,它封装了从获取 nonce 到签名再到发送验证的完整流程。

typescript 复制代码
// hooks/useAuth.ts
import { useSignMessage } from 'wagmi'
import { useAccount } from 'wagmi'
import { useState } from 'react'

export function useAuth() {
  const { address, isConnected } = useAccount()
  const { signMessageAsync } = useSignMessage()
  const [nonce, setNonce] = useState<string | null>(null)
  const [isLoading, setIsLoading] = useState(false)

  // 1. 请求后端获取 nonce
  const requestNonce = async () => {
    if (!address) throw new Error('钱包未连接')
    const response = await fetch('/api/auth/nonce', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address }),
    })
    const data = await response.json()
    // 注意:后端返回的 nonce 应该是一个随机字符串,比如 "Sign this message to prove you own 0x123...  Nonce: 12345"
    setNonce(data.nonce)
    return data.nonce
  }

  // 2. 签名 nonce
  const signNonce = async (nonceToSign: string) => {
    if (!isConnected || !address) {
      throw new Error('钱包未连接')
    }
    // 这里有个坑:wagmi 的 signMessageAsync 默认对消息进行 EIP-191 格式化,
    // 但如果你直接传原始字符串,它会自动加上 "\x19Ethereum Signed Message:\n" 前缀。
    // 后端验签时也需要用同样的方式处理,否则会验签失败。
    const signature = await signMessageAsync({ message: nonceToSign })
    return signature
  }

  // 3. 登录:组合 nonce 获取、签名、发送验证
  const login = async () => {
    if (!address) throw new Error('钱包未连接')
    setIsLoading(true)
    try {
      const nonce = await requestNonce()
      const signature = await signNonce(nonce)
      // 发送签名和地址到后端验证
      const response = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          address,
          signature,
          nonce, // 后端需要 nonce 来重新计算签名
        }),
      })
      if (!response.ok) {
        throw new Error('验签失败')
      }
      const { token } = await response.json()
      // 保存 token 到 localStorage 或 cookie
      localStorage.setItem('auth_token', token)
      return token
    } catch (error) {
      console.error('登录失败:', error)
      throw error
    } finally {
      setIsLoading(false)
    }
  }

  return { login, isLoading, isConnected }
}

注意这个细节signMessageAsync 默认会对消息进行 EIP-191 格式化(加上 \x19Ethereum Signed Message:\n 前缀)。如果你在后端用 ethers 的 verifyMessage 验签,它会自动处理这个前缀,所以没问题。但如果你在后端自己实现验签逻辑(比如用 eth_sign 的原始方式),就需要手动处理前缀。我当时后端用了 web3.js 的 eth.accounts.recover,它也需要前缀,但默认不自动加,导致前后端不一致。最后我统一用 ethers 的 verifyMessage 解决了。

第三步:后端验签(Node.js 示例)

为了让文章完整,我写一个简单的后端验签逻辑,使用 ethers v6:

typescript 复制代码
// server/verify.ts (Node.js + Express)
import { ethers } from 'ethers'

// 验签中间件
export function verifySignature(req, res, next) {
  const { address, signature, nonce } = req.body
  if (!address || !signature || !nonce) {
    return res.status(400).json({ error: '缺少参数' })
  }
  try {
    // ethers v6 的 verifyMessage 会自动处理 EIP-191 前缀
    const recoveredAddress = ethers.verifyMessage(nonce, signature)
    if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
      return res.status(401).json({ error: '验签失败,地址不匹配' })
    }
    // 验签通过,生成 JWT
    const token = generateJWT({ address })
    res.json({ token })
  } catch (error) {
    console.error('验签错误:', error)
    res.status(500).json({ error: '验签异常' })
  }
}

这里有个容易忽略的点:地址比较时一定要 toLowerCase(),因为 MetaMask 返回的地址可能大小写混用(checksum 格式),而 recoverAddress 返回的是全小写。我一开始没做大小写转换,导致明明签名正确,但地址不匹配,排查了好久。

第四步:前端 UI 组件(React 实现)

最后是前端的登录按钮组件,使用上面定义的 useAuth hook:

typescript 复制代码
// components/LoginButton.tsx
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { injected } from 'wagmi/connectors'
import { useAuth } from '../hooks/useAuth'

export function LoginButton() {
  const { address, isConnected } = useAccount()
  const { connect } = useConnect()
  const { disconnect } = useDisconnect()
  const { login, isLoading } = useAuth()

  const handleLogin = async () => {
    if (!isConnected) {
      // 如果未连接钱包,先连接
      connect({ connector: injected() })
      return
    }
    try {
      await login()
      alert('登录成功!')
    } catch (error) {
      alert('登录失败,请重试')
    }
  }

  return (
    <div>
      {isConnected ? (
        <div>
          <p>已连接钱包: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
          <button onClick={handleLogin} disabled={isLoading}>
            {isLoading ? '登录中...' : '签名登录'}
          </button>
          <button onClick={() => disconnect()}>断开钱包</button>
        </div>
      ) : (
        <button onClick={handleLogin}>连接钱包</button>
      )}
    </div>
  )
}

完整代码(可直接运行的示例)

为了方便你直接测试,我把前后端核心代码整合成一个最小可运行示例。前端使用 Vite + React + wagmi,后端使用 Node.js + Express。

前端完整代码

typescript 复制代码
// App.tsx
import { WagmiProvider, createConfig, http, useAccount, useConnect, useDisconnect, useSignMessage } from 'wagmi'
import { mainnet } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { injected } from 'wagmi/connectors'
import { useState } from 'react'

const config = createConfig({
  chains: [mainnet],
  connectors: [injected()],
  transports: { [mainnet.id]: http() },
})
const queryClient = new QueryClient()

function AuthComponent() {
  const { address, isConnected } = useAccount()
  const { connect } = useConnect()
  const { disconnect } = useDisconnect()
  const { signMessageAsync } = useSignMessage()
  const [isLoading, setIsLoading] = useState(false)

  // 模拟后端 nonce 获取(实际项目中应请求真实后端)
  const getNonce = async () => {
    // 这里模拟后端返回的 nonce
    return `Sign this message to prove you own ${address}. Nonce: ${Date.now()}`
  }

  const handleSignAndLogin = async () => {
    if (!address) return
    setIsLoading(true)
    try {
      // 1. 获取 nonce
      const nonce = await getNonce()
      // 2. 签名
      const signature = await signMessageAsync({ message: nonce })
      // 3. 发送到后端验证(模拟)
      console.log('发送到后端验证:', { address, signature, nonce })
      // 实际请求:await fetch('/api/auth/verify', { method: 'POST', body: JSON.stringify({ address, signature, nonce }) })
      alert('签名成功,请查看控制台日志')
    } catch (error) {
      console.error('签名失败:', error)
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <div style={{ padding: '20px' }}>
      <h1>Web3 签名登录演示</h1>
      {isConnected ? (
        <>
          <p>钱包地址: {address}</p>
          <button onClick={handleSignAndLogin} disabled={isLoading}>
            {isLoading ? '签名中...' : '签名登录'}
          </button>
          <button onClick={() => disconnect()}>断开连接</button>
        </>
      ) : (
        <button onClick={() => connect({ connector: injected() })}>连接 MetaMask</button>
      )}
    </div>
  )
}

export default function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <AuthComponent />
      </QueryClientProvider>
    </WagmiProvider>
  )
}

后端完整代码

typescript 复制代码
// server/index.js (Node.js + Express)
const express = require('express')
const { ethers } = require('ethers')
const app = express()
app.use(express.json())

// 存储已使用的 nonce(防止重放攻击)
const usedNonces = new Set()

app.post('/api/auth/verify', (req, res) => {
  const { address, signature, nonce } = req.body
  if (!address || !signature || !nonce) {
    return res.status(400).json({ error: '缺少参数' })
  }

  // 检查 nonce 是否已被使用
  if (usedNonces.has(nonce)) {
    return res.status(401).json({ error: 'nonce 已过期' })
  }

  try {
    const recoveredAddress = ethers.verifyMessage(nonce, signature)
    if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
      return res.status(401).json({ error: '验签失败,地址不匹配' })
    }
    // 标记 nonce 为已使用
    usedNonces.add(nonce)
    // 生成 JWT(简化版,生产环境应使用 jsonwebtoken 库)
    const token = `jwt_${Date.now()}_${address}`
    res.json({ token })
  } catch (error) {
    console.error('验签错误:', error)
    res.status(500).json({ error: '验签异常' })
  }
})

app.listen(3001, () => console.log('后端服务运行在 http://localhost:3001'))

踩坑记录

  1. 签名格式不匹配 :前端用 wagmi 的 signMessageAsync 签名,后端用 web3.js 的 eth.accounts.recover 验签,一直失败。后来发现 web3.js 的 recover 方法默认不处理 EIP-191 前缀,需要手动加上 \x19Ethereum Signed Message:\n。我最终统一用 ethers 的 verifyMessage 解决了。

  2. 地址大小写问题 :MetaMask 返回的地址是 checksum 格式(如 0xAbCd...),而 ethers.verifyMessage 返回的是全小写。直接比较 === 会失败。必须 toLowerCase() 后再比较。

  3. nonce 重放攻击 :刚开始我没做 nonce 去重,同一个 nonce 可以反复使用。后来在服务端加了 usedNonces Set 来记录已使用的 nonce,并设置过期时间(比如 5 分钟后自动清除)。

  4. 钱包切换后状态未重置 :用户连接钱包 A 签名后,切换钱包 B 再点击登录,会发送钱包 A 的签名和钱包 B 的地址。解决方案是在 useAccountaddress 变化时清空签名状态,或者在登录前检查 address 是否与签名时的地址一致。

小结

Web3 签名验证的核心就是"前端签名、后端验签",但细节决定成败:签名格式、地址大小写、nonce 防重放、状态管理。如果你也遇到类似问题,建议先统一前后端使用的验签库(推荐 ethers),然后严格按 EIP-191 标准处理签名。如果想深入,可以研究 EIP-712 结构化签名和 ERC-1271 合约验签,适合更复杂的场景。

相关推荐
用户6990304848751 小时前
try catch使用场景 处理同步代码错误兼容用的
javascript·uni-app
雪碧聊技术1 小时前
Tree.js是什么?一文讲透
开发语言·javascript·ecmascript
VidDown2 小时前
VidDown 工具站:免费、本地优先的开发者工具箱
javascript·编辑器·音视频·视频编解码·视频
触底反弹3 小时前
🚀 手把手用 HTML5 Canvas 从零打造飞机大战游戏,代码全开源!
前端·javascript·canvas
DJ斯特拉3 小时前
axios快速使用
开发语言·前端·javascript
智通3 小时前
可取消的异步任务与 AbortController
javascript
Hilaku4 小时前
AI 写代码越快,为什么 Code Review 越不能省?
前端·javascript·程序员
HjhIron5 小时前
CSS 3D 世界:从盒子模型到三维空间动画
javascript·css
VidDown5 小时前
显卡处理视频技术详解:从硬解码到 NVENC,GPU 如何让视频处理起飞?
javascript·编辑器·音视频·视频编解码·视频