Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统
很多人第一次做 Web3 登录,都会把它理解成“让用户用 MetaMask 点一下签名,然后后端验签发个 token”。这当然没错,但如果你想把它真正做成可扩展、可审计、可接入业务系统的链上身份认证方案,事情就没那么简单了。
这篇文章我会从架构视角出发,带你搭一个中级可用的系统:
钱包登录 + 签名认证 + 智能合约身份绑定 + 后端会话签发。
重点不是“签名能不能验过”,而是:
- 如何把“钱包地址”提升为“可管理的身份”
- 如何设计链上与链下的职责边界
- 如何避免重放攻击、签名混淆、链切换、代理钱包兼容这些常见坑
- 如何落到一套可以运行的代码
背景与问题
在传统 Web2 里,身份通常来自手机号、邮箱、用户名密码,认证依赖中心化数据库。
但在 Web3 里,最自然的身份载体是钱包地址。
问题也随之出现:
-
钱包地址不等于完整身份
- 地址只能证明“我持有私钥”
- 不能天然表达昵称、角色、业务账号映射、权限状态
-
纯前端签名登录不够用
- 能登录,不代表可接入后端业务
- 缺少统一 session 管理、权限控制、风控入口
-
只靠后端数据库会丢失 Web3 特征
- 地址与用户关系如果只在中心化库里,跨应用复用性差
- 某些身份关系更适合公开、可验证、链上可组合
-
直接把所有认证逻辑放链上又不现实
- 成本高
- 延迟高
- 不适合频繁登录态校验
所以,一个更实用的方案通常是:
钱包签名做“身份持有证明”,智能合约做“身份关系登记”,后端做“会话和业务权限承载”。
这套模式兼顾了去中心化身份的可验证性,也保留了业务系统需要的性能与灵活性。
目标架构
我们先明确这套系统要实现什么:
- 用户用钱包发起登录
- 服务端生成一次性 nonce
- 用户签名 nonce
- 服务端验签确认地址归属
- 若用户尚未注册,则调用智能合约绑定一个链上 profile
- 服务端签发 JWT / Session
- 后续接口通过 session 识别用户
- 必要时可从链上读取身份状态进行二次校验
系统分层
flowchart LR
U[用户钱包] --> F[前端 DApp]
F --> B[认证后端]
B --> DB[(Nonce/Session DB)]
B --> RPC[区块链 RPC]
RPC --> C[IdentityRegistry 合约]
B --> Biz[业务服务]
这张图的关键点在于:
登录动作发生在钱包与后端之间,身份登记发生在后端与合约之间。
职责划分建议
| 组件 | 职责 |
|---|---|
| 前端 DApp | 连接钱包、请求 nonce、触发签名、提交签名结果 |
| 后端认证服务 | nonce 管理、验签、会话签发、链上身份同步 |
| 智能合约 | 地址与身份 ID 绑定、身份元数据哈希、状态管理 |
| 数据库 | 保存 nonce、用户快照、会话信息、审计日志 |
| 业务服务 | 根据 session 和链上身份状态决定授权 |
方案对比与取舍分析
做钱包登录时,常见有三种实现思路。
方案一:纯链下签名登录
流程很简单:服务端发 nonce,客户端签名,服务端验签。
优点:
- 成本低
- 实现快
- 不需要链上交易
缺点:
- 身份关系只在中心化数据库里
- 跨应用复用差
- 缺少公开可验证的身份登记层
适合:
- MVP
- 内部工具
- 对链上身份要求不高的产品
方案二:纯链上认证
让用户通过链上交易完成登录或认证状态变更。
优点:
- 强可验证
- 状态公开透明
缺点:
- 用户体验差
- 每次认证成本高
- 延迟大,不适合频繁调用
适合:
- 高可信链上流程
- 低频高价值认证场景
方案三:链下登录 + 链上身份绑定
也就是本文的方案。
优点:
- 登录体验好
- 业务接口仍然高性能
- 身份关系可公开验证
- 易于扩展权限、角色、资料绑定
缺点:
- 架构复杂度中等
- 需要维护链上链下一致性
适合:
- 中级以上 Web3 应用
- 需要“登录 + 业务账号体系 + 链上身份可验证”的系统
核心原理
核心原理其实可以拆成三层:
- 地址归属证明
- 身份绑定
- 会话承载
1. 地址归属证明:靠签名,不靠密码
服务端生成一个 nonce,比如:
Login to Demo DApp
Address: 0xabc...
Nonce: 2f7d8c...
ChainId: 11155111
IssuedAt: 2024-01-01T12:00:00Z
用户用钱包签名这段消息,服务端验签后就能知道:
- 这个签名确实由该地址持有者发起
- 这个登录请求是针对当前系统的
- 这个 nonce 没被重复使用
2. 身份绑定:靠合约记录“地址 -> profile”
仅有地址是不够的。我们通常希望有一个稳定身份对象,例如:
- userId / profileId
- metadataURI 或 metadataHash
- 激活/冻结状态
- 角色、等级、KYC 标记等
这些关系可以放到合约里维护。
classDiagram
class IdentityRegistry {
+register(address user, bytes32 profileId, string metadataURI)
+isRegistered(address user) bool
+getProfile(address user) bytes32
+setStatus(address user, uint8 status)
}
3. 会话承载:靠后端 JWT / Session
钱包签名适合做“登录入口”,但不适合每个 API 都重新签一次。
因此通常在首次验签成功后,由后端签发 JWT:
sub: 钱包地址profileId: 链上身份 IDchainIdrolesexp
后续业务接口只验证 JWT 即可,必要时再补一次链上状态校验。
认证时序
sequenceDiagram
participant U as 用户钱包
participant F as 前端
participant B as 后端
participant C as IdentityRegistry
F->>B: GET /auth/nonce?address=0x...
B-->>F: nonce + message
F->>U: 请求钱包签名
U-->>F: signature
F->>B: POST /auth/verify {address, message, signature}
B->>B: 验签 + 校验 nonce
alt 未注册
B->>C: register(address, profileId, metadataURI)
C-->>B: tx success
end
B-->>F: JWT + profile
这个流程里我建议你特别注意两点:
- nonce 必须一次性使用
- 链上注册最好异步化或加幂等控制
否则在高并发或用户重复点击时,很容易出现“登录成功但注册重复提交”的问题。
智能合约设计
下面给一个可运行的基础版本,使用 Solidity 0.8.x。
这个合约的目标不是做复杂 DID,而是作为一个轻量 Identity Registry。
合约代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract IdentityRegistry {
enum Status {
None,
Active,
Suspended
}
struct Profile {
bytes32 profileId;
string metadataURI;
Status status;
uint256 createdAt;
}
mapping(address => Profile) private profiles;
address public owner;
event IdentityRegistered(address indexed user, bytes32 indexed profileId, string metadataURI);
event IdentityStatusUpdated(address indexed user, Status status);
event MetadataUpdated(address indexed user, string metadataURI);
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
modifier onlyRegistered(address user) {
require(profiles[user].status != Status.None, "not registered");
_;
}
constructor() {
owner = msg.sender;
}
function register(
address user,
bytes32 profileId,
string calldata metadataURI
) external onlyOwner {
require(user != address(0), "zero address");
require(profiles[user].status == Status.None, "already registered");
profiles[user] = Profile({
profileId: profileId,
metadataURI: metadataURI,
status: Status.Active,
createdAt: block.timestamp
});
emit IdentityRegistered(user, profileId, metadataURI);
}
function setStatus(address user, Status status)
external
onlyOwner
onlyRegistered(user)
{
profiles[user].status = status;
emit IdentityStatusUpdated(user, status);
}
function updateMetadata(address user, string calldata metadataURI)
external
onlyOwner
onlyRegistered(user)
{
profiles[user].metadataURI = metadataURI;
emit MetadataUpdated(user, metadataURI);
}
function isRegistered(address user) external view returns (bool) {
return profiles[user].status != Status.None;
}
function getProfile(address user)
external
view
returns (
bytes32 profileId,
string memory metadataURI,
Status status,
uint256 createdAt
)
{
Profile memory p = profiles[user];
return (p.profileId, p.metadataURI, p.status, p.createdAt);
}
}
设计说明
这个版本用了最简单的 owner 模式,适合 demo 和中小型项目。
真实项目里,你可以进一步升级为:
AccessControl做角色管理- 多签控制
register权限 - 支持一个地址绑定多个身份
- 支持 EIP-712 授权注册
- metadata 只存哈希,实际资料放 IPFS / Arweave
实战代码(可运行)
下面用一套最小可运行方案来串起来:
- 合约:Solidity
- 后端:Node.js + Express + ethers
- 前端:原生 HTML + ethers.js
为了让你能直接试,我会尽量写成“拷贝就能改”的风格。
一、后端:认证服务
1. 安装依赖
mkdir web3-auth-demo
cd web3-auth-demo
npm init -y
npm install express cors jsonwebtoken ethers dotenv
2. 创建 .env
PORT=3000
JWT_SECRET=replace_this_with_a_long_random_string
RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY
PRIVATE_KEY=YOUR_SERVER_WALLET_PRIVATE_KEY
CONTRACT_ADDRESS=0xYourContractAddress
CHAIN_ID=11155111
这里的
PRIVATE_KEY是后端用于调用合约注册身份的服务钱包。
生产环境千万不要明文放机器里,至少接 KMS 或密钥托管。
3. 编写 server.js
const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { ethers } = require("ethers");
require("dotenv").config();
const app = express();
app.use(cors());
app.use(express.json());
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET;
const CHAIN_ID = Number(process.env.CHAIN_ID);
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const signer = new ethers.Wallet(process.env.PRIVATE_KEY, provider);
const abi = [
"function isRegistered(address user) view returns (bool)",
"function register(address user, bytes32 profileId, string metadataURI) external",
"function getProfile(address user) view returns (bytes32 profileId, string metadataURI, uint8 status, uint256 createdAt)"
];
const contract = new ethers.Contract(process.env.CONTRACT_ADDRESS, abi, signer);
// 简化演示:用内存存 nonce。生产环境请换 Redis/DB。
const nonces = new Map();
function buildMessage(address, nonce) {
return [
"Login to Demo DApp",
`Address: ${address}`,
`Nonce: ${nonce}`,
`ChainId: ${CHAIN_ID}`,
`IssuedAt: ${new Date().toISOString()}`
].join("\n");
}
app.get("/auth/nonce", async (req, res) => {
try {
const { address } = req.query;
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid address" });
}
const normalized = ethers.getAddress(address);
const nonce = crypto.randomBytes(16).toString("hex");
const message = buildMessage(normalized, nonce);
nonces.set(normalized, {
nonce,
message,
createdAt: Date.now(),
used: false
});
res.json({ address: normalized, nonce, message, chainId: CHAIN_ID });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
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 normalized = ethers.getAddress(address);
const record = nonces.get(normalized);
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.createdAt > 5 * 60 * 1000) {
return res.status(400).json({ error: "nonce expired" });
}
if (record.message !== message) {
return res.status(400).json({ error: "message mismatch" });
}
const recovered = ethers.verifyMessage(message, signature);
if (ethers.getAddress(recovered) !== normalized) {
return res.status(401).json({ error: "signature invalid" });
}
record.used = true;
let registered = await contract.isRegistered(normalized);
let profileId;
if (!registered) {
const profileIdHex = ethers.keccak256(
ethers.toUtf8Bytes(`${normalized}:${Date.now()}`)
);
profileId = profileIdHex;
const metadataURI = `ipfs://demo-profile/${normalized.toLowerCase()}`;
const tx = await contract.register(normalized, profileIdHex, metadataURI);
await tx.wait();
}
const profile = await contract.getProfile(normalized);
profileId = profile[0];
const token = jwt.sign(
{
sub: normalized,
wallet: normalized,
profileId,
chainId: CHAIN_ID
},
JWT_SECRET,
{ expiresIn: "2h" }
);
res.json({
token,
user: {
address: normalized,
profileId,
metadataURI: profile[1],
status: Number(profile[2])
}
});
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
app.get("/me", async (req, res) => {
try {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) {
return res.status(401).json({ error: "missing token" });
}
const payload = jwt.verify(token, JWT_SECRET);
const profile = await contract.getProfile(payload.wallet);
res.json({
wallet: payload.wallet,
profileId: profile[0],
metadataURI: profile[1],
status: Number(profile[2]),
chainId: payload.chainId
});
} catch (err) {
res.status(401).json({ error: err.message });
}
});
app.listen(PORT, () => {
console.log(`server running at http://localhost:${PORT}`);
});
二、前端:钱包连接与登录
新建一个 index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Web3 Wallet Login Demo</title>
</head>
<body>
<h2>Web3 钱包登录 Demo</h2>
<button id="connectBtn">连接钱包</button>
<button id="loginBtn">签名登录</button>
<pre id="output"></pre>
<script type="module">
import { ethers } from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
const output = document.getElementById("output");
let provider;
let signer;
let address;
function log(data) {
output.textContent = typeof data === "string" ? data : JSON.stringify(data, null, 2);
}
document.getElementById("connectBtn").onclick = async () => {
if (!window.ethereum) {
log("请安装 MetaMask");
return;
}
provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
signer = await provider.getSigner();
address = await signer.getAddress();
log({ connected: true, address });
};
document.getElementById("loginBtn").onclick = async () => {
try {
if (!signer || !address) {
log("请先连接钱包");
return;
}
const nonceResp = await fetch(`http://localhost:3000/auth/nonce?address=${address}`);
const nonceData = await nonceResp.json();
if (!nonceResp.ok) {
log(nonceData);
return;
}
const signature = await signer.signMessage(nonceData.message);
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) {
log(verifyData);
return;
}
localStorage.setItem("token", verifyData.token);
log(verifyData);
} catch (err) {
log({ error: err.message });
}
};
</script>
</body>
</html>
三、部署与运行
1. 部署合约
你可以用 Hardhat 部署。这里给最小脚本。
安装:
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
contracts/IdentityRegistry.sol 放上面的合约代码。
创建 scripts/deploy.js:
const hre = require("hardhat");
async function main() {
const Factory = await hre.ethers.getContractFactory("IdentityRegistry");
const contract = await Factory.deploy();
await contract.waitForDeployment();
console.log("IdentityRegistry deployed to:", await contract.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行:
npx hardhat run scripts/deploy.js --network sepolia
2. 启动后端
node server.js
3. 打开前端页面
直接用浏览器打开 index.html,或者起个静态服务器:
npx serve .
容量估算与架构扩展
如果只是 Demo,到这里就够了。
但如果你打算给真实项目用,建议提前考虑规模问题。
登录请求量
假设:
- 日活 5 万
- 每人每天平均登录 1.5 次
- 峰值在 10 分钟内集中 20%
则高峰期登录请求大约是:
50000 * 1.5 * 20% / 600 ≈ 25 次/秒
这对普通 Node 服务完全没压力。
链上注册请求量
不是每次登录都上链,只有首次注册或身份变更才上链。
如果新用户转化每天 2000,平均分布也只是很低的 TPS。
真正要担心的不是 TPS,而是:
- RPC 可靠性
- 交易确认延迟
- 重试导致的重复提交
推荐扩展策略
小规模
- 单实例认证服务
- Redis 存 nonce
- PostgreSQL 存用户快照
- 单个链上 registry 合约
中规模
- 认证服务无状态化,多实例部署
- Redis 做 nonce + 幂等锁
- MQ 异步处理首次链上注册
- 后端先发临时 token,再等注册完成升级正式身份
大规模
- 多链支持
- 索引服务同步身份状态
- 事件驱动同步 DB 快照
- 链上操作交给任务队列与交易中继层
常见坑与排查
这部分非常重要。我自己做这类系统时,80% 的时间都花在“看起来没问题,但就是登录失败”的细节上。
1. signature invalid
常见原因
- 前端签名的 message 和后端保存的 message 不完全一致
- 地址大小写没统一
- 使用了
signTypedData,后端却用verifyMessage - 用户切换了钱包账户
排查方法
打印以下内容逐项比对:
console.log({
address,
message,
signature
});
后端也打印:
console.log({
recovered,
expected: normalized,
savedMessage: record.message,
incomingMessage: message
});
经验建议:
不要自己在前端“重新拼 message”,直接签后端返回的原始字符串。
2. nonce 明明刚拿到却提示过期
常见原因
- 前端拿到 nonce 后停留太久
- 用户钱包弹窗没点,拖了几分钟
- 服务端 nonce TTL 过短
建议
- nonce 有效期 3~5 分钟比较合理
- 过期后直接重新请求 nonce
- 一个地址只保留最新 nonce,旧 nonce 自动失效
3. 合约注册重复执行
现象
用户连续点击登录按钮,或者前端重试,导致后端多次发起 register。
解决思路
- 后端先查
isRegistered - 用数据库/Redis 对
address加幂等锁 - 捕获合约
already registered错误并视为成功分支
我实际项目里一般会这么处理:
try {
const tx = await contract.register(user, profileId, metadataURI);
await tx.wait();
} catch (e) {
const registered = await contract.isRegistered(user);
if (!registered) throw e;
}
4. 链切换导致认证错链
现象
用户钱包当前连的是 Polygon,但你后端按 Sepolia 的 chainId 在验。
建议
- 签名消息里必须包含
ChainId - 前端连接后先主动检查链
- 错链就提示用户切换,不要糊里糊涂继续
5. 智能合约钱包无法正常登录
比如 Safe 多签、合约钱包等场景,普通 EOA 签名验证不总是适用。
原因
ethers.verifyMessage 适用于 EOA,
而合约钱包可能需要走 ERC-1271 验证。
排查方向
- 判断地址是否为合约账户
- 如果是合约钱包,调用
isValidSignature
这一步是很多项目后期才补的。如果你服务的是高级用户群体,最好一开始就纳入设计。
安全最佳实践
这一节我建议你认真看,因为 Web3 登录最容易“功能能跑,但安全边界不清”。
1. nonce 必须一次性、短时效
要求:
- 随机性足够
- 绑定地址
- 用后即焚
- 过期失效
不要接受固定文案签名,例如:
I am signing in
这种写法几乎就是在给重放攻击开门。
2. 签名内容必须有域隔离
建议签名消息至少包含:
- 应用名
- 钱包地址
- nonce
- chainId
- issuedAt
- 可选 domain / origin
例如:
Login to Demo DApp
Address: 0x...
Nonce: ...
ChainId: 11155111
IssuedAt: ...
更进一步,推荐使用 SIWE(Sign-In with Ethereum, EIP-4361) 标准格式。
3. 服务端不要把“验签成功”直接等同于“有业务权限”
验签只能说明:
这个人控制了这个地址。
它不能说明:
- 他是否完成 KYC
- 是否被封禁
- 是否具备某角色
- 是否仍然持有某 NFT / SBT
所以业务授权应该至少看:
- session
- 链上身份状态
- 业务数据库状态
4. 合约写操作最好异步化
首次登录就上链注册,用户体验通常会卡在交易确认。
更稳妥的方式是:
- 登录成功后先发临时 session
- 链上注册放后台队列
- 完成后更新身份状态
当然,这取决于你的业务是否要求“登录后立刻有链上身份”。
5. 私钥管理不能草率
后端服务钱包通常掌握身份注册权限,这个风险很高。
建议:
- 使用专用热钱包,不要复用运营钱包
- 设置每日额度和告警
- 迁移到 KMS / HSM / MPC 托管
- 合约 owner 最好交给多签,而不是单私钥
6. JWT 不要无限期有效
建议:
- access token:30 分钟到 2 小时
- refresh token:单独管理,支持吊销
- 高风险操作重新校验链上状态或重新签名
性能最佳实践
钱包登录本身不算重,但和链上交互一旦混在主链路里,性能问题就会暴露。
1. 链上读取做缓存
getProfile 这种读操作虽然是 view,但频繁打 RPC 仍然会慢。
可以做:
- 本地缓存 30~120 秒
- 事件驱动更新缓存
- 用户登录时拉一份快照存数据库
2. nonce 存 Redis,不要只放内存
文章里的 demo 用 Map 只是为了简洁。
正式环境多实例部署时,如果 nonce 存内存,会出现:
- 请求打到 A 实例生成 nonce
- 验证打到 B 实例找不到 nonce
所以必须上 Redis 或共享存储。
3. 合约注册做幂等与队列
如果链拥堵或 RPC 抖动,直接同步等待交易确认会拉长登录时间。
可以改成:
- API 返回
pending - 异步 worker 提交交易
- 前端轮询注册状态
4. 区分“登录态”和“身份态”
这是我比较推荐的设计:
- 登录态:只表示用户已完成签名认证
- 身份态:表示链上 profile 是否已激活
这样你就能把“能进系统”和“能用某功能”分开处理,弹性会大很多。
进一步演进方向
如果你要把这套系统做成更成熟的身份层,可以往这些方向继续:
1. 接入 EIP-4361(SIWE)
好处是:
- 消息格式标准化
- 更容易和生态工具兼容
- 降低自定义消息造成的解析错误
2. 支持 ERC-1271
兼容合约钱包,是走向机构用户和高阶 Web3 用户的关键一步。
3. 引入 ENS / Lens / Farcaster 等外部身份信号
让“钱包地址”不仅是地址,还能关联社交身份、命名系统、声誉体系。
4. 用 SBT / Attestation 承载更复杂身份
例如:
- KYC 已完成
- 某课程已认证
- 某 DAO 成员资格
- 企业员工凭证
这比单纯 address -> profileId 更适合做链上身份组合。
总结
如果你只需要“用户能登录”,那一套最简单的 nonce + 签名 + JWT 就够了。
但如果你要搭的是一个真正可持续演进的 Web3 身份系统,我建议按下面这个思路来:
- 钱包签名负责证明地址控制权
- 智能合约负责登记公开可验证的身份关系
- 后端 session 负责承载高频业务访问
- 业务授权不要只看验签结果,还要看身份状态
- 首次上链注册要做好幂等、异步和错误兜底
一句话概括这套架构的边界:
链上负责可信身份锚点,链下负责高性能认证体验。
如果你现在准备落地,我的可执行建议是:
- 先做一个最小版:EOA 钱包 + nonce 签名 + registry 合约 + JWT
- 第二阶段补:Redis、幂等锁、事件同步、链上状态缓存
- 第三阶段再补:SIWE、ERC-1271、多链、角色系统
这样推进最稳,不会一开始就把系统做得太重,也不会后面推倒重来。