Web3表单签名验证:我如何用 wagmi 和 siwe 让用户“无密码”登录

11 阅读1分钟

背景:用户提交地址,后端凭什么相信?

几个月前,我在做一个 DeFi 策略管理平台的前端。用户可以在上面创建“自动复投”策略,然后通过我们的合约执行。流程很简单:前端收集用户输入的策略参数(比如目标池地址、复投频率),然后调用合约。

但问题出在“用户身份”上。后端需要记录每个用户创建了哪些策略,但用户并没有注册流程,也没有密码。他们只是连接了钱包(MetaMask 或 WalletConnect),然后直接操作。后端收到的请求里,用户传一个 userAddress 字段,比如 0x1234...

我当时就想:这太不安全了。如果某个恶意用户伪造一个请求,把 userAddress 改成别人的地址,后端怎么知道这个地址真的是当前操作者?更糟的是,我们的后端还依赖这个地址来查询用户的历史数据,如果地址被篡改,数据就全乱了。

我需要一种方法:让后端能够验证“当前请求确实来自某个地址的持有者”,而且这个过程不能依赖密码,必须完全基于区块链钱包的签名机制。

问题分析:为什么简单的签名不行?

我最初的想法很简单:让前端用 ethers.js 对一段固定字符串签名,然后把签名和地址一起发给后端,后端用 ethers.utils.verifyMessage 验证。

// 最初的错误思路
const message = "I am the owner of this address";
const signature = await signer.signMessage(message);
// 然后发 { address, signature } 给后端

这看起来没问题,但实际跑起来就发现一堆坑:

  1. 重放攻击:如果签名被截获,攻击者可以重复使用这个签名来冒充用户。因为消息是固定的,签名永远有效。
  2. 过期问题:没有时间戳,后端不知道这个签名是什么时候签的。如果用户忘记断开连接,别人拿到这个签名可以一直用。
  3. 跨域问题:如果用户在不同 dApp 上签名了同样的消息,攻击者可以拿到签名后在我们的后端使用。

我当时就踩了这个坑:上线第一天,团队安全审计就说“这个方案不能上线,太脆弱了”。后来我才知道,社区早就有一个标准解决方案——EIP-4361,也就是“Sign-In with Ethereum”(SIWE)。

核心实现:用 siwe 构造防重放签名

SIWE 的核心思想是:把签名消息变成一个结构化的对象,包含 domain(域名)、uri(当前页面)、nonce(随机数)、issuedAt(签发时间)等字段。这样每个签名都是唯一的、有时效的、绑定到特定网站的。

我选择了 siwe 这个 npm 包,配合 wagmi v2 的 useSignMessage hook 来实现。

第一步:前端生成 nonce 并让用户签名

这里有个关键点:nonce 必须由后端生成,否则前端自己生成的 nonce 没有意义。所以我先向后端请求一个 nonce。

// 1. 从后端获取 nonce
const getNonce = async (): Promise<string> => {
  const res = await fetch('/api/auth/nonce');
  const data = await res.json();
  return data.nonce;
};

// 2. 构造 SIWE 消息
import { SiweMessage } from 'siwe';
import { useSignMessage, useAccount } from 'wagmi';

function LoginButton() {
  const { address, chainId } = useAccount();
  const { signMessageAsync } = useSignMessage();

  const handleLogin = async () => {
    if (!address || !chainId) return;

    // 注意:domain 必须和你的前端域名一致,否则验证会失败
    const domain = window.location.host;
    const origin = window.location.origin;

    const nonce = await getNonce();

    const siweMessage = new SiweMessage({
      domain,
      address,
      statement: 'Sign in to DeFi Dashboard to manage your strategies.',
      uri: origin,
      version: '1',
      chainId,
      nonce,
      issuedAt: new Date().toISOString(),
    });

    const message = siweMessage.prepareMessage();
    const signature = await signMessageAsync({ message });

    // 发送给后端验证
    const verifyRes = await fetch('/api/auth/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message, signature }),
    });

    if (verifyRes.ok) {
      // 登录成功,后端返回一个 session token
      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
    }
  };

  return <button onClick={handleLogin}>Sign in with Ethereum</button>;
}

这里有个坑domain 字段必须精确匹配。我当时在本地开发时用的是 localhost:3000,但部署后域名变成了 app.example.com,结果生产环境一直报 Domain mismatch。后来我把 domain 从 window.location.host 获取,问题解决。

第二步:后端验证签名并创建 session

后端我用 Node.js + Express 实现,使用 siwe 包进行验证。验证通过后,我生成一个 JWT token 返回给前端,后续的 API 请求都带上这个 token。

// 后端:验证签名
import { SiweMessage } from 'siwe';
import express from 'express';

const app = express();
app.use(express.json());

// 存储 nonce(生产环境应该用 Redis)
const nonceStore: Set<string> = new Set();

// 生成 nonce 接口
app.get('/api/auth/nonce', (req, res) => {
  const nonce = generateRandomNonce(); // 使用 crypto.randomBytes 生成
  nonceStore.add(nonce);
  // 设置过期时间,比如 5 分钟
  setTimeout(() => nonceStore.delete(nonce), 5 * 60 * 1000);
  res.json({ nonce });
});

// 验证签名接口
app.post('/api/auth/verify', async (req, res) => {
  const { message, signature } = req.body;

  try {
    const siweMessage = new SiweMessage(message);
    const fields = await siweMessage.verify({
      signature,
      // 这里传入 nonce 是为了验证 nonce 是否有效
      nonce: siweMessage.nonce,
      // 这里传入 domain 是为了验证域名
      domain: siweMessage.domain,
    });

    // 验证成功后,从存储中删除 nonce,防止重放
    nonceStore.delete(siweMessage.nonce);

    // 生成 JWT token
    const token = jwt.sign(
      { address: fields.data.address, chainId: fields.data.chainId },
      process.env.JWT_SECRET,
      { expiresIn: '24h' }
    );

    res.json({ token });
  } catch (error) {
    console.error('Verification failed:', error);
    res.status(401).json({ error: 'Invalid signature' });
  }
});

注意这个细节siweMessage.verify 方法内部会自动检查 nonce、domain、过期时间等。如果 nonce 已经被使用过(比如重放攻击),就会抛出异常。我一开始没理解这个机制,以为需要手动检查,后来发现包已经帮我做了。

第三步:session 持久化与自动登录

用户每次刷新页面都要重新签名,体验很差。所以我用 JWT token 做 session 持久化。前端在初始化时检查 localStorage 中是否有 token,如果有就自动恢复登录状态。

// 封装一个 hook 来管理认证状态
import { useAccount, useDisconnect } from 'wagmi';
import { useState, useEffect } from 'react';

export function useAuth() {
  const { address, isConnected } = useAccount();
  const { disconnect } = useDisconnect();
  const [isAuthenticated, setIsAuthenticated] = useState(false);

  // 检查是否有有效的 token
  useEffect(() => {
    const token = localStorage.getItem('auth_token');
    if (token && address) {
      // 可以验证 token 是否过期(简单做法:解码 payload 检查 exp)
      setIsAuthenticated(true);
    }
  }, [address]);

  // 登出
  const logout = () => {
    localStorage.removeItem('auth_token');
    setIsAuthenticated(false);
    disconnect();
  };

  return { isAuthenticated, logout };
}

这里有个坑:JWT token 过期后,用户需要重新签名。我最初没有处理 token 过期的情况,结果用户操作到一半突然报 401 错误,体验非常糟糕。后来我加了一个“静默刷新”机制:在 API 请求拦截器中检查 token 是否即将过期,如果是,就弹出一个轻提示让用户重新签名。

完整代码:一个可运行的 React 组件

下面是一个完整的登录组件,包含签名验证和 session 管理。假设你已经配置好了 wagmi 的 provider。

// LoginWithSiwe.tsx
import { useState } from 'react';
import { useAccount, useSignMessage, useDisconnect } from 'wagmi';
import { SiweMessage } from 'siwe';
import { useAuth } from './useAuth';

export default function LoginWithSiwe() {
  const { address, isConnected, chainId } = useAccount();
  const { signMessageAsync } = useSignMessage();
  const { disconnect } = useDisconnect();
  const { isAuthenticated, logout } = useAuth();
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const handleLogin = async () => {
    if (!address || !chainId) {
      setError('Please connect your wallet first');
      return;
    }

    setLoading(true);
    setError('');

    try {
      // 1. 获取 nonce
      const nonceRes = await fetch('/api/auth/nonce');
      const { nonce } = await nonceRes.json();

      // 2. 构造 SIWE 消息
      const message = new SiweMessage({
        domain: window.location.host,
        address,
        statement: 'Sign in to access your dashboard.',
        uri: window.location.origin,
        version: '1',
        chainId,
        nonce,
        issuedAt: new Date().toISOString(),
      });

      // 3. 签名
      const signature = await signMessageAsync({
        message: message.prepareMessage(),
      });

      // 4. 发送给后端验证
      const verifyRes = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          message: message.prepareMessage(),
          signature,
        }),
      });

      if (!verifyRes.ok) {
        throw new Error('Verification failed');
      }

      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
      // 触发状态更新
      window.location.reload();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Login failed');
    } finally {
      setLoading(false);
    }
  };

  if (isAuthenticated) {
    return (
      <div>
        <p>Logged in as: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
        <button onClick={logout}>Logout</button>
      </div>
    );
  }

  return (
    <div>
      {!isConnected ? (
        <p>Please connect your wallet first</p>
      ) : (
        <button onClick={handleLogin} disabled={loading}>
          {loading ? 'Signing...' : 'Sign in with Ethereum'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录

  1. Domain mismatch 错误:在本地开发时,domain 是 localhost:3000,但部署到生产环境后,domain 变成了 app.example.com。SIWE 验证要求 domain 必须精确匹配前端域名。解决方案:使用 window.location.host 动态获取 domain。

  2. Nonce already used 错误:第一次测试时,我在短时间内连续点击登录按钮,结果第二次签名时后端报错。原因是 nonce 只能使用一次,我忘记在验证成功后删除 nonce。解决方案:在验证成功后立即从存储中删除 nonce。

  3. 签名弹窗不显示:使用 wagmi 的 useSignMessage 时,如果用户已经连接了钱包,但 MetaMask 不弹出签名窗口。后来发现是因为我传入了 { message } 而不是 { message: siweMessage.prepareMessage() }prepareMessage() 方法会把结构化消息转成符合 EIP-4361 格式的字符串,MetaMask 才能正确识别。

  4. JWT token 过期后用户无感知:用户登录后,如果 token 过期了,API 请求会返回 401,但前端没有提示。解决方案:在 API 请求拦截器中检查 token 的 exp 字段,如果即将过期,提前弹出提示让用户重新签名。

小结

通过 EIP-4361 + SIWE,我成功实现了一套无需密码、基于钱包签名的身份认证方案。核心收获是:不要自己造轮子,社区标准方案(SIWE)已经解决了重放攻击、过期、跨域等问题。如果想继续深挖,可以研究 EIP-4361 的扩展(如 EIP-5573),或者结合 SIWE 实现更细粒度的权限控制。