Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统
很多人第一次做 Web3 登录时,直觉上会把它理解成“用钱包替代用户名密码”。这句话不算错,但如果真按这个思路落地,往往会很快遇到几个问题:
- 钱包签名能证明“你控制这个地址”,但怎么证明“你在我的系统里是什么身份”?
- 仅靠前端验签,后端怎么建立会话?
- 用户换钱包、换链、换设备后,身份如何延续?
- 哪些信息适合上链,哪些一定不能上链?
- 合约里的角色权限,和业务系统里的登录态,怎么统一?
这篇文章我会从架构视角带你搭一套“钱包登录 + 链上身份认证”系统。不是只讲一个 signMessage demo,而是把:
- 前端钱包登录
- 后端验签建会话
- 链上身份注册与角色认证
- 智能合约鉴权
- 常见坑与安全实践
串成一条完整链路。
目标读者是已经接触过 ethers.js、Solidity 和基础前后端开发的中级开发者。
背景与问题
在传统 Web2 系统里,身份体系通常由这几层组成:
- 认证(Authentication):你是谁?
- 授权(Authorization):你能做什么?
- 会话(Session):你现在是否处于已登录状态?
到了 Web3,这三件事会被拆开:
- 钱包签名负责证明地址控制权
- 链上合约负责记录公开、可验证的身份状态
- 后端会话/JWT负责业务接口访问控制
问题就在于,很多项目只做了第一层:让用户用 MetaMask 签一下消息,验证通过就认为“登录完成”。这通常不够。
只做钱包签名会带来的问题
1. 无法表达业务身份
地址只是地址。
你可以知道 0xabc... 签了消息,但你不知道它是:
- 普通用户
- KYC 用户
- DAO 成员
- 某个租户下的管理员
- 某个 NFT 持有人
这些都需要额外的身份模型。
2. 权限散落
如果你一部分权限写在后端数据库,一部分权限写在合约里,没有统一身份锚点,后面会很难维护。
3. 难以跨系统复用
一个地址登录 A 系统,不代表 B 系统自动认可它在 A 的角色。
这时就需要一个链上可验证身份层,作为多个 dApp 或服务之间的共享信任基础。
目标架构:把“认证、身份、授权”拆清楚
我们先给出一个可落地的架构分层:
- 钱包层:用户用私钥签名,证明地址所有权
- 会话层:后端验证签名后签发 JWT / Session
- 身份层:智能合约保存用户链上身份状态与角色
- 业务层:前端和后端根据链上身份 + 后端会话共同决策
架构全景图
flowchart LR
U[用户钱包] --> FE[前端 dApp]
FE -->|请求 nonce| BE[后端认证服务]
BE -->|返回挑战消息| FE
FE -->|钱包签名| U
U --> FE
FE -->|address + signature + nonce| BE
BE -->|验签通过| JWT[签发 JWT/Session]
FE -->|带 JWT 调用业务接口| API[业务后端]
FE -->|读取身份| SC[(IdentityRegistry 合约)]
API -->|校验角色/状态| SC
这个设计里有两个关键事实:
-
登录动作主要发生在链下
因为签名验签不一定要上链,放链下更便宜、更快。 -
身份状态锚定在链上
例如“这个地址是否已注册”“是否具备某种角色”“是否绑定某个 DID 元数据”等。
方案对比与取舍分析
在真正动手之前,先把常见方案的边界讲清楚。这里我踩过的坑是:一开始什么都想上链,后来才发现,认证链路不应该为了“去中心化”而把用户体验做没了。
方案一:纯链下钱包登录
流程很简单:
- 后端生成 nonce
- 用户签名
- 后端验签
- 签发 session/JWT
优点:
- 成本低
- 响应快
- 实现简单
缺点:
- 没有链上可复用身份
- 多系统之间难共享信任
- 权限来源不统一
适合:
轻量 dApp、内部工具、MVP 验证阶段。
方案二:链下登录 + 链上身份注册
这是本文采用的方案。
流程是:
- 登录时链下验签建立会话
- 首次使用或需要升级身份时,调用身份合约写入链上状态
- 后续前端/后端通过读取合约状态进行授权判断
优点:
- 登录成本低
- 身份状态可公开验证
- 授权模型清晰,可扩展
缺点:
- 需要同时维护链下会话与链上身份
- 设计不当时,容易出现状态不一致
适合:
需要角色、权限、会员身份、链上认证、DAO 访问控制的应用。
方案三:所有认证都上链
例如每次登录都发起链上交易,链上验证并记录登录状态。
优点:
- 最“原教旨主义”的链上模式
- 状态公开透明
缺点:
- 慢
- 贵
- 用户体验差
- 完全没必要
适合:
极少数对“链上登录事件”本身有业务意义的系统,不适合作为通用登录方案。
核心原理
这一套系统核心有三块:签名认证、链上身份、授权决策。
1. 钱包签名认证的本质
后端生成一个随机 nonce,拼成挑战消息,例如:
Welcome to Demo DApp
Address: 0x…
Nonce: 123456
ChainId: 31337
Issued At: …
用户使用钱包签名后,后端通过恢复签名地址的方式验证:
- 签名是否有效
- 地址是否匹配
- nonce 是否未使用
- 是否过期
如果都成立,就可以认为:
当前请求发起者控制该地址的私钥。
2. 链上身份的本质
链上身份不是“把所有用户资料放进合约”,而是存放最小可验证状态,例如:
- 是否注册
- 角色位(member / admin / auditor)
- 身份哈希
- KYC 状态
- 资料 URI 或 DID 文档指针
通常建议:
- 敏感信息不上链
- 只把可验证、可公开、需要跨系统共享的状态上链
3. 授权决策的本质
授权往往不是单一来源,而是组合判断:
- 你是否完成钱包签名登录?
- 你的地址是否在身份合约中注册?
- 你是否具备某角色?
- 你当前请求是否来自正确链?
- 你是否满足某资源访问策略?
一个常见模式是:
- 链下做认证与会话控制
- 链上做身份锚定与角色证明
- 后端和合约共同做授权
系统交互时序
sequenceDiagram
participant User as 用户
participant Wallet as 钱包
participant FE as 前端
participant BE as 后端
participant SC as IdentityRegistry合约
User->>FE: 点击登录
FE->>BE: 请求 nonce
BE-->>FE: 返回挑战消息
FE->>Wallet: 发起签名
Wallet-->>FE: 返回 signature
FE->>BE: 提交 address + signature + nonce
BE->>BE: 验签并校验 nonce
BE-->>FE: 返回 JWT
User->>FE: 首次注册链上身份
FE->>SC: 调用 register()
SC-->>FE: 返回 tx receipt
FE->>SC: 读取 getIdentity(address)
SC-->>FE: 返回 role / active / metadataHash
FE->>BE: 带 JWT 调用业务接口
BE->>SC: 校验链上角色
SC-->>BE: 返回角色信息
BE-->>FE: 返回业务数据
合约设计:IdentityRegistry
我们实现一个中等复杂度、适合实战起步的身份合约:
功能包括:
- 用户注册身份
- 管理员授予角色
- 查询身份
- 启用/禁用身份
这里故意不做得过于“大而全”,因为中级实战更重要的是把边界建立起来。
合约数据结构设计
classDiagram
class Identity {
+bool registered
+bool active
+uint8 role
+bytes32 metadataHash
+uint256 registeredAt
}
class IdentityRegistry {
+mapping(address => Identity) identities
+register(bytes32 metadataHash)
+setRole(address user, uint8 role)
+setActive(address user, bool active)
+getIdentity(address user) Identity
}
IdentityRegistry --> Identity
实战代码(可运行)
下面用一套最小可运行示例来搭建:
- 合约:Solidity + Hardhat
- 后端:Node.js + Express + ethers
- 前端:简化版 HTML/JS(方便你先跑通流程)
一、项目结构
web3-identity-demo/
├─ contracts/
│ └─ IdentityRegistry.sol
├─ scripts/
│ └─ deploy.js
├─ server/
│ └─ index.js
├─ frontend/
│ └─ index.html
├─ hardhat.config.js
├─ package.json
└─ .env
二、安装依赖
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install express cors dotenv jsonwebtoken ethers
npx hardhat
选择一个基础 JavaScript 项目即可。
三、编写智能合约
contracts/IdentityRegistry.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract IdentityRegistry {
struct Identity {
bool registered;
bool active;
uint8 role; // 0=user, 1=member, 2=admin
bytes32 metadataHash;
uint256 registeredAt;
}
address public owner;
mapping(address => Identity) private identities;
event IdentityRegistered(address indexed user, bytes32 metadataHash, uint256 registeredAt);
event RoleUpdated(address indexed user, uint8 role);
event ActiveUpdated(address indexed user, bool active);
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
modifier onlyRegistered(address user) {
require(identities[user].registered, "identity not registered");
_;
}
constructor() {
owner = msg.sender;
}
function register(bytes32 metadataHash) external {
require(!identities[msg.sender].registered, "already registered");
identities[msg.sender] = Identity({
registered: true,
active: true,
role: 0,
metadataHash: metadataHash,
registeredAt: block.timestamp
});
emit IdentityRegistered(msg.sender, metadataHash, block.timestamp);
}
function setRole(address user, uint8 role) external onlyOwner onlyRegistered(user) {
identities[user].role = role;
emit RoleUpdated(user, role);
}
function setActive(address user, bool active) external onlyOwner onlyRegistered(user) {
identities[user].active = active;
emit ActiveUpdated(user, active);
}
function getIdentity(address user)
external
view
returns (
bool registered,
bool active,
uint8 role,
bytes32 metadataHash,
uint256 registeredAt
)
{
Identity memory identity = identities[user];
return (
identity.registered,
identity.active,
identity.role,
identity.metadataHash,
identity.registeredAt
);
}
function isAuthorized(address user, uint8 minRole) external view returns (bool) {
Identity memory identity = identities[user];
return identity.registered && identity.active && identity.role >= minRole;
}
}
四、部署脚本
scripts/deploy.js
const hre = require("hardhat");
async function main() {
const IdentityRegistry = await hre.ethers.getContractFactory("IdentityRegistry");
const registry = await IdentityRegistry.deploy();
await registry.waitForDeployment();
console.log("IdentityRegistry deployed to:", await registry.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行编译与部署:
npx hardhat compile
npx hardhat node
npx hardhat run scripts/deploy.js --network localhost
记下部署出来的合约地址。
五、后端:钱包登录验签 + JWT 会话
这里我们实现两个接口:
GET /auth/nonce?address=...:生成挑战消息POST /auth/verify:验证签名并签发 JWT
server/index.js
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const { ethers } = require("ethers");
const app = express();
app.use(cors());
app.use(express.json());
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || "demo_jwt_secret";
const RPC_URL = process.env.RPC_URL || "http://127.0.0.1:8545";
const CONTRACT_ADDRESS = process.env.CONTRACT_ADDRESS;
const provider = new ethers.JsonRpcProvider(RPC_URL);
const abi = [
"function getIdentity(address user) view returns (bool registered, bool active, uint8 role, bytes32 metadataHash, uint256 registeredAt)",
"function isAuthorized(address user, uint8 minRole) view returns (bool)"
];
const contract = new ethers.Contract(CONTRACT_ADDRESS, abi, provider);
// 内存 nonce 存储,演示用;生产环境请用 Redis/DB
const nonceStore = new Map();
function buildMessage(address, nonce, chainId = 31337) {
return [
"Welcome to Demo DApp",
`Address: ${address}`,
`Nonce: ${nonce}`,
`ChainId: ${chainId}`,
`Issued At: ${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 nonce = Math.floor(Math.random() * 1e9).toString();
const message = buildMessage(address, nonce);
nonceStore.set(address.toLowerCase(), {
nonce,
message,
createdAt: Date.now()
});
res.json({ address, nonce, message });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post("/auth/verify", async (req, res) => {
try {
const { address, signature } = req.body;
if (!address || !signature) {
return res.status(400).json({ error: "missing address or signature" });
}
const key = address.toLowerCase();
const record = nonceStore.get(key);
if (!record) {
return res.status(400).json({ error: "nonce not found" });
}
// 5 分钟有效期
if (Date.now() - record.createdAt > 5 * 60 * 1000) {
nonceStore.delete(key);
return res.status(400).json({ error: "nonce expired" });
}
const recovered = ethers.verifyMessage(record.message, signature);
if (recovered.toLowerCase() !== key) {
return res.status(401).json({ error: "invalid signature" });
}
nonceStore.delete(key);
let identity = null;
try {
const result = await contract.getIdentity(address);
identity = {
registered: result[0],
active: result[1],
role: Number(result[2]),
metadataHash: result[3],
registeredAt: Number(result[4])
};
} catch (e) {
identity = null;
}
const token = jwt.sign(
{
sub: address,
walletAddress: address,
identity
},
JWT_SECRET,
{ expiresIn: "2h" }
);
res.json({
token,
address,
identity
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
function authMiddleware(req, res, next) {
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 {
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch (err) {
return res.status(401).json({ error: "invalid token" });
}
}
app.get("/me", authMiddleware, async (req, res) => {
const address = req.user.walletAddress;
const result = await contract.getIdentity(address);
res.json({
walletAddress: address,
identity: {
registered: result[0],
active: result[1],
role: Number(result[2]),
metadataHash: result[3],
registeredAt: Number(result[4])
}
});
});
app.get("/admin/resource", authMiddleware, async (req, res) => {
const address = req.user.walletAddress;
const ok = await contract.isAuthorized(address, 2);
if (!ok) {
return res.status(403).json({ error: "admin role required" });
}
res.json({ data: "secret admin resource" });
});
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
.env
PORT=3001
JWT_SECRET=replace_this_with_random_string
RPC_URL=http://127.0.0.1:8545
CONTRACT_ADDRESS=你的合约地址
启动后端:
node server/index.js
六、前端:连接钱包、签名登录、注册链上身份
为了让你最快跑通,我用原生 HTML + Ethers CDN 写一个简版页面。
frontend/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Web3 Identity Demo</title>
</head>
<body>
<h2>Web3 钱包登录与链上身份认证 Demo</h2>
<button id="connectBtn">连接钱包</button>
<button id="loginBtn">钱包登录</button>
<button id="registerBtn">注册链上身份</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");
const connectBtn = document.getElementById("connectBtn");
const loginBtn = document.getElementById("loginBtn");
const registerBtn = document.getElementById("registerBtn");
const meBtn = document.getElementById("meBtn");
const backendUrl = "http://localhost:3001";
const contractAddress = "你的合约地址";
const abi = [
"function register(bytes32 metadataHash) external",
"function getIdentity(address user) view returns (bool registered, bool active, uint8 role, bytes32 metadataHash, uint256 registeredAt)"
];
let provider;
let signer;
let userAddress;
let token;
function log(data) {
output.textContent = typeof data === "string" ? data : JSON.stringify(data, null, 2);
}
connectBtn.onclick = async () => {
if (!window.ethereum) {
return log("请先安装 MetaMask");
}
provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
signer = await provider.getSigner();
userAddress = await signer.getAddress();
log({ connected: true, userAddress });
};
loginBtn.onclick = async () => {
if (!signer) return log("请先连接钱包");
const nonceResp = await fetch(`${backendUrl}/auth/nonce?address=${userAddress}`);
const nonceData = await nonceResp.json();
const signature = await signer.signMessage(nonceData.message);
const verifyResp = await fetch(`${backendUrl}/auth/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
address: userAddress,
signature
})
});
const verifyData = await verifyResp.json();
token = verifyData.token;
log(verifyData);
};
registerBtn.onclick = async () => {
if (!signer) return log("请先连接钱包");
const contract = new ethers.Contract(contractAddress, abi, signer);
const metadataHash = ethers.keccak256(ethers.toUtf8Bytes("demo-user-profile-v1"));
const tx = await contract.register(metadataHash);
const receipt = await tx.wait();
log({
registered: true,
txHash: receipt.hash
});
};
meBtn.onclick = async () => {
if (!token) return log("请先登录");
const resp = await fetch(`${backendUrl}/me`, {
headers: {
Authorization: `Bearer ${token}`
}
});
const data = await resp.json();
log(data);
};
</script>
</body>
</html>
你可以直接用本地静态服务器打开它,例如:
npx serve frontend
七、运行流程验证清单
按这个顺序做,最容易排错:
- 启动本地链
- 部署合约
- 启动后端服务
- 打开前端页面
- 连接钱包
- 钱包登录
- 注册链上身份
- 查看
/me返回的数据
你应该看到的结果
- 未注册前,
identity.registered可能是false - 调用
register()后,再查身份会变成true - 如果用 owner 地址调用
setRole(user, 2),则该地址可以访问管理员资源
进一步扩展:管理员赋权脚本
有时候你需要快速测试角色授权,可以写一个脚本。
scripts/setRole.js
const hre = require("hardhat");
async function main() {
const contractAddress = process.env.CONTRACT_ADDRESS;
const user = process.env.USER_ADDRESS;
const role = Number(process.env.ROLE || 2);
const registry = await hre.ethers.getContractAt("IdentityRegistry", contractAddress);
const tx = await registry.setRole(user, role);
await tx.wait();
console.log(`Role ${role} set for ${user}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
执行:
CONTRACT_ADDRESS=你的合约地址 USER_ADDRESS=用户地址 ROLE=2 npx hardhat run scripts/setRole.js --network localhost
授权状态机:从未登录到完成认证
很多人容易把“登录”和“注册身份”混在一起。其实它们是两个状态变化。
stateDiagram-v2
[*] --> Unauthenticated
Unauthenticated --> WalletAuthenticated: 钱包签名验签成功
WalletAuthenticated --> IdentityRegistered: 调用 register()
IdentityRegistered --> AuthorizedUser: 角色满足业务要求
IdentityRegistered --> Suspended: active = false
Suspended --> AuthorizedUser: 管理员重新激活
这张图很重要,因为它提醒我们:
- 钱包已登录 ≠ 已有链上身份
- 有链上身份 ≠ 一定有权限访问目标资源
- active=false 时应视为失效身份
常见坑与排查
这部分我建议你认真看。因为实际开发时,80% 的时间都花在这里。
坑 1:签名恢复地址不一致
现象
后端 verifyMessage 恢复出来的地址和前端地址不一样。
常见原因
- 前端签名的消息和后端保存的消息不一致
- 前端拼接换行符和后端不同
- 使用了
eth_sign、personal_sign、signTypedData中的不同一种,但后端验签方式没对应上
排查建议
- 把前后端消息原文完整打印出来,一字不差比对
- 先固定 message 模板,不要动态插时间字段后再重建
- 如果你用的是 EIP-712 Typed Data,就不要再用
verifyMessage
我当时踩过一个很隐蔽的坑:前端签名时用了从接口返回的 message,后端验签时又自己重新拼了一遍
Issued At,结果时间戳不同,验签必然失败。
坑 2:nonce 重放攻击
现象
同一个签名可以反复登录。
根因
nonce 验证后没有立即失效,或者一个地址长期复用同一个 nonce。
解决方式
- nonce 一次一用
- 验签成功后立刻删除
- 设置过期时间
- 生产环境放入 Redis,支持分布式实例共享
坑 3:切链后身份读取失败
现象
前端已经登录,但读取身份合约报错或返回空数据。
根因
钱包当前链和部署合约链不一致。
排查建议
- 登录消息中加入
chainId - 前端调用前检查
provider.getNetwork() - 如果链不对,主动提示切换网络
坑 4:后端 JWT 中缓存了旧角色
现象
管理员刚被降权,但旧 token 还能访问部分接口。
根因
把角色写进 JWT 后,接口只信 JWT,不重新查链上状态。
解决方式
对高敏感接口:
- 不只看 JWT
- 每次实时读取链上角色,或使用短期缓存
- 把 JWT 仅作为“已认证地址”的证明,而不是最终授权依据
坑 5:合约里存了过多资料
现象
gas 很贵,修改资料非常麻烦。
根因
把昵称、头像、邮箱、长文本等都塞进链上。
建议
链上只存:
- 哈希
- 状态位
- URI 指针
- 最小角色信息
链下存:
- 用户详情
- 隐私信息
- 可变频繁的数据
坑 6:前端把“签名”误当成“交易”
现象
用户点登录后,钱包弹窗显示 gas、确认交易,体验很差。
根因
登录应该使用消息签名,而不是发链上交易。
正确认知
- 登录:签名,不花 gas
- 身份注册/角色变更:交易,花 gas
安全最佳实践
Web3 身份系统里,安全问题通常不在“密码泄露”,而在“签名滥用、权限失控、状态不一致”。
1. 使用结构化签名优于纯文本签名
本文为了演示方便用了 signMessage。
但在生产环境,我更推荐你升级到 EIP-712 Typed Data,好处是:
- 字段结构明确
- 避免消息字符串拼接歧义
- 钱包展示更可读
- 更适合域隔离(domain separator)
至少要把这些字段纳入签名内容:
- domain name
- version
- chainId
- verifyingContract(如有)
- nonce
- issuedAt / expiration
- statement / purpose
2. 明确区分认证与授权
不要因为用户“签名成功”就默认他能访问一切资源。
建议分开判断:
- 认证成功:地址控制权已验证
- 授权成功:链上角色/状态满足要求
这是系统长期可维护的关键。
3. 敏感接口实时读链上权限
对于这些操作,建议实时校验链上角色:
- 后台管理
- 资金操作
- 白名单铸造
- 高价值资源访问
普通只读接口可以做缓存,但高风险接口最好实时判断。
4. 链上身份合约尽量最小化
如果你的身份合约越来越像“全能用户中心”,说明它已经开始失控了。
更稳妥的做法是:
- 身份注册合约:只管身份状态
- 权限控制合约:只管角色
- 业务合约:只消费身份与角色结果
中大型系统可以继续拆分。
5. 管理员权限必须可治理
本文里用了 owner,适合 demo。
生产环境建议至少升级到:
- OpenZeppelin
AccessControl - 多签管理员
- 角色变更事件审计
- timelock(对关键权限变更)
否则管理员私钥一旦出问题,身份系统等于失守。
6. 防钓鱼签名
钱包签名在用户侧最大的风险不是破解,而是被诱导签错消息。
建议:
- 登录消息必须清晰说明用途
- 加上域名、时间、nonce、用途声明
- 不要让登录签名看起来像资产授权
- 前端明确展示“这是登录签名,不会消耗 gas”
性能最佳实践
虽然身份读取是 view 调用,但如果你的业务接口每次都实时读链,吞吐还是会受影响。
1. 热路径做缓存,冷路径读链
一种很实用的折中方式是:
- 登录成功后,把链上身份快照写入缓存
- 普通页面渲染先读缓存
- 高风险动作再实时读链
- 监听链上事件更新缓存
比如监听这些事件:
IdentityRegisteredRoleUpdatedActiveUpdated
这样可以避免每次 API 请求都命中 RPC。
2. 事件驱动更新索引
如果用户量变大,建议引入一个索引层:
- 自建 indexer
- The Graph
- 订阅 RPC 事件写入数据库
这样后端就可以更快地按地址、角色、状态查询身份。
3. 容量估算思路
以一个中等规模 dApp 为例:
- 日活 1 万
- 每人每日登录 2 次
- 每次登录链下验签 1 次
- 每个请求平均读取链上身份 1~3 次
如果所有身份都实时从 RPC 拉,后端和 RPC 压力会明显增加。
比较合理的做法是:
- 验签请求走应用服务
- 身份读走缓存 + 索引层
- 高敏感资源再做链上最终校验
也就是说,链是信任根,不一定要成为每个请求的第一读源。
架构扩展建议
当这套系统进入生产阶段,你大概率会继续往这些方向演进:
1. 支持多钱包绑定同一身份
例如一个用户可能有:
- 主钱包
- 冷钱包
- 社交恢复钱包
你可以设计 primaryIdentity -> linked wallets 模型,而不是“一地址一用户”。
2. 引入 DID / VC
如果你的场景不只是角色权限,而是更复杂的可验证身份:
- DID 文档
- 可验证凭证(VC)
- 链上哈希锚定 + 链下凭证存储
会比单纯 role 更灵活。
3. 支持多链身份映射
如果业务部署在多条链上,可以做:
- 主身份链
- 其他链镜像注册
- 跨链证明或签名映射
4. 合约可升级 or 注册中心模式
身份系统一旦上线,后续很难停机迁移。
通常建议使用:
- 注册中心 + 版本化实现
- 或谨慎使用代理升级
中级阶段我更推荐前者,因为可读性强、心智负担小。
什么时候不适合做链上身份认证
说句实话,不是所有系统都值得上这套架构。
如果你只是做:
- 一个简单 NFT 展示页
- 一个纯前端小游戏
- 一个不需要角色和权限的轻应用
那么只做钱包连接 + 签名登录可能就够了。
适合引入链上身份层的典型场景是:
- DAO 成员系统
- Web3 社区等级体系
- 链上白名单与访问控制
- 链上资质证明
- 多 dApp 共享用户身份
- 需要公开可验证角色状态的系统
换句话说:
当“身份本身”成为业务资产时,链上身份才真正有价值。
总结
这篇文章我们搭建了一套完整的中级 Web3 身份认证架构:
- 用钱包签名完成地址控制权认证
- 用后端验签 + JWT建立业务会话
- 用IdentityRegistry 合约存储链上身份状态
- 用链上角色 + 链下接口共同完成授权控制
如果你只记住三件事,我建议是这三条:
- 登录尽量链下完成,身份状态链上锚定
- 认证和授权一定要分开设计
- 链上只放最小可验证信息,别把用户中心全搬进合约
可执行落地建议
如果你准备把 demo 升级成生产版,优先级可以按这个顺序推进:
- 把
signMessage升级到 EIP-712 - 把 nonce 存储迁移到 Redis
- 用 OpenZeppelin AccessControl 替代
owner - 对高风险接口改成实时链上授权校验
- 增加事件索引与缓存层,降低 RPC 压力
边界条件提醒
这套方案的前提是:
- 你的业务允许用户拥有链上地址身份
- 你的角色状态适合公开或至少可哈希公开
- 你能接受链上写入带来的 gas 成本与确认延迟
如果你的业务高度隐私、频繁改资料、完全不需要跨系统可验证身份,那就没必要强行上链。
最后一句经验之谈:
Web3 身份系统最难的不是“怎么签名”,而是“你到底想让链证明什么”。
把这个问题想清楚,后面的架构就会顺很多。