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

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

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

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

在传统 Web 应用里,登录这件事很“中心化”:用户名、密码、短信验证码,最终都落在服务端数据库和权限系统里。到了 Web3,用户天然已经有了“账户”——钱包地址。问题变成了:如何证明“这个地址真的是当前这个用户在控制”,并把这个证明接入我们的业务系统。

这篇文章我会从架构视角带你完整走一遍:不仅是“让 MetaMask 弹一下签名框”,而是搭一个可运行、可扩展、可排查问题的钱包登录与签名验证系统。重点放在:

  • 如何设计钱包登录认证流
  • 如何做服务端签名验证
  • 如何避免重放攻击
  • 如何在“去中心化身份”和“中心化会话管理”之间做好边界划分

如果你已经写过简单的 DApp 页面,但还没把“钱包地址 -> 认证身份 -> 后端会话”这一套真正打通,这篇比较适合你。


背景与问题

为什么钱包地址不能直接当“已登录”凭证?

很多人刚接触 Web3 时会有一个误区:

前端拿到了 eth_requestAccounts 返回的地址,不就说明用户登录了吗?

其实并不是。

原因很简单:地址是公开信息,不是身份证明本身。任何人都可以声称“我是 0x123…”,但只有掌握私钥的人,才能对指定消息完成签名。因此,真正的认证链路应该是:

  1. 前端请求钱包地址
  2. 服务端生成一次性挑战消息(nonce)
  3. 用户用钱包对挑战消息签名
  4. 服务端验证签名,确认地址控制权
  5. 服务端签发业务会话(JWT / Session)

也就是说,钱包负责“密码学证明”,后端负责“业务态登录”。

这个问题本质上是在解决什么?

从架构角度看,它解决的是三个层面的事情:

  • 身份声明:用户声称自己拥有某个钱包地址
  • 所有权证明:用户对随机消息完成签名,证明控制该地址
  • 业务接入:系统将链上身份映射为站内用户与权限

常见错误方案

我见过不少项目一开始这么做,后面几乎都要返工:

  • 仅前端保存钱包地址,后端完全不验证
  • 直接让用户签固定文案,没有 nonce
  • nonce 不失效,可被重复使用
  • 验签成功后不发会话,所有请求都要求前端重复签名
  • 没有链 ID、域名、时间戳约束,签名可被跨站滥用

这些问题的共同点是:把“签名”当成了交互动作,而不是完整的认证协议的一部分


方案总览与架构设计

我们先看一个适合中小型 Web3 应用的认证架构。

flowchart TD
    A[前端 DApp] --> B[请求 nonce]
    B --> C[后端 Auth 服务]
    C --> D[(Redis/DB 存储 nonce)]
    A --> E[钱包签名]
    E --> A
    A --> F[提交 address + message + signature]
    F --> C
    C --> G[验签]
    G --> H[签发 JWT / Session]
    H --> I[(用户表 / 会话表)]
    H --> A
    A --> J[携带 Token 访问业务 API]
    J --> K[业务服务]

这个架构里有两个关键边界:

边界一:链上身份 ≠ 站内业务身份

钱包地址只能证明“你控制这个地址”,不能自动代表:

  • 你是管理员
  • 你有某个业务角色
  • 你拥有某个订阅权益
  • 你一定要上链创建用户资料

因此,通常我们会有一张用户表,把钱包地址映射为站内用户:

user_id -> wallet_address -> role -> profile -> permissions

边界二:不必把认证写到链上

题目里提到“链上签名验证”,这里需要澄清一个很容易混淆的点:

  • 常见登录验签:服务端本地用 ecrecover 逻辑验证签名,不需要真的发链上交易
  • 链上验证合约:把签名和消息提交给合约,由合约验证签名

对于大多数 Web 登录场景,本地验签更合理,成本低、响应快。只有当你的业务要求“验证结果必须被链上状态消费”时,才需要把验签逻辑放到合约里。


核心原理

1. ECDSA 签名验证

以以太坊钱包为例,用户对消息签名,本质上是对消息哈希进行 ECDSA 签名。服务端可以通过签名恢复出公钥对应地址,再和用户声明的地址比对。

核心流程:

  1. 服务端生成 challenge message
  2. 用户钱包签名
  3. 服务端通过 verifyMessage 恢复签名地址
  4. 比对地址是否一致

2. Nonce 防重放

如果没有 nonce,攻击者只要拿到一份历史签名,就能无限复用。正确做法是:

  • nonce 随机生成
  • 与地址绑定
  • 验签成功后立即作废
  • 设置短期过期时间,比如 5 分钟

3. 域隔离与上下文绑定

一个好的签名消息至少应该包含:

  • 站点域名
  • 钱包地址
  • nonce
  • 签发时间
  • 过期时间
  • chainId
  • 用途说明

这样即使用户在别的站点也签过类似消息,攻击者也难以直接复用。

4. 会话层的必要性

验证签名只是“登录动作”。后续每次 API 请求都要求用户重新签名,体验会非常差。所以通常流程是:

  • 首次登录:钱包签名
  • 后续访问:JWT / HttpOnly Cookie

这是 Web3 应用经常被忽略的一点:Web3 身份证明与 Web2 会话管理可以组合,而不是互斥


认证时序图

sequenceDiagram
    participant U as 用户
    participant W as 钱包
    participant F as 前端
    participant S as 认证服务

    U->>F: 点击“钱包登录”
    F->>W: 请求连接钱包
    W-->>F: 返回 address
    F->>S: GET /auth/nonce?address=0x...
    S-->>F: 返回 message + nonce
    F->>W: 请求签名 personal_sign
    W-->>F: 返回 signature
    F->>S: POST /auth/verify
    Note over S: 校验 nonce/过期时间/签名地址
    S-->>F: 返回 JWT
    F->>S: 携带 JWT 访问业务接口
    S-->>F: 返回受保护资源

方案对比与取舍分析

方案 A:前端拿地址即登录

优点:

  • 实现最快
  • 几乎没有后端改造

缺点:

  • 没有真正的身份证明
  • 容易被伪造
  • 无法安全支持受保护接口

结论: 只能做 Demo,不能上生产。


方案 B:钱包签名 + 服务端本地验签

优点:

  • 安全性和成本比较平衡
  • 性能高,响应快
  • 易于与现有用户系统整合

缺点:

  • 需要自己管理 nonce、会话、风控
  • 多钱包、多链兼容需要更多工程细节

结论: 大多数 Web3 应用的主流选项。


方案 C:链上合约验签 + 链下登录映射

优点:

  • 验证逻辑可公开审计
  • 某些需要链上状态消费的场景更自然

缺点:

  • 成本高
  • 延迟高
  • 登录流程复杂
  • 没必要为普通 Web 登录强行上链

结论: 适合链上治理、合约授权类场景,不适合通用站点登录。


实战代码(可运行)

下面我们做一个最小可运行版本:

  • 前端:HTML + ethers.js
  • 后端:Node.js + Express + ethers + jsonwebtoken
  • 存储:内存版 nonce(方便本地演示),生产请换 Redis

项目结构

web3-auth-demo/
├─ server.js
├─ package.json
└─ public/
   └─ index.html

安装依赖

mkdir web3-auth-demo
cd web3-auth-demo
npm init -y
npm install express ethers jsonwebtoken cors

后端代码:server.js

const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const { ethers } = require("ethers");
const path = require("path");

const app = express();
const PORT = 3000;
const JWT_SECRET = "replace-this-in-production";

// 演示用内存存储,生产环境请使用 Redis
const nonceStore = new Map();

// 生成随机 nonce
function generateNonce() {
  return Math.random().toString(36).slice(2) + Date.now().toString(36);
}

// 构造签名消息
function buildMessage({ domain, address, nonce, chainId, issuedAt, expirationTime }) {
  return `${domain} wants you to sign in with your Ethereum account:
${address}

Sign in to the app.

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

app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));

// 获取 nonce 与待签名消息
app.get("/auth/nonce", (req, res) => {
  const { address, chainId = 1 } = req.query;

  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();

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

  nonceStore.set(ethers.getAddress(address), {
    nonce,
    message,
    expiresAt: Date.now() + 5 * 60 * 1000,
  });

  res.json({ message, nonce });
});

// 验证签名并签发 JWT
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 required fields" });
    }

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

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

    if (Date.now() > record.expiresAt) {
      nonceStore.delete(normalizedAddress);
      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 (ethers.getAddress(recoveredAddress) !== normalizedAddress) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    // 验证成功后立即销毁 nonce,防止重放
    nonceStore.delete(normalizedAddress);

    // 模拟站内用户
    const user = {
      walletAddress: normalizedAddress,
      role: "user",
    };

    const token = jwt.sign(user, JWT_SECRET, { expiresIn: "2h" });

    res.json({
      success: true,
      token,
      user,
    });
  } 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: "Missing token" });
  }

  try {
    const payload = jwt.verify(token, JWT_SECRET);
    res.json({
      authenticated: true,
      user: payload,
    });
  } catch (err) {
    res.status(401).json({ error: "Invalid token" });
  }
});

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

前端代码:public/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Web3 Auth Demo</title>
</head>
<body>
  <h1>Web3 钱包登录演示</h1>
  <button id="loginBtn">连接钱包并登录</button>
  <button id="profileBtn">获取当前用户信息</button>
  <pre id="output"></pre>

  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ethers.umd.min.js"></script>
  <script>
    const output = document.getElementById("output");

    function log(data) {
      output.textContent = typeof data === "string" ? data : JSON.stringify(data, null, 2);
    }

    async function login() {
      if (!window.ethereum) {
        log("请先安装 MetaMask");
        return;
      }

      try {
        const provider = new ethers.BrowserProvider(window.ethereum);
        await provider.send("eth_requestAccounts", []);
        const signer = await provider.getSigner();
        const address = await signer.getAddress();
        const network = await provider.getNetwork();
        const chainId = Number(network.chainId);

        // 1. 向服务端请求待签名消息
        const nonceResp = await fetch(`http://localhost:3000/auth/nonce?address=${address}&chainId=${chainId}`);
        const nonceData = await nonceResp.json();

        if (!nonceResp.ok) {
          throw new Error(nonceData.error || "获取 nonce 失败");
        }

        // 2. 钱包签名
        const signature = await signer.signMessage(nonceData.message);

        // 3. 提交验签
        const verifyResp = await fetch("http://localhost:3000/auth/verify", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            address,
            message: nonceData.message,
            signature,
          }),
        });

        const verifyData = await verifyResp.json();

        if (!verifyResp.ok) {
          throw new Error(verifyData.error || "登录失败");
        }

        localStorage.setItem("token", verifyData.token);
        log({
          message: "登录成功",
          ...verifyData,
        });
      } catch (err) {
        log(`错误:${err.message}`);
      }
    }

    async function getProfile() {
      const token = localStorage.getItem("token");

      if (!token) {
        log("请先登录");
        return;
      }

      const resp = await fetch("http://localhost:3000/me", {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });

      const data = await resp.json();
      log(data);
    }

    document.getElementById("loginBtn").addEventListener("click", login);
    document.getElementById("profileBtn").addEventListener("click", getProfile);
  </script>
</body>
</html>

运行方式

node server.js

浏览器访问:

http://localhost:3000

点击“连接钱包并登录”,完成签名后即可拿到 JWT,再点击“获取当前用户信息”验证会话。


如果你需要“链上验签”而不是本地验签

上面的登录方案已经足够支撑绝大多数认证系统。但如果你的业务要求“合约里也要验证这个签名”,可以使用 Solidity 的 ECDSA 库。

示例合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract SignatureVerifier {
    using ECDSA for bytes32;

    function getMessageHash(string memory message) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(message));
    }

    function verify(
        address signer,
        string memory message,
        bytes memory signature
    ) public pure returns (bool) {
        bytes32 messageHash = getMessageHash(message);
        bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
        return ethSignedMessageHash.recover(signature) == signer;
    }
}

什么时候值得用链上验签?

更适合这类场景:

  • 合约授权操作
  • 链上投票
  • Permit / meta transaction
  • 需要链上状态直接消费签名结果

如果只是网站登录,我的建议很明确:别为了“去中心化”而把登录也搞成链上交易,你会平白引入 gas 成本和确认延迟。


状态与失败路径建模

这部分在实际项目里很有用,因为登录系统出问题时,往往不是“完全不能用”,而是卡在某个中间状态。

stateDiagram-v2
    [*] --> Disconnected
    Disconnected --> WalletConnected: 连接钱包
    WalletConnected --> NonceIssued: 请求 nonce 成功
    NonceIssued --> Signed: 用户完成签名
    NonceIssued --> Cancelled: 用户取消签名
    Signed --> Verified: 服务端验签成功
    Signed --> Failed: 验签失败
    Verified --> SessionActive: 签发 JWT
    SessionActive --> Expired: Token 过期
    Failed --> Disconnected
    Cancelled --> WalletConnected
    Expired --> WalletConnected

常见坑与排查

这部分我建议你在开发时直接对照检查,能省掉很多“明明签了却验不过”的时间。

1. 地址大小写不一致

以太坊地址有 checksum 格式。如果前端传上来的是小写地址,而后端恢复的是 checksum 地址,直接字符串比较可能失败。

解决办法:

统一使用:

ethers.getAddress(address)

做标准化处理。


2. 前后端签名消息不完全一致

这是最常见的坑之一。我当时第一次接的时候,也是在这里卡了半天。

比如这些细节都可能导致验签失败:

  • 少一个换行
  • 多一个空格
  • 前端签的是 message + "\n"
  • 后端重新拼接 message,但字段顺序不同

建议:

  • 服务端生成完整 message
  • 前端只负责原样签名
  • 验签时比对 message 原文,不要在后端“重新猜”一遍

3. nonce 没及时失效,导致重放攻击

如果一个签名可重复提交,那么别人截获请求后就能冒用。

排查点:

  • 验签成功后是否删除 nonce
  • nonce 是否设置 TTL
  • 同一地址是否允许并发多个 nonce

建议:

生产里用 Redis,并为 nonce 设置:

  • 单地址单有效 nonce
  • 5 分钟过期
  • 验签成功立即删除

4. 使用了错误的签名方法

常见签名方式包括:

  • personal_sign
  • eth_sign
  • signTypedData_v4

不同方式验签逻辑不完全一样。本文示例使用的是 signMessage,对应以太坊前缀消息签名。

建议:

登录认证优先用:

signer.signMessage(message)

如果要做更标准化的登录,可进一步升级到 EIP-4361(SIWE)。


5. 切链导致上下文不一致

用户在 Polygon 上连接钱包,但后端消息写的是 Ethereum Mainnet 的 chainId,虽然签名本身可能仍然成立,但业务语义已经不一致。

建议:

  • 把 chainId 写入 challenge message
  • 验签后校验该值是否符合业务要求
  • 某些业务场景下限制仅支持特定链

6. JWT 放在 localStorage 的风险

本文为了演示简单,token 放在了 localStorage。但在生产环境中,这会扩大 XSS 风险。

更稳妥的方案:

  • 使用 HttpOnly Cookie 存储会话
  • 配合 CSRF 防护
  • 对前端进行严格 CSP 限制

安全最佳实践

1. 使用标准化消息格式

如果准备长期维护,我建议直接采用 SIWE(Sign-In with Ethereum, EIP-4361)。它解决了:

  • 域名绑定
  • nonce
  • 时间字段
  • 资源声明
  • 标准消息结构

这样钱包登录不再是“你自己定义的一段字符串”,而是更像协议化的身份声明。


2. nonce 存 Redis,不存前端

nonce 必须由后端生成和管理。不要相信前端自己传来的随机值。

推荐 Redis key 设计:

auth:nonce:{walletAddress}

值中可包含:

{
  "nonce": "abc123",
  "message": "...",
  "expiresAt": 1640352000000
}

3. 限流与风控

虽然签名登录不怕撞库,但仍然需要防刷:

  • 按 IP 限流 nonce 请求
  • 按地址限流验签请求
  • 对异常频率做告警
  • 记录失败签名样本便于排查

4. 域名、URI、Chain ID 必须校验

不要只验证“签名对不对”,还要验证“签的到底是不是你这个站的消息”。

建议校验:

  • 域名是否是本站
  • URI 是否为预期来源
  • chainId 是否在支持列表
  • issuedAt / expirationTime 是否有效

5. 钱包地址只做身份锚点,不直接承载业务权限

权限系统仍然应该在后端维护:

  • wallet -> user
  • user -> role
  • role -> permission

这样当你后续需要支持:

  • 一个用户绑定多个钱包
  • 钱包更换
  • 子账号体系
  • DAO 成员身份同步

系统不会推倒重来。


性能最佳实践

登录系统看起来流量不大,但在活动场景下往往会突然放量,比如 NFT mint 前、空投活动、白名单校验时。

1. 验签本地完成,避免链上调用

本地验签是纯 CPU 操作,比链上 RPC 或合约调用更轻量。绝大多数登录场景都应该这样做。

2. nonce 使用 Redis,避免数据库热点写入

nonce 是高频、短生命周期数据,放 MySQL 不划算。Redis 更适合:

  • 自动过期
  • 高并发
  • 易于原子删除

3. JWT 尽量短期,配合刷新机制

推荐:

  • access token:15 分钟 ~ 2 小时
  • refresh token:更长,但要可吊销

如果系统对安全要求高,建议用服务端 session 存储替代完全无状态 JWT。

4. 容量估算思路

假设高峰期:

  • 每分钟 3000 次登录挑战请求
  • 每次 nonce 占用约 500B ~ 1KB
  • 有效期 5 分钟

那么 Redis 同时在库 nonce 数量大约:

3000 * 5 = 15000

按 1KB 粗估,约 15MB 量级,完全可控。

真正更需要关注的是:

  • 钱包签名交互延迟
  • 前端重试策略
  • 验签接口的限流与幂等

面向生产的改进建议

如果你准备把这个 Demo 升级成生产可用系统,我建议按这个顺序演进:

第一步:替换内存 nonce 为 Redis

因为服务多实例部署后,内存 Map 无法共享。

第二步:引入 SIWE

减少自定义消息格式导致的兼容和安全问题。

降低 token 被脚本窃取的风险。

第四步:支持多钱包与多链

例如:

  • MetaMask
  • WalletConnect
  • Coinbase Wallet

并建立支持链白名单。

第五步:补充账户映射层

将钱包地址映射到站内用户中心,而不是让钱包地址直接充当所有业务主键。


总结

从架构上看,一个靠谱的 Web3 身份认证系统,核心不是“让用户签个名”这么简单,而是把这条链路拆清楚:

  • 钱包地址负责身份声明
  • 签名负责所有权证明
  • 后端负责会话签发与权限管理

最实用、也最平衡的落地方案依然是:

  1. 前端连接钱包获取地址
  2. 后端生成一次性 nonce 和待签名消息
  3. 用户钱包签名
  4. 服务端本地验签
  5. 验签成功后发 JWT 或 Session

如果你只记住一句话,我希望是这个:

Web3 登录不是“去掉后端认证”,而是“把密码换成了链上私钥控制权证明”。

最后给几个可执行建议,适合你直接用于项目:

  • Demo 阶段:按本文示例跑通完整登录链路
  • 测试环境:把 nonce 改成 Redis,增加过期与日志
  • 生产环境:引入 SIWE、HttpOnly Cookie、限流、域校验
  • 复杂业务:把钱包身份与站内权限彻底解耦

边界条件也很明确:

  • 如果只是普通网站登录,不要强行把验签放链上
  • 如果需要合约消费签名结果,再考虑 Solidity 验签
  • 如果未来要支持多链、多钱包、多身份绑定,尽早设计用户映射层

这套系统搭好后,你的 Web3 应用才算真正跨过了“能连钱包”和“能做认证”的分水岭。


分享到:

上一篇
《区块链节点数据索引实战:面向中级开发者的链上事件抓取、清洗与查询系统设计》
下一篇
《分布式架构中基于一致性哈希与服务发现的微服务流量路由实战》