跳转到内容
123xiao | 无名键客

《Web3 中级实战:从零搭建基于钱包登录与链上签名验证的去中心化身份认证系统》

字数: 0 阅读时长: 1 分钟

Web3 中级实战:从零搭建基于钱包登录与链上签名验证的去中心化身份认证系统

很多人第一次做 Web3 登录,都会觉得“这不就是让用户点一下钱包授权吗”。真上手后很快会发现:真正难的不是连上钱包,而是把“地址归属”变成一个可靠、可扩展、可审计的身份认证系统

这篇文章我会站在架构设计 + 可运行实践的角度,带你从零搭一个中级可用的方案:前端使用钱包发起登录,后端生成挑战消息并验证签名,再下发会话令牌,形成一套去中心化身份认证流程。重点不是“能跑”,而是“为什么这么设计,以及哪里最容易出问题”。


背景与问题

在传统 Web 应用里,身份认证通常依赖:

  • 用户名/密码
  • 手机验证码
  • 第三方 OAuth
  • 服务端 Session

但在 Web3 场景里,用户往往没有用户名,最天然的身份标识是钱包地址。于是一个典型需求就出现了:

用户打开 DApp,点击“Connect Wallet”,签一段消息,后端验证签名,确认这个地址确实由当前用户控制,然后建立登录态。

听起来很直接,但如果你只做成“前端拿地址,后端信了”,那基本等于没做认证。因为:

  1. 钱包地址公开可见,不等于地址所有权证明
  2. 没有挑战消息(nonce)就容易被重放攻击
  3. 签名消息不规范,前后端很容易验签失败
  4. 登录态没有域名、时间、链 ID 约束,会产生跨站复用风险
  5. 合约钱包与 EOA 验证方式不完全一样

所以我们真正要解决的问题是:

  • 如何证明“当前请求者持有该地址的私钥或控制权”
  • 如何防止历史签名被重放
  • 如何把钱包签名接入现有 Web 应用的会话体系
  • 如何兼顾安全性、可维护性、性能与后续扩展

方案目标与架构边界

本文实现的目标是:

  • 前端连接 MetaMask
  • 请求后端生成一次性挑战消息
  • 用户使用钱包签名
  • 后端验证签名
  • 验证通过后签发 JWT
  • 后续接口使用 JWT 鉴权

这套方案适用于:

  • DApp 后台管理系统
  • 链上数据平台
  • NFT / DAO 社区登录
  • 需要“钱包即账号”的中后台服务

这套方案不直接覆盖

  • 合约钱包完整支持(文末会讲扩展)
  • 多链统一身份聚合
  • ENS / Lens / DID 文档解析
  • 零知识身份认证

先把一条稳定主链打通,是中级阶段最划算的做法。


整体架构设计

从架构角度,我建议把钱包登录拆成四层:

  1. 钱包交互层:连接钱包、请求签名
  2. 认证协议层:挑战消息、nonce、域名、过期时间
  3. 签名验证层:恢复地址、比对地址、状态校验
  4. 会话管理层:JWT / Session、权限、续期、登出

架构总览

flowchart LR
  U[用户] --> F[前端 DApp]
  F --> W[钱包 MetaMask]
  F --> B[后端 Auth API]
  B --> R[(Redis/DB)]
  B --> J[JWT 服务]

  W -->|返回签名| F
  B -->|存储 nonce/状态| R
  B -->|签发 token| J

登录时序图

sequenceDiagram
  participant U as User
  participant F as Frontend
  participant B as Backend
  participant W as Wallet
  participant R as Redis/DB

  U->>F: 点击钱包登录
  F->>W: 请求钱包地址
  W-->>F: address
  F->>B: POST /auth/nonce { address }
  B->>R: 保存 nonce
  B-->>F: 返回 message + nonce
  F->>W: signMessage(message)
  W-->>F: signature
  F->>B: POST /auth/verify { address, message, signature }
  B->>B: 验签并校验 nonce
  B->>R: nonce 标记已使用
  B-->>F: JWT
  F->>B: 携带 JWT 请求业务接口

方案对比:为什么不用“只连接钱包就登录”

这是一个非常常见的取舍点。

方案 A:只拿钱包地址,不签名

优点:

  • 实现最简单
  • 用户点击成本最低

缺点:

  • 完全无法证明地址归属
  • 任何人都能伪造请求体中的 address
  • 只能当“访客态标识”,不能当认证

方案 B:固定消息签名

例如永远让用户签:

Login to MyApp

优点:

  • 比方案 A 强
  • 实现简单

缺点:

  • 容易被重放攻击
  • 跨环境、跨时间复用签名风险高
  • 无法追踪一次认证的上下文

方案 C:一次性挑战消息签名

消息中包含:

  • domain
  • address
  • nonce
  • issuedAt
  • expirationTime
  • chainId
  • statement

优点:

  • 防重放
  • 可审计
  • 易扩展
  • 可以和 SIWE 思路对齐

缺点:

  • 实现稍复杂
  • 需要后端状态管理

我的建议

中级项目直接上 方案 C,不要在“省几行代码”上妥协。
因为你今天不做 nonce,明天就会在安全审计时补回来,而且补起来更疼。


核心原理

1. 钱包地址不是认证,签名才是认证

地址可以公开传播,私钥不能。
只要用户能对服务端指定消息进行签名,我们就能通过验签确认:当前用户确实控制这个地址

2. 为什么必须有 nonce

如果你让用户签了消息:

我同意登录 MyApp

攻击者只要拿到这段签名,就可能在未来重复使用。
nonce 的作用就是:让每次登录消息都唯一且仅能使用一次

3. 验签是怎么做的

以 EVM 链为例,常见流程是:

  1. 服务端构造消息
  2. 用户钱包签名
  3. 服务端使用 ethers.verifyMessage(message, signature) 恢复签名地址
  4. 与用户提交的地址比较
  5. 地址一致则证明签名有效

4. 为什么还要 JWT

因为钱包签名很贵,不能每次接口请求都弹钱包。
所以通常做法是:

  • 首次登录:钱包签名
  • 登录成功:后端签发 JWT / Session
  • 后续请求:使用 JWT

这就把 Web3 身份认证和传统 Web 会话管理结合起来了。


消息格式设计

建议消息中至少包含以下字段:

  • domain:当前站点域名
  • address:钱包地址
  • statement:登录用途说明
  • uri:站点 URI
  • version:协议版本
  • chainId:链 ID
  • nonce:一次性随机串
  • issuedAt:签发时间
  • expirationTime:过期时间

示例:

myapp.com wants you to sign in with your Ethereum account:
0x1234...abcd

Sign in to MyApp.

URI: https://myapp.com
Version: 1
Chain ID: 1
Nonce: u8K1mPz9aQ
Issued At: 2024-06-01T12:00:00.000Z
Expiration Time: 2024-06-01T12:05:00.000Z

这类格式非常接近 SIWE(Sign-In with Ethereum),好处是规范、可读、可扩展。


实战代码(可运行)

下面我们用一个最小可运行方案:

  • 前端:React + ethers
  • 后端:Node.js + Express + ethers + jsonwebtoken
  • 存储:内存 Map(演示用,生产改 Redis)

为了保证文章可直接跑,我会用尽量少的依赖。你也可以很容易迁移到 Next.js / NestJS。


项目结构

web3-auth-demo/
├─ server/
│  ├─ package.json
│  └─ index.js
└─ client/
   ├─ package.json
   └─ src/
      └─ App.jsx

后端实现

1. 安装依赖

mkdir server && cd server
npm init -y
npm install express cors ethers jsonwebtoken

2. 编写认证服务

// server/index.js
const express = require('express');
const cors = require('cors');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const { ethers } = require('ethers');

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

const PORT = 3001;
const JWT_SECRET = 'replace-this-in-production';

// 演示使用内存存储,生产环境请换 Redis
// 结构: address => { nonce, message, expiresAt, used }
const nonceStore = new Map();

function generateNonce(length = 10) {
  return crypto.randomBytes(length).toString('base64url').slice(0, length);
}

function buildMessage({ domain, address, uri, chainId, nonce, issuedAt, expirationTime }) {
  return `${domain} wants you to sign in with your Ethereum account:
${address}

Sign in to MyApp.

URI: ${uri}
Version: 1
Chain ID: ${chainId}
Nonce: ${nonce}
Issued At: ${issuedAt}
Expiration Time: ${expirationTime}`;
}

app.post('/auth/nonce', (req, res) => {
  const { address, chainId = 1 } = req.body;
  if (!address || !ethers.isAddress(address)) {
    return res.status(400).json({ error: 'Invalid address' });
  }

  const nonce = generateNonce();
  const issuedAt = new Date().toISOString();
  const expirationTime = new Date(Date.now() + 5 * 60 * 1000).toISOString(); // 5 分钟有效期

  const domain = 'localhost:5173';
  const uri = 'http://localhost:5173';

  const checksumAddress = ethers.getAddress(address);
  const message = buildMessage({
    domain,
    address: checksumAddress,
    uri,
    chainId,
    nonce,
    issuedAt,
    expirationTime,
  });

  nonceStore.set(checksumAddress.toLowerCase(), {
    nonce,
    message,
    expiresAt: new Date(expirationTime).getTime(),
    used: false,
  });

  res.json({
    address: checksumAddress,
    nonce,
    message,
    expirationTime,
  });
});

app.post('/auth/verify', async (req, res) => {
  try {
    const { address, message, signature } = req.body;

    if (!address || !message || !signature) {
      return res.status(400).json({ error: 'Missing params' });
    }

    const checksumAddress = ethers.getAddress(address);
    const record = nonceStore.get(checksumAddress.toLowerCase());

    if (!record) {
      return res.status(400).json({ error: 'Nonce not found' });
    }

    if (record.used) {
      return res.status(400).json({ error: 'Nonce already used' });
    }

    if (Date.now() > record.expiresAt) {
      return res.status(400).json({ error: 'Nonce expired' });
    }

    if (record.message !== message) {
      return res.status(400).json({ error: 'Message mismatch' });
    }

    const recoveredAddress = ethers.verifyMessage(message, signature);

    if (recoveredAddress.toLowerCase() !== checksumAddress.toLowerCase()) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    record.used = true;
    nonceStore.set(checksumAddress.toLowerCase(), record);

    const token = jwt.sign(
      {
        sub: checksumAddress,
        walletAddress: checksumAddress,
      },
      JWT_SECRET,
      { expiresIn: '2h' }
    );

    res.json({
      success: true,
      token,
      address: checksumAddress,
    });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Verification failed' });
  }
});

app.get('/me', (req, res) => {
  const auth = req.headers.authorization || '';
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';

  if (!token) {
    return res.status(401).json({ error: 'No token' });
  }

  try {
    const payload = jwt.verify(token, JWT_SECRET);
    res.json({
      address: payload.walletAddress,
      message: 'Authenticated request success',
    });
  } catch (err) {
    res.status(401).json({ error: 'Invalid token' });
  }
});

app.listen(PORT, () => {
  console.log(`Auth server running at http://localhost:${PORT}`);
});

3. 启动后端

node index.js

前端实现

1. 安装依赖

mkdir client && cd client
npm create vite@latest . -- --template react
npm install
npm install ethers

2. 编写页面

// client/src/App.jsx
import { useState } from 'react';
import { ethers } from 'ethers';

function App() {
  const [address, setAddress] = useState('');
  const [token, setToken] = useState('');
  const [result, setResult] = useState('');

  async function connectAndLogin() {
    try {
      if (!window.ethereum) {
        alert('请先安装 MetaMask');
        return;
      }

      const provider = new ethers.BrowserProvider(window.ethereum);
      const accounts = await provider.send('eth_requestAccounts', []);
      const signer = await provider.getSigner();
      const userAddress = ethers.getAddress(accounts[0]);
      const network = await provider.getNetwork();
      const chainId = Number(network.chainId);

      setAddress(userAddress);

      const nonceResp = await fetch('http://localhost:3001/auth/nonce', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ address: userAddress, chainId }),
      });

      const nonceData = await nonceResp.json();
      if (!nonceResp.ok) {
        throw new Error(nonceData.error || '获取 nonce 失败');
      }

      const signature = await signer.signMessage(nonceData.message);

      const verifyResp = await fetch('http://localhost:3001/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          address: userAddress,
          message: nonceData.message,
          signature,
        }),
      });

      const verifyData = await verifyResp.json();
      if (!verifyResp.ok) {
        throw new Error(verifyData.error || '验签失败');
      }

      setToken(verifyData.token);
      setResult(`登录成功:${verifyData.address}`);
    } catch (err) {
      console.error(err);
      setResult(`登录失败:${err.message}`);
    }
  }

  async function fetchProfile() {
    try {
      const resp = await fetch('http://localhost:3001/me', {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      const data = await resp.json();
      if (!resp.ok) {
        throw new Error(data.error || '请求失败');
      }

      setResult(JSON.stringify(data, null, 2));
    } catch (err) {
      setResult(`获取用户信息失败:${err.message}`);
    }
  }

  return (
    <div style={{ padding: 24, fontFamily: 'sans-serif' }}>
      <h1>Web3 钱包登录 Demo</h1>
      <p>当前地址:{address || '未连接'}</p>
      <button onClick={connectAndLogin}>连接钱包并登录</button>
      <button onClick={fetchProfile} disabled={!token} style={{ marginLeft: 12 }}>
        获取当前用户信息
      </button>
      <pre style={{ marginTop: 20, background: '#f5f5f5', padding: 16 }}>
        {result}
      </pre>
    </div>
  );
}

export default App;

3. 启动前端

npm run dev

到这里,一个基本的钱包登录 + 后端验签 + JWT 会话系统就跑起来了。


登录状态机设计

实际项目里,我建议明确区分认证状态,而不是把所有逻辑堆进一个 login() 函数里。这样排错非常省心。

stateDiagram-v2
  [*] --> Disconnected
  Disconnected --> WalletConnected: connect wallet
  WalletConnected --> NonceIssued: request nonce
  NonceIssued --> Signing: user signs message
  Signing --> Verifying: submit signature
  Verifying --> Authenticated: verify success
  Verifying --> Failed: verify failed
  Failed --> WalletConnected: retry
  Authenticated --> [*]

核心组件职责划分

如果你准备把这套系统做进正式项目,建议按职责拆分:

classDiagram
  class WalletClient {
    +connect()
    +signMessage(message)
    +getAddress()
  }

  class AuthController {
    +issueNonce(address, chainId)
    +verifySignature(address, message, signature)
  }

  class NonceStore {
    +save(address, nonceRecord)
    +get(address)
    +markUsed(address)
  }

  class SessionService {
    +signToken(address)
    +verifyToken(token)
  }

  WalletClient --> AuthController
  AuthController --> NonceStore
  AuthController --> SessionService

这样做的好处是:

  • 钱包逻辑和认证逻辑解耦
  • nonce 存储可从内存换 Redis
  • 会话层可从 JWT 换成服务端 Session
  • 便于未来支持多链或多钱包

常见坑与排查

这部分很重要。我自己做这类系统时,真正耗时间的往往不是主流程,而是这些边边角角。

1. 前后端消息字符串不完全一致

现象:

  • 用户签名成功
  • 后端 verifyMessage 后恢复出的地址不对,或者总是验签失败

原因:

消息验签是对“原始字符串”做的,只要有一点差异都不行,比如:

  • 少一个换行
  • 多一个空格
  • 时间字段格式不同
  • 前端自己拼 message,后端也自己拼 message,但模板不一致

建议:

  • 由后端统一生成 message,前端只负责签名
  • 验签时使用后端原始 message,不要二次拼接

2. 地址大小写导致比较失败

现象:

  • 恢复地址看起来是同一个地址,但比较失败

原因:

EVM 地址可能存在 checksum 大小写形式。

建议:

统一使用:

ethers.getAddress(address)

比较时使用:

a.toLowerCase() === b.toLowerCase()

3. nonce 没有失效或没标记已使用

风险:

同一签名可以重复登录,形成重放攻击。

建议:

  • nonce 设置有效期,常见 5 分钟
  • 验签成功后立即标记 used=true
  • 最好使用原子操作,避免并发下被重复消费

4. chainId 不匹配

现象:

用户连的是测试网,但系统以主网身份处理。

建议:

  • 挑战消息中包含 chainId
  • 后端验证时检查当前业务允许的链
  • 对多链系统,地址身份最好带上链上下文

5. CORS 或本地端口不一致

开发环境特别常见。

建议:

  • 前后端明确配置允许来源
  • domainuri 要与实际环境对应
  • 本地开发和线上环境不要混用消息模板

6. MetaMask 弹窗不出现

可能原因:

  • 浏览器拦截
  • 前端没有在用户点击事件中直接触发签名
  • 多次调用 provider / signer 顺序有问题

建议:

connect -> request nonce -> sign 尽量放在同一个用户交互流程里,不要被异步链路打断太多。

7. 合约钱包不能用 verifyMessage 直接处理

这是进阶坑。
对于 EOA(外部账户),verifyMessage 足够;但合约钱包可能需要用 ERC-1271 验证签名。

如果你的用户里有 Safe 钱包,这点一定要提前规划。


安全最佳实践

1. 使用标准化签名协议

如果条件允许,直接对齐 SIWE(EIP-4361)
好处是:

  • 字段规范统一
  • 钱包生态更兼容
  • 安全语义更明确

本文是手写版,适合理解原理;生产中建议尽量靠标准。

2. nonce 存 Redis,且单次消费

生产环境千万别用内存 Map。正确姿势通常是:

  • SETEX nonce:{address} 存挑战数据
  • 验证成功后删除或标记已消费
  • 借助 Redis 原子操作避免并发重放

3. 对域名、URI、链 ID 做强校验

不要只验证签名,还要验证上下文是否属于你的站点:

  • 是否当前业务域名
  • 是否允许的链
  • 是否在有效时间内
  • 是否来自正确环境(prod / staging)

4. token 最小化设计

JWT 里只放必要信息:

  • sub
  • walletAddress
  • role(如有)
  • exp

不要把过多链上画像、权限快照塞进 token,后续权限变更会很难处理。

5. 为敏感操作增加二次确认

钱包签名登录只证明“地址归属”,不代表所有高风险操作都应该直接放行。
例如:

  • 提现
  • 修改安全设置
  • 绑定 Web2 账号
  • 管理员操作

建议加:

  • 二次签名
  • 短时重新认证
  • 设备风险校验

6. 防止签名钓鱼

前端展示的签名消息一定要可读,避免用户看到一串不可理解的 hex。
签名文案应该明确:

  • 登录哪个站点
  • 什么目的
  • 是否会发起链上交易(登录签名通常不会)

性能最佳实践

钱包登录系统流量大时,性能瓶颈通常不在验签本身,而在状态管理和认证边界。

1. nonce 接口要轻量

/auth/nonce 是高频接口,建议:

  • 不查复杂数据库
  • 只做基础地址校验
  • nonce 存 Redis,TTL 自动回收

2. 验签成功后减少链上依赖

本文方案中,登录过程不需要直接访问链节点,因为 EOA 验签是纯离线计算。
这点很重要:意味着登录延迟主要取决于钱包弹窗和网络请求,而不是 RPC 节点性能。

3. 头像、ENS、链上画像异步获取

很多项目喜欢在登录成功后立刻查:

  • ENS 名称
  • NFT 持仓
  • Token 余额
  • DAO 角色

我建议异步做,不要阻塞登录主链路。
登录认证和用户画像是两件事,架构上应拆开。

4. 会话层与权限层分离

  • 会话层:用户是不是本人
  • 权限层:这个地址能做什么

这样权限变化时,只更新权限缓存或数据库,不必强制所有用户重新登录。


容量估算与扩展思路

对于一个中型 DApp 后台,如果你有:

  • 日活 1 万
  • 峰值每秒 50 次登录
  • nonce 有效期 5 分钟

那么 Redis 中同时存在的 nonce 数量通常并不夸张。粗略估算:

  • 峰值 50 次/秒
  • 5 分钟 = 300 秒
  • 并发 nonce 量约 15000 条

每条记录只保存:

  • address
  • nonce
  • message 或消息摘要
  • expiresAt
  • used 标记

这对 Redis 来说压力不大。

扩展方向

1. 支持多链

你可以把身份键从:

address

扩展为:

chainId:address

避免不同链混淆。

2. 支持合约钱包

增加 ERC-1271 检测流程:

  • 先判断地址是否合约
  • 若是 EOA,走 verifyMessage
  • 若是合约钱包,调用合约 isValidSignature

3. 支持角色系统

钱包地址只是身份入口,真正业务还需要:

  • 白名单
  • 会员等级
  • 管理员角色
  • 链上持仓门槛

建议在认证通过后做一层“地址到用户实体”的绑定。


一套更稳的落地建议

如果你准备把这套方案上生产,我建议按下面顺序做:

  1. 先跑通 EOA 钱包登录最小链路
  2. 接入 Redis 管理 nonce
  3. 对齐 SIWE 消息格式
  4. 加入 JWT 刷新与登出机制
  5. 补上角色与权限模型
  6. 如果有企业级需求,再支持 ERC-1271 和多链

不要一开始就试图做成“超级通用身份中台”。
Web3 认证系统很容易陷入过度设计,尤其是在多链、多钱包、多画像同时推进时。


总结

这类去中心化身份认证系统,真正的关键点可以浓缩成一句话:

用一次性挑战消息证明钱包控制权,再用传统会话机制承接后续请求。

本文我们完成了这些核心环节:

  • 理解为什么“连接钱包”不等于“认证”
  • 设计了带 nonce 的挑战消息机制
  • 使用 ethers.verifyMessage 实现后端验签
  • 通过 JWT 建立业务登录态
  • 分析了常见坑、安全边界和性能取舍

如果你现在正在做一个中级 Web3 应用,我的可执行建议是:

  • 认证主链路先简单、先标准
  • 消息模板由后端统一生成
  • nonce 必须一次性且有过期时间
  • JWT 只承载会话,不要承载复杂业务状态
  • 登录与链上画像解耦
  • 有合约钱包用户时,尽早评估 ERC-1271

最后说个很实际的经验:
Web3 登录系统最怕的不是“代码写不出来”,而是“看起来能用,但安全边界是虚的”。你只要把 挑战消息、验签、nonce、会话 这四件事做扎实,这套身份系统就已经具备了不错的工程基础。


分享到:

上一篇
《前端性能实战:基于 Core Web Vitals 的页面加载优化与监控方案设计》
下一篇
《分布式架构中基于一致性哈希与服务发现的弹性扩缩容实战指南》