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

《Web3 中级实战:用 EIP-712 与钱包签名实现链上身份认证与防重放登录系统》

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

Web3 中级实战:用 EIP-712 与钱包签名实现链上身份认证与防重放登录系统

在 Web3 应用里,“连接钱包”很容易,“安全登录”其实没那么容易。

很多项目一开始会直接让用户签一段字符串,比如:

Sign this message to login

它能跑,但问题也很明显:

  • 消息不可读,用户不知道自己到底签了什么
  • 签名内容结构化程度低,前后端容易约定不一致
  • 很容易遗漏 nonce、防重放、域隔离、过期时间 这些关键字段
  • 一旦把“登录签名”和“别的业务签名”混在一起,风险会上升

如果你的系统已经从 Demo 走向真实用户,建议尽快从“随便签个字符串”升级到 EIP-712 结构化签名登录

这篇文章,我会从架构视角把这套方案拆开:为什么要这样做、关键字段怎么设计、前后端如何配合、链上与链下边界怎么划分,以及一套可运行的 Node.js 示例代码。你可以把它当作一个中级项目的登录基线。


背景与问题

传统 Web2 登录和 Web3 登录的根本差异

Web2 登录通常依赖:

  • 用户名 + 密码
  • 短信验证码
  • OAuth 三方登录
  • 服务端会话或 JWT

而 Web3 登录更像:

  • 用户持有私钥
  • 钱包负责签名
  • 服务端验证签名
  • 验证通过后签发应用自己的 session/JWT

也就是说,钱包签名本质上是在证明“我控制这个地址”,而不是直接替代你业务系统里的会话层。

只做“签名登录”还不够

如果你只是让用户签名,然后服务端验一下地址是否对得上,常见问题马上就会出现:

  1. 重放攻击 攻击者拿到一份旧签名,在有效期内甚至长期重复提交。

  2. 跨域重放 某个站点的签名,被另一个站点拿去冒用。

  3. 跨链重放 没有 chainId 约束时,不同链环境可能出现误用。

  4. 签名语义不清 用户钱包里只看到一段乱七八糟的字符串,体验差,也容易误签。

  5. Nonce 生命周期管理缺失 登录 nonce 生成了,但没有一次性消费,没有过期策略,也没有并发控制。

所以问题的关键不是“能不能验签”,而是:

如何设计一套既符合钱包生态、又能抵抗重放、还能稳定集成前后端的认证协议?


方案目标与架构边界

在这类系统里,我通常建议先明确 4 个目标:

  • 身份证明:用户确实控制某个钱包地址
  • 防重放:同一份签名不能重复利用
  • 域隔离:签名只对当前应用有效
  • 可审计:登录过程可追踪、可排查、可扩展

推荐架构

  • 钱包:负责 EIP-712 签名
  • 前端:请求 challenge、发起签名、提交签名结果
  • 认证服务:生成 nonce、组织 typed data、验签、消费 nonce、签发 JWT/session
  • 链上合约:不是登录必需,但可作为权限补充来源,比如是否持有 NFT、是否具备某角色

这里要强调一个边界:

登录认证本身通常是链下完成的,链上更多负责“权限证明素材”,而不是承担每次登录开销。


方案总览

整个登录流程建议拆成两个阶段:

  1. Challenge 获取阶段 服务端生成一次性 nonce,并结合 domain、chainId、issuedAt、expiration 等信息返回给前端

  2. 签名验证阶段 前端用钱包对 EIP-712 typed data 签名,服务端验证签名、核对 nonce 未使用且未过期,然后签发会话令牌


flowchart TD
    A[前端请求登录挑战 challenge] --> B[服务端生成 nonce]
    B --> C[服务端返回 EIP-712 typed data]
    C --> D[前端调用钱包 signTypedData]
    D --> E[用户确认签名]
    E --> F[前端提交 address + signature + challengeId]
    F --> G[服务端恢复 signer 地址]
    G --> H{nonce 未使用且未过期?}
    H -- 否 --> I[拒绝登录]
    H -- 是 --> J[标记 nonce 已消费]
    J --> K[签发 JWT/Session]

核心原理

1. 为什么选 EIP-712

EIP-712 的价值不只是“结构化”,而是它天然适合做认证协议:

  • 用户钱包能展示字段化内容
  • 可以引入 domain.namedomain.versionchainIdverifyingContract
  • 消息类型明确,前后端不容易串格式
  • eth_signpersonal_sign 相比,可读性和安全边界更清晰

一个典型的登录消息应该包含什么

我推荐至少包含这些字段:

  • wallet:用户地址
  • nonce:一次性随机值
  • issuedAt:签发时间
  • expirationTime:过期时间
  • chainId:链 ID
  • statement:给用户看的简短说明
  • uri:当前应用域名或站点 URI

如果你要支持多租户、多环境或更严格隔离,还可以加入:

  • audience
  • requestId
  • resources
  • sessionKeyHint

2. 防重放靠什么实现

很多人以为“有签名就安全”,其实不对。防重放的核心不是签名算法,而是协议状态管理。

完整防重放至少要做到:

  • nonce 随机且唯一
  • nonce 一次性消费
  • 签名消息带过期时间
  • 签名消息绑定 domain / chainId / uri
  • 服务端验签后原子性更新 nonce 状态

也就是说,防重放真正落地的关键点在服务端数据库。


sequenceDiagram
    participant U as 用户钱包
    participant F as 前端
    participant S as 认证服务
    participant DB as Nonce存储

    F->>S: GET /auth/challenge?address=0x...
    S->>DB: 创建 nonce(status=pending, expireAt)
    DB-->>S: 返回 challengeId + nonce
    S-->>F: typedData
    F->>U: signTypedData(typedData)
    U-->>F: signature
    F->>S: POST /auth/verify
    S->>S: recoverAddress(signature, typedData)
    S->>DB: 原子更新 nonce pending -> used
    alt 成功
        S-->>F: JWT / Session
    else 已使用或过期
        S-->>F: 401/409 拒绝
    end

3. 域分离为什么重要

EIP-712 的 domain 可以理解为“这份签名的作用范围”。

推荐至少设置:

  • name: 应用名,比如 MyDapp Auth
  • version: 协议版本,比如 1
  • chainId: 当前链 ID
  • verifyingContract: 如果是纯链下登录,也可以固定一个协议标识地址;如果你有实际认证相关合约,也可以绑定它

这里我踩过一个坑:有些团队做纯链下登录时,把 verifyingContract 留空,表面上没问题,但后续多环境、多协议版本并存时,签名边界会变模糊。
更稳妥的做法是:

  • 要么明确约定一个固定占位地址并写入文档
  • 要么至少保证 name + version + chainId + uri 的组合足够唯一

方案对比与取舍分析

方案 A:personal_sign 登录

优点

  • 集成最简单
  • 老项目兼容性高

缺点

  • 文本可读性依赖你自己拼接
  • 结构容易不统一
  • 钱包展示体验不稳定
  • 字段约束弱,长期维护成本高

方案 B:EIP-712 登录

优点

  • 结构化、可审计
  • 域分离更清晰
  • 更适合中长期演进
  • 对防重放字段支持自然

缺点

  • 前后端类型定义必须严格一致
  • 某些钱包/移动端兼容性需要额外测试

方案 C:SIWE(Sign-In with Ethereum, EIP-4361)

优点

  • 标准化程度高
  • 多数钱包和生态工具支持较好
  • 适合通用 Ethereum 登录

缺点

  • 文本消息为主,不是纯 EIP-712
  • 如果你有更复杂业务字段,扩展方式未必最顺手

我的建议

  • 如果你要做 通用以太坊账号登录标准兼容:优先评估 SIWE
  • 如果你要做 定制化业务认证协议、强域隔离、多字段结构化管理:优先 EIP-712
  • 如果你只是做内部工具或 PoC:personal_sign 可用,但要尽快升级

数据模型设计

一个实用的 login_challenge 表,至少需要这些字段:

CREATE TABLE login_challenge (
  id VARCHAR(64) PRIMARY KEY,
  wallet_address VARCHAR(42) NOT NULL,
  nonce VARCHAR(128) NOT NULL UNIQUE,
  chain_id BIGINT NOT NULL,
  domain_name VARCHAR(128) NOT NULL,
  uri TEXT NOT NULL,
  issued_at TIMESTAMP NOT NULL,
  expiration_time TIMESTAMP NOT NULL,
  status VARCHAR(16) NOT NULL,
  signature TEXT,
  used_at TIMESTAMP NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

状态建议

  • pending:已签发 challenge,待提交签名
  • used:已成功消费
  • expired:已过期
  • revoked:主动撤销,比如风险控制命中

stateDiagram-v2
    [*] --> pending
    pending --> used: 验签成功并原子消费
    pending --> expired: 超时
    pending --> revoked: 风控撤销
    used --> [*]
    expired --> [*]
    revoked --> [*]

实战代码(可运行)

下面给一套可运行的最小实现:

  • 后端:Node.js + Express + ethers
  • 前端:浏览器 + MetaMask / 兼容 EIP-712 钱包
  • 存储:为了方便演示,先用内存 Map;生产环境请换成 Redis / PostgreSQL

后端:认证服务

先安装依赖:

npm init -y
npm install express ethers jsonwebtoken cors

server.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";
const APP_DOMAIN = "localhost";
const APP_URI = "http://localhost:5173";
const CHAIN_ID = 1;

// 演示用内存存储
// 生产环境请使用 Redis / PostgreSQL,并使用事务或原子 CAS 更新
const challenges = new Map();

function randomNonce(size = 16) {
  return crypto.randomBytes(size).toString("hex");
}

function buildTypedData({ address, nonce, issuedAt, expirationTime }) {
  return {
    domain: {
      name: "MyDapp Auth",
      version: "1",
      chainId: CHAIN_ID,
      verifyingContract: "0x0000000000000000000000000000000000000001",
    },
    types: {
      Login: [
        { name: "wallet", type: "address" },
        { name: "statement", type: "string" },
        { name: "nonce", type: "string" },
        { name: "uri", type: "string" },
        { name: "issuedAt", type: "string" },
        { name: "expirationTime", type: "string" },
      ],
    },
    primaryType: "Login",
    message: {
      wallet: address,
      statement: "Sign this message to authenticate with MyDapp.",
      nonce,
      uri: APP_URI,
      issuedAt,
      expirationTime,
    },
  };
}

app.get("/auth/challenge", (req, res) => {
  const address = req.query.address;

  if (!address || !ethers.isAddress(address)) {
    return res.status(400).json({ error: "invalid address" });
  }

  const id = crypto.randomUUID();
  const nonce = randomNonce(16);
  const issuedAt = new Date().toISOString();
  const expirationTime = new Date(Date.now() + 5 * 60 * 1000).toISOString();

  const typedData = buildTypedData({
    address,
    nonce,
    issuedAt,
    expirationTime,
  });

  challenges.set(id, {
    id,
    address: ethers.getAddress(address),
    nonce,
    issuedAt,
    expirationTime,
    status: "pending",
    typedData,
    createdAt: Date.now(),
  });

  res.json({
    challengeId: id,
    typedData,
  });
});

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

    if (!challengeId || !signature) {
      return res.status(400).json({ error: "missing challengeId or signature" });
    }

    const record = challenges.get(challengeId);
    if (!record) {
      return res.status(404).json({ error: "challenge not found" });
    }

    if (record.status !== "pending") {
      return res.status(409).json({ error: "challenge already used or invalid" });
    }

    if (new Date(record.expirationTime).getTime() < Date.now()) {
      record.status = "expired";
      return res.status(401).json({ error: "challenge expired" });
    }

    const recovered = ethers.verifyTypedData(
      record.typedData.domain,
      record.typedData.types,
      record.typedData.message,
      signature
    );

    const normalizedRecovered = ethers.getAddress(recovered);
    if (normalizedRecovered !== record.address) {
      return res.status(401).json({ error: "signature verification failed" });
    }

    // 演示里直接更新;生产环境必须原子消费,避免并发重放
    record.status = "used";
    record.signature = signature;
    record.usedAt = Date.now();

    const token = jwt.sign(
      {
        sub: record.address,
        wallet: record.address,
        authMethod: "wallet_eip712",
      },
      JWT_SECRET,
      { expiresIn: "2h", issuer: APP_DOMAIN, audience: APP_URI }
    );

    res.json({
      ok: true,
      address: record.address,
      token,
    });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "internal error" });
  }
});

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

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

  try {
    const payload = jwt.verify(token, JWT_SECRET, {
      issuer: APP_DOMAIN,
      audience: APP_URI,
    });
    res.json({ user: payload });
  } catch (err) {
    res.status(401).json({ error: "invalid token" });
  }
});

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

前端:发起签名并登录

下面是一段最小化浏览器脚本。你也可以很容易改成 React + wagmi + viem 版本。

index.html

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>EIP-712 Login Demo</title>
  </head>
  <body>
    <button id="connectBtn">连接钱包并登录</button>
    <pre id="output"></pre>

    <script>
      const output = document.getElementById("output");
      const log = (msg) => {
        output.textContent += `${typeof msg === "string" ? msg : JSON.stringify(msg, null, 2)}\n`;
      };

      async function main() {
        if (!window.ethereum) {
          alert("请安装 MetaMask");
          return;
        }

        const accounts = await window.ethereum.request({
          method: "eth_requestAccounts",
        });
        const address = accounts[0];
        log("当前地址: " + address);

        const challengeResp = await fetch(`http://localhost:3001/auth/challenge?address=${address}`);
        const challenge = await challengeResp.json();

        if (!challenge.challengeId) {
          log(challenge);
          return;
        }

        const typedData = challenge.typedData;

        // 注意:eth_signTypedData_v4 需要第二个参数是字符串化 JSON
        const signature = await window.ethereum.request({
          method: "eth_signTypedData_v4",
          params: [address, JSON.stringify(typedData)],
        });

        log("签名成功: " + signature);

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

        const verifyResult = await verifyResp.json();
        log(verifyResult);

        if (verifyResult.token) {
          localStorage.setItem("token", verifyResult.token);

          const meResp = await fetch("http://localhost:3001/me", {
            headers: {
              Authorization: `Bearer ${verifyResult.token}`,
            },
          });
          const me = await meResp.json();
          log(me);
        }
      }

      document.getElementById("connectBtn").onclick = () => {
        main().catch((err) => {
          console.error(err);
          log("出错: " + err.message);
        });
      };
    </script>
  </body>
</html>

如何运行

先启动后端:

node server.js

然后用任意静态服务器启动前端,比如:

npx serve .

打开页面后:

  1. 点击“连接钱包并登录”
  2. 获取 challenge
  3. 钱包弹窗展示 EIP-712 登录消息
  4. 用户确认签名
  5. 服务端验签成功并返回 JWT
  6. 调用 /me 验证登录状态

如果你用 React + ethers / wagmi,核心调用是什么

如果你不想直接使用 window.ethereum.request,可以用库封装。核心逻辑仍然是:

const signature = await signer.signTypedData(domain, types, message);

例如 ethers v6:

const signature = await signer.signTypedData(
  typedData.domain,
  typedData.types,
  typedData.message
);

注意:有些库要求你 不要传入 EIP712Domain 类型定义,否则会报错。这个兼容性坑非常常见。


容量估算与系统取舍

对于登录 challenge 服务,容量主要取决于:

  • nonce 存储数量
  • challenge TTL
  • 登录峰值 QPS
  • JWT 签发频率

粗略估算

假设:

  • 峰值登录请求 200 QPS
  • challenge 有效期 5 分钟
  • 平均每条 challenge 存储 1 KB

则同时在库 challenge 数量大约:

200 * 300 = 60000

占用存储约:

60000 * 1KB ≈ 60MB

这对 Redis 来说是完全可接受的。

Redis 还是数据库

Redis 适合:

  • challenge 短期存储
  • 高频读写
  • TTL 自动过期
  • 原子消费操作

PostgreSQL/MySQL 适合:

  • 审计留痕
  • 风险分析
  • 登录行为追踪
  • 后台排查

我的建议是:

  • 在线认证状态放 Redis
  • 审计日志异步落数据库

这样一般比较均衡。


常见坑与排查

这一部分非常重要。我见过很多“签名不通过”的问题,最后都不是密码学错误,而是字段不一致。

1. 前后端 typedData 不一致

最常见的坑:

  • chainId 前端和后端不一致
  • message.wallet 地址大小写格式不同
  • 时间字段一个是 ISO 字符串,一个是 Unix 时间戳
  • 字段顺序或类型不一致
  • primaryType 写错

排查办法:

  • 服务端把最终参与验签的 typedData 原样打印
  • 前端把传给钱包的 JSON 也打印
  • 对比每个字段,尤其是 domaintypes

建议直接规定:
前端不自己拼 typedData,由后端返回完整结构。

这样能减少大量协作误差。


2. eth_signTypedData_v4 与库封装差异

有的钱包注入接口要:

params: [address, JSON.stringify(typedData)]

而 ethers 的 signTypedData 则是:

signTypedData(domain, types, message)

如果你把 typedData 整体直接塞进去,通常会报参数格式错误。


3. types 里误传 EIP712Domain

在使用 ethers.verifyTypedData 时,很多时候 types 应该只传业务类型,比如:

{
  Login: [...]
}

不要额外传:

EIP712Domain: [...]

否则不同库行为可能不一致。


4. 地址比较没有标准化

以太坊地址最好统一处理:

ethers.getAddress(address)

不要直接字符串比较原始输入。
因为用户提交的小写地址、校验和地址、钱包返回地址可能格式不同。


5. nonce 已过期但前端还在用旧 challenge

这在移动端切后台、用户长时间不确认签名时很常见。

建议:

  • challenge TTL 设为 3~5 分钟
  • 前端遇到 challenge expired 自动重新拉取 challenge
  • UI 明确提示“签名已过期,请重试”

6. 并发重放

一个非常隐蔽的坑是:

  • 同一份签名几乎同时打到两个服务实例
  • 两边都先查到 pending
  • 两边都成功验签
  • 两边都签发 token

这说明你的 nonce 消费不是原子的。

正确做法

在 Redis 里可以用:

  • SETNX
  • Lua 脚本
  • 事务 / CAS

在数据库里可以用:

  • UPDATE ... WHERE status='pending'
  • 然后检查影响行数是否为 1

如果不是 1,就说明这次 challenge 已被别人消费。


安全最佳实践

1. 登录签名和交易签名彻底分离

不要让同一套消息结构既能做登录,又能被解释成链上授权意图。

登录签名应该只表达:

  • 认证
  • 会话建立
  • 风险确认

不要夹带:

  • Token 授权
  • Permit
  • Delegate
  • 合约调用意图

2. 严格设置过期时间

建议:

  • challenge 有效期:3~5 分钟
  • JWT 有效期:1~2 小时
  • 高敏操作额外要求重新签名

长会话不是不能做,但要结合刷新机制和风控。


3. 把域信息写清楚

建议固定这些信息:

  • name
  • version
  • chainId
  • uri
  • statement

并确保用户在钱包里能看懂签名用途。
一句人话,比十个技术字段更能降低误签率。


4. 做好风控与审计

建议记录:

  • 钱包地址
  • challengeId
  • nonce
  • IP
  • User-Agent
  • 签名时间
  • 验签结果
  • 拒绝原因

这样出了问题,你至少能回答:

  • 是签名字段错了?
  • 是 nonce 重放?
  • 是前端拿旧 challenge?
  • 还是用户钱包网络不匹配?

5. 生产环境不要用内存存储 nonce

单机 Demo 可以,生产环境绝对不建议。

因为内存存储会有这些问题:

  • 服务重启 challenge 全丢
  • 多实例之间状态不一致
  • 无法原子消费
  • 审计困难

最小可用方案是:

  • Redis 存 challenge + TTL + 原子消费
  • PostgreSQL 存审计日志

6. 明确链切换策略

如果你登录只接受某条链,比如 Ethereum Mainnet:

  • 前端先检查当前钱包网络
  • 不匹配时提示切链
  • 服务端也必须校验 domain.chainId

不要只做前端限制。
因为安全边界永远不能只靠前端。


性能最佳实践

1. Challenge 接口尽量无状态化

除了 nonce 存储外,/auth/challenge 尽量做成轻逻辑:

  • 生成 nonce
  • 写缓存
  • 返回 typedData

不要在这里夹杂太多链上查询。
链上权限判断可以放到登录成功后再做,或者异步缓存。


2. 验签很快,瓶颈多半不在密码学

verifyTypedData 本身开销一般不大。
真正的瓶颈通常是:

  • Redis/数据库写入
  • 审计日志
  • 下游用户信息初始化
  • 风控服务调用

所以优化重点应该放在:

  • 减少同步链路
  • 审计异步化
  • 用户画像延迟加载

3. 对 challenge 做自动清理

如果用 Redis,直接设置 TTL。
如果用数据库,定时任务清理 expired 和 오래未使用记录。

例如:

DELETE FROM login_challenge
WHERE created_at < NOW() - INTERVAL '7 days';

一个更稳的生产版设计建议

如果你已经进入正式业务,我建议把这套系统拆成这几个模块:

  • auth-api:challenge 签发、签名验证、JWT 签发
  • nonce-store:Redis,负责短期状态和原子消费
  • audit-log:数据库,负责留痕
  • policy-engine:可选,负责黑名单、限流、设备/IP 风险评分
  • permission-service:可选,从链上或索引服务同步 NFT / Role / Token 状态

这样做的好处是:

  • 登录链路保持短
  • 权限与身份解耦
  • 风险控制可独立扩展
  • 审计数据更完整

边界条件:什么时候这套方案不够

虽然 EIP-712 登录已经很实用,但它也不是万能的。

以下场景你可能要继续加能力:

1. 需要“会话持续签名”能力

比如高敏操作要再次确认,可以引入:

  • 二次签名确认
  • session key
  • passkey / WebAuthn 绑定

2. 需要多链统一身份

如果一个用户可能用同一钱包在多条 EVM 链活动,你需要单独设计:

  • 多链地址映射
  • 统一账户模型
  • 跨链权限聚合

3. 需要合约钱包支持

EOA 钱包验签可以直接恢复地址。
但合约钱包往往要考虑:

  • ERC-1271
  • 智能账户签名验证
  • Bundler / AA 场景

如果你的用户中合约钱包比例上升,服务端就不能只靠 recoverAddress,还需要支持 ERC-1271 签名有效性校验


总结

把钱包签名做成“能登录”并不难,难的是把它做成一套 可靠、可扩展、能防重放 的认证系统。

这篇文章的核心结论可以浓缩成 5 点:

  1. 用 EIP-712 替代随意字符串签名
  2. challenge 必须包含 nonce、过期时间、域信息、链信息
  3. 服务端必须原子消费 nonce,防并发重放
  4. 登录认证链下完成,链上更多提供权限依据
  5. 生产环境用 Redis + 审计数据库,不要用内存状态

如果你准备真正落地,我建议按这个顺序推进:

  • 第一步:后端统一生成 typedData,前端不自己拼
  • 第二步:nonce 加 TTL,并支持一次性消费
  • 第三步:登录成功后签发 JWT/session
  • 第四步:补审计、限流、风控
  • 第五步:评估 ERC-1271 和多链支持

一句实用建议收尾:

钱包登录不是“验签成功就结束”,而是“验签只是认证协议的开始”。

只有把 nonce、域隔离、过期、并发消费和会话管理都串起来,这套系统才算真正能上生产。


分享到:

上一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实践》
下一篇
《从单体到高可用:基于 Kubernetes 的中型业务集群架构设计与故障演练实战》