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

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

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

Web3 中级实战:从零搭建基于 EVM 的钱包登录与链上签名认证系统

很多人第一次做 Web3 登录,思路都很直接:
“用户连上钱包,拿到地址,不就等于登录了吗?”

我一开始也这么想,后来很快发现这只是拿到了一个公开地址,并没有完成“认证”。真正的认证问题是:

  • 这个地址是不是当前用户真实控制的?
  • 这个请求是不是刚刚发起的,而不是别人重放的?
  • 登录后服务端怎么建立会话?
  • 切链、换钱包、签名格式不一致时,系统怎么稳住?

这篇文章我们不讲概念空转,而是从架构视角 + 可运行代码,带你搭一个完整的、基于 EVM 的钱包登录与链上签名认证系统。
目标读者是已经接触过前端、Node.js、以太坊基础交互的中级开发者。


背景与问题

在传统 Web 系统里,登录通常依赖:

  • 用户名 + 密码
  • 手机验证码
  • OAuth(微信、GitHub、Google)

而在 Web3 里,最常见的是:

  • 用户连接钱包
  • 服务端发一个随机挑战(nonce)
  • 用户使用钱包签名
  • 服务端验签,通过后发放 session/JWT

看起来不复杂,但真正落地时会碰到几个核心问题。

1. 连接钱包不等于登录

eth_requestAccounts 只是让用户暴露地址。
地址公开可见,任何人都能知道一个地址存在,所以这一步不具备认证能力

真正的认证依赖的是:只有私钥控制者才能生成正确签名

2. 只验签还不够

如果服务端直接让用户签一个固定字符串,比如:

Login to my app

那么攻击者一旦拿到这份签名,就可能无限复用。
所以必须加入:

  • nonce
  • 域名
  • 时间戳
  • 过期时间
  • chainId
  • 资源范围(可选)

这也是为什么现在越来越多项目采用 SIWE(Sign-In with Ethereum) 思路。

3. “链上签名认证”并不一定真的上链

这是一个容易误解的点。
大多数登录认证中的签名,本质上是链下签名(off-chain signature),但签名体系来自 EVM 账户模型,服务端用 ECDSA 恢复地址并验证,所以通常也被叫作链上签名认证。

它的好处是:

  • 不消耗 gas
  • 用户体验更好
  • 仍然能证明地址控制权

4. 架构上要把“认证”和“授权”分开

建议把系统拆成两层:

  • 认证层:用户是否真正控制某个钱包地址
  • 授权层:这个地址在你的业务系统里拥有什么权限

例如:

  • 认证:这个人控制 0xabc...
  • 授权:这个地址是否是管理员、是否领取过资格、是否绑定了某个 UID

这两层不要混在一起,否则后期扩展会很痛苦。


方案概览

我们先给出一个可落地的整体方案:

  1. 前端请求服务端生成登录挑战消息
  2. 服务端返回包含 nonce 的标准消息
  3. 用户用钱包签名
  4. 前端把 message + signature + address 提交给服务端
  5. 服务端验签、校验 nonce/时效/domain/chainId
  6. 验证通过后创建用户会话,返回 JWT 或 session cookie

整体架构图

flowchart TD
    A[用户打开前端 DApp] --> B[连接 EVM 钱包]
    B --> C[前端请求后端获取 nonce]
    C --> D[后端生成挑战消息]
    D --> E[前端调用 personal_sign]
    E --> F[用户在钱包确认签名]
    F --> G[前端提交 message + signature]
    G --> H[后端验签恢复地址]
    H --> I{校验 nonce/域名/时间/chainId}
    I -- 通过 --> J[签发 JWT/Session]
    I -- 失败 --> K[拒绝登录]

核心原理

这一部分是整套系统的“地基”。

1. EVM 签名验证原理

EVM 钱包一般使用 secp256k1 椭圆曲线算法。
当用户对消息签名后,服务端可以通过签名恢复出公钥,再推导出地址。

验证逻辑大致是:

  1. 用户签名消息 message
  2. 得到 signature
  3. 服务端执行 recoverAddress(message, signature)
  4. 若恢复出的地址与用户声称地址一致,则说明签名有效

在以太坊生态中,最常见的是:

  • personal_sign
  • eth_signTypedData_v4(EIP-712)

个人建议

如果你是第一次搭系统,登录认证先用 personal_sign
原因很现实:

  • 钱包兼容性更稳
  • 实现更简单
  • 排查成本更低

如果你需要更强结构化表达,再升级到 EIP-712。


2. 为什么需要 nonce

nonce 的目的不是“随机好看”,而是抗重放攻击

如果没有 nonce:

  • 攻击者截获一次合法签名
  • 以后就可以不断拿这个签名来冒充用户登录

有了 nonce 后:

  • 每次登录的消息都不同
  • 服务端验签后立即把 nonce 标记为已使用或直接销毁
  • 同一份签名无法再次通过

3. 推荐消息结构

虽然可以手写任意字符串,但建议消息里至少包含:

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

一个典型示例如下:

example.com wants you to sign in with your Ethereum account:
0xAbC123...

Sign in to Example App.

URI: https://example.com
Version: 1
Chain ID: 1
Nonce: a8f9c2d1
Issued At: 2024-01-01T10:00:00.000Z
Expiration Time: 2024-01-01T10:05:00.000Z

4. 登录序列图

sequenceDiagram
    participant U as 用户
    participant W as 钱包
    participant F as 前端
    participant B as 后端

    U->>F: 点击登录
    F->>W: 请求连接钱包
    W-->>F: 返回 address
    F->>B: GET /auth/nonce?address=...
    B-->>F: 返回 nonce + message
    F->>W: personal_sign(message)
    U->>W: 确认签名
    W-->>F: 返回 signature
    F->>B: POST /auth/verify
    B->>B: 恢复地址并校验 nonce/时效
    B-->>F: JWT / Set-Cookie

5. 方案对比与取舍

方案 A:只连接钱包,不做签名

优点

  • 实现最简单
  • 几乎零后端逻辑

缺点

  • 没有认证能力
  • 不能证明地址控制权
  • 不能安全建立会话

结论

只能做“展示地址”,不能叫登录。


方案 B:personal_sign 登录

优点

  • 实现简单
  • 钱包兼容性好
  • 对大多数 DApp 足够用

缺点

  • 消息结构化程度一般
  • 用户在钱包里看到的是原始文本

结论

非常适合作为第一版生产方案。


方案 C:EIP-712 Typed Data 登录

优点

  • 结构化强
  • 字段更清晰
  • 更适合复杂授权场景

缺点

  • 前后端实现更复杂
  • 某些钱包或移动端兼容性需要额外测试

结论

适合有协议化需求、审计要求更高的系统。


实战代码(可运行)

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

  • 前端:原生 HTML + ethers.js
  • 后端:Node.js + Express + ethers + jsonwebtoken
  • 认证方式:personal_sign
  • 会话方式:JWT

为了让示例能直接跑,我这里用内存存 nonce。
真正生产环境请替换成 Redis 或数据库。


项目结构

web3-wallet-auth/
├─ server/
│  ├─ package.json
│  └─ index.js
└─ client/
   └─ index.html

后端实现

1. 安装依赖

mkdir -p web3-wallet-auth/server
cd web3-wallet-auth/server
npm init -y
npm install express cors ethers jsonwebtoken

2. server/index.js

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

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

const PORT = 3001;
const JWT_SECRET = "replace_this_in_production";

// 用内存存储 nonce,生产环境换成 Redis/DB
// key: address.toLowerCase()
const nonceStore = new Map();

function generateNonce() {
  return crypto.randomBytes(16).toString("hex");
}

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

Sign in to Example App.

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

// 1) 获取登录挑战消息
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 record = {
    nonce,
    chainId: Number(chainId),
    issuedAt,
    expirationTime,
    domain: "localhost:5500",
    uri: "http://localhost:5500"
  };

  nonceStore.set(address.toLowerCase(), record);

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

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

// 2) 验签登录
app.post("/auth/verify", (req, res) => {
  try {
    const { address, message, signature } = req.body;

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

    const record = nonceStore.get(address.toLowerCase());
    if (!record) {
      return res.status(400).json({ error: "Nonce not found" });
    }

    if (new Date(record.expirationTime).getTime() < Date.now()) {
      nonceStore.delete(address.toLowerCase());
      return res.status(400).json({ error: "Nonce expired" });
    }

    // 验签
    const recovered = ethers.verifyMessage(message, signature);

    if (recovered.toLowerCase() !== address.toLowerCase()) {
      return res.status(401).json({ error: "Signature mismatch" });
    }

    // 核验 message 是否包含当前服务端签发的 nonce,避免伪造 message
    if (!message.includes(`Nonce: ${record.nonce}`)) {
      return res.status(401).json({ error: "Invalid nonce in message" });
    }

    if (!message.includes(`Chain ID: ${record.chainId}`)) {
      return res.status(401).json({ error: "Invalid chainId in message" });
    }

    if (!message.includes(`URI: ${record.uri}`)) {
      return res.status(401).json({ error: "Invalid uri in message" });
    }

    // 一次性使用
    nonceStore.delete(address.toLowerCase());

    const token = jwt.sign(
      {
        sub: address.toLowerCase(),
        wallet: address.toLowerCase(),
        loginType: "evm-wallet"
      },
      JWT_SECRET,
      { expiresIn: "2h" }
    );

    return res.json({
      ok: true,
      token,
      address: address.toLowerCase()
    });
  } catch (err) {
    console.error(err);
    return res.status(500).json({ error: "Verification failed" });
  }
});

// 3) 受保护接口示例
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({
      wallet: payload.wallet,
      loginType: payload.loginType
    });
  } catch (err) {
    res.status(401).json({ error: "Invalid token" });
  }
});

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

3. 启动后端

node index.js

前端实现

为了演示最小可运行逻辑,我这里不引入 React,直接用一个静态页面。

client/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>Wallet Login Demo</title>
</head>
<body>
  <h2>EVM 钱包登录演示</h2>
  <button id="connectBtn">连接钱包并登录</button>
  <button id="meBtn">获取当前用户信息</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 connectAndLogin() {
      if (!window.ethereum) {
        alert("请先安装 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);

        log({ step: "wallet connected", address, chainId });

        const nonceResp = await fetch(
          `http://localhost:3001/auth/nonce?address=${address}&chainId=${chainId}`
        );
        const nonceData = await nonceResp.json();

        if (!nonceResp.ok) {
          throw new Error(nonceData.error || "Failed to get nonce");
        }

        const message = nonceData.message;

        // 使用 signer.signMessage,本质对应 personal_sign 流程
        const signature = await signer.signMessage(message);

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

        const verifyData = await verifyResp.json();

        if (!verifyResp.ok) {
          throw new Error(verifyData.error || "Verify failed");
        }

        localStorage.setItem("token", verifyData.token);
        log({
          step: "login success",
          data: verifyData
        });
      } catch (err) {
        console.error(err);
        log(`登录失败: ${err.message}`);
      }
    }

    async function getMe() {
      const token = localStorage.getItem("token");
      if (!token) {
        log("请先登录");
        return;
      }

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

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

    document.getElementById("connectBtn").addEventListener("click", connectAndLogin);
    document.getElementById("meBtn").addEventListener("click", getMe);
  </script>
</body>
</html>

运行前端

你可以直接用任意静态服务启动,例如:

cd ../client
python3 -m http.server 5500

然后访问:

http://localhost:5500

一次完整验证流程

你可以按下面顺序手动测试:

  1. 打开前端页面
  2. 点击“连接钱包并登录”
  3. 钱包授权地址访问
  4. 钱包弹出签名确认
  5. 前端把签名提交给后端
  6. 后端验签成功,返回 JWT
  7. 点击“获取当前用户信息”
  8. 能拿到 /me 返回结果,说明登录闭环成立

认证状态图

stateDiagram-v2
    [*] --> Disconnected
    Disconnected --> Connected: 连接钱包
    Connected --> ChallengeIssued: 请求 nonce
    ChallengeIssued --> Signed: 用户完成签名
    Signed --> Verified: 后端验签成功
    Signed --> Rejected: 验签失败/过期/重放
    Verified --> SessionActive: 签发 JWT/Session
    SessionActive --> Disconnected: 退出登录/过期

从架构角度看:这套系统为什么成立

这里我们稍微上升一个层次,不只是“能跑”,而是理解它为什么是合理的。

1. 钱包负责身份控制

私钥只在钱包端保管,服务端永远不触碰私钥。
这意味着:

  • 降低平台保管凭证的风险
  • 认证权交给用户控制的钱包
  • 平台只负责验证,不负责托管

2. 后端负责挑战和会话

服务端生成 nonce 和消息,是为了:

  • 把登录请求和当前站点绑定
  • 限定请求时效
  • 防止签名被复用
  • 把一次性签名转换成后续可用的会话 token

这其实和传统 Web 的“验证码换 session”很像,只是验证码换成了钱包签名。

3. JWT 只是会话载体,不是认证本体

很多同学容易混淆:

  • 签名认证:证明你拥有地址
  • JWT:证明你已经通过本系统登录

JWT 不是为了替代签名,而是为了避免用户每次请求都签名。
否则每调一个接口都弹钱包,用户会疯掉。


常见坑与排查

这部分我建议你认真看。
因为真实项目里,80% 的时间都花在这些“明明看起来没问题”的细节上。

1. 前端签了,后端却验不过

常见原因

  • 前后端使用的 message 字符串不完全一致
  • 字符串中的换行、空格、缩进不同
  • 地址大小写混用
  • 前端签的是 UTF-8 文本,后端按别的格式处理

排查方法

第一步,不要猜,直接打印:

console.log("message from frontend:", JSON.stringify(message));
console.log("signature:", signature);

后端也打印:

console.log("message on server:", JSON.stringify(message));

重点看:

  • 是否有 \n
  • 是否多了空格
  • 是否字段顺序不同

我当时踩过一个很典型的坑:前端拼接文案时多了一行空行,结果验签一直失败,看了半天代码才发现。


2. recovered address 和用户地址不一致

常见原因

  • 用户切换了钱包账号
  • 前端拿到地址后,签名前地址变了
  • 使用了错误签名方法
  • 你验的是 hash,但签的是原始 message

建议

签名前重新取一次 signer 地址:

const signer = await provider.getSigner();
const address = await signer.getAddress();

然后服务端统一:

recovered.toLowerCase() === address.toLowerCase()

3. nonce 明明生成了,却总提示无效

常见原因

  • nonce 被覆盖了
  • 同一地址并发登录,旧 nonce 失效
  • 后端重启后内存数据丢失
  • 前端拿了旧 message 去签

解决思路

生产环境使用 Redis,结构例如:

  • key: login_nonce:${address}
  • value: { nonce, issuedAt, expirationTime, chainId, domain }
  • TTL: 5 分钟

并且验签成功后立即删除。


4. 本地能跑,上线后签名总失败

常见原因

  • 线上 domain 和本地写死的不一致
  • 反向代理后 HostOrigin 不一致
  • HTTPS 域名变了,消息中的 URI 还写着 localhost

建议

消息中的:

  • domain
  • uri

都应该由后端根据实际环境动态生成,而不是硬编码在前端。


5. 用户签名后拒绝登录,提示链 ID 不匹配

场景

你要求用户在主网登录,但他当前连的是 Sepolia 或 BSC。

处理建议

在前端签名前先检查链:

const network = await provider.getNetwork();
if (Number(network.chainId) !== 1) {
  alert("请切换到 Ethereum Mainnet");
  return;
}

如果你支持多链,也要在后端明确允许列表。


安全最佳实践

这一部分决定了你的系统是“Demo 能跑”,还是“上线能扛”。

1. nonce 必须一次性、短时有效

最低要求:

  • 长度足够随机
  • TTL 3~10 分钟
  • 使用后立刻销毁

不要复用 nonce,也不要把旧 nonce 当缓存继续接受。


2. 验签后必须比对服务端原始挑战内容

很多实现只做:

ethers.verifyMessage(message, signature)

这还不够。
因为攻击者完全可以对另一个 message 做合法签名。

服务端必须确认这份 message 就是自己发出的挑战,至少校验:

  • nonce
  • chainId
  • domain
  • uri
  • issuedAt / expirationTime

更稳妥的做法是:服务端存完整 challenge,验签时逐字段比对


本文演示用 JWT + localStorage,是为了简单。
但上线时我更建议:

  • token 放在 HttpOnly + Secure + SameSite Cookie
  • 减少 XSS 窃取 token 风险

如果你必须用 JWT,也要控制:

  • 过期时间
  • 刷新机制
  • 撤销策略

4. 限流与风控不能省

钱包登录并不意味着不需要风控。
后端至少要对这些接口做限流:

  • /auth/nonce
  • /auth/verify

否则会被刷:

  • 挑战生成接口
  • 验签接口
  • 绑定/注册接口

可以按以下维度限流:

  • IP
  • address
  • User-Agent
  • 设备指纹(可选)

5. 绑定业务账户时要做好幂等

真实项目里,登录成功后一般还会做:

  • 自动注册用户
  • 绑定邀请关系
  • 初始化画像
  • 同步链上资产快照

这些动作必须幂等。
因为用户可能重复登录、重复点击、网络重试。


6. 不要默认 EOA 是唯一身份形态

这一点是中级项目常被忽略的。

随着账户抽象和智能合约钱包普及,未来你会遇到:

  • Safe
  • Argent
  • AA 钱包
  • 合约账户签名校验

EOA 可以用 verifyMessage 恢复地址,但合约钱包往往需要走 EIP-1271 验证。
如果你的产品面向更广用户,后续应扩展为:

  • EOA:ECDSA 恢复地址
  • Contract Wallet:调用 isValidSignature

性能与容量估算

认证接口虽然单次逻辑不重,但登录高峰时也有几个注意点。

1. 验签开销并不大,瓶颈常在存储和限流

对大多数 Node 服务来说:

  • 单次 verifyMessage 开销可接受
  • 更常见瓶颈在 Redis、数据库、日志、网关限流

所以优化优先级建议是:

  1. nonce 存储与删除
  2. 接口限流
  3. JWT 签发
  4. 日志采样

2. nonce 存储建议放 Redis

原因很直接:

  • 天然支持 TTL
  • 多实例共享
  • 适合短生命周期挑战数据

一个 nonce 记录即使按 300~500 字节估算,
10 万并发待验证登录请求,也不过几十 MB 量级,完全可控。


3. JWT 不要塞太多业务字段

JWT 的 payload 建议只放:

  • sub
  • wallet
  • iat
  • exp
  • 少量角色信息

不要把用户画像、资产列表、权限全集都塞进去。
否则:

  • token 过大
  • 网关传输浪费
  • 权限变更难即时生效

进阶扩展:升级到 EIP-712 的思路

如果你的系统后续要支持更结构化的签名消息,可以升级到 EIP-712。

典型优点:

  • 字段明确
  • 更适合机器解析
  • 审计更友好

但你要多处理:

  • domain separator
  • typed data schema
  • 钱包兼容差异
  • 前后端字段序列化一致性

如果是团队第一版,我一般建议:

  • 先用 personal_sign 把认证链路跑通
  • 稳定后再按场景升级 EIP-712
  • 若要支持合约钱包,再加 EIP-1271

这个演进路径更稳,不容易一上来把复杂度拉满。


一个更稳的生产版落地建议

如果你准备真正上线,我建议至少做到下面这些:

基础版

  • personal_sign
  • Redis nonce
  • JWT 或 Cookie 会话
  • 限流
  • 登录审计日志

进阶版

  • SIWE 标准消息
  • 多链支持
  • 钱包切链引导
  • 自动注册/绑定流程幂等化
  • 风控评分

企业版/高安全版

  • EIP-712
  • EIP-1271 合约钱包支持
  • 设备风控
  • 会话撤销
  • 登录行为告警
  • 签名内容审计与灰度发布

总结

我们这篇文章搭建的,不只是“钱包弹一下签名”的 Demo,而是一套完整的 EVM 钱包登录认证架构:

  • 前端连接钱包
  • 后端生成 challenge
  • 用户签名证明地址控制权
  • 服务端验签并做 nonce/时效/domain/chainId 校验
  • 最终签发业务会话

核心结论可以记住三点:

  1. 连接钱包不是登录,签名验证才是认证核心
  2. nonce + 过期时间 + 域名绑定,是防重放的基础
  3. 认证解决“你是谁”,授权解决“你能做什么”

如果你现在要开始做第一版,我的可执行建议是:

  • 先上 personal_sign
  • nonce 放 Redis,5 分钟过期,一次性消费
  • 服务端保存完整 challenge 并逐字段校验
  • 会话优先用 HttpOnly Cookie
  • 为未来预留 EIP-712 和 EIP-1271 扩展位

边界条件也要明确:

  • 本文示例主要针对 EOA(普通 EVM 钱包)
  • 如果要支持 智能合约钱包,验签逻辑需要补充 EIP-1271
  • 如果你的系统是高价值资产场景,光有登录认证还不够,必须叠加风控和交易确认策略

你可以先把本文代码跑通,再逐步替换成 Redis、标准 SIWE、Cookie 会话和多链配置。
这样做的好处是:每一步都能验证、能回滚、能上线,而不是一开始就把系统做得又重又脆。


分享到:

上一篇
《Web3 中级实战:从智能合约审计到前端签名验证,构建一套安全的 DApp 登录与授权方案》
下一篇
《Docker 多阶段构建与镜像瘦身实战:从构建优化到安全发布的完整方案》