Web3 中级实战:从零搭建基于智能合约的钱包登录与链上身份认证系统
很多人第一次做 Web3 登录,都会把它想成“把用户名密码换成钱包签名”。真到落地时才发现,事情没这么简单:
登录 只是第一步,后面还有 身份绑定、权限判定、会话管理、链上可验证声明、合约升级、安全防重放 等一整套问题。
这篇文章我会从架构视角带你搭一个中级可用的系统:
前端用钱包完成签名登录,后端完成会话与验签,链上智能合约负责身份注册与认证声明存证。这样做的目标不是炫技,而是让“钱包地址”真正变成一套可复用、可验证、可扩展的身份基础设施。
背景与问题
在传统 Web2 里,身份认证通常依赖:
- 用户名/密码
- 手机号验证码
- OAuth(微信、GitHub、Google)
而在 Web3 里,用户的“账号”本质上是一个钱包控制权。你能证明自己持有某个地址对应的私钥,就能证明“你是你”。
但工程上会遇到几个现实问题:
1. 只有钱包地址,不等于有完整身份体系
地址只能说明“谁控制这个账户”,不能说明:
- 这个人是否绑定了某个业务身份
- 是否有某个角色权限
- 是否通过 KYC / 社区认证 / 白名单
- 是否在多个应用之间共享身份信誉
2. 纯后端签名登录,无法沉淀链上身份资产
如果只做“签名 -> 后端验签 -> 发 JWT”,那跟 Web2 的 Session 系统差别不大。
一旦你希望:
- 让其他 DApp 也能验证身份
- 做链上角色声明
- 支持跨系统互认
- 保留身份变更历史
那么仅靠数据库就不够了。
3. 纯链上认证,又会变得昂贵且笨重
把所有登录和权限判断都放到链上,会遇到:
- Gas 成本高
- 响应慢
- 用户体验差
- 不适合高频会话校验
所以更合理的做法通常是:
登录走链下,身份锚定上链,权限与会话做分层设计。
这也是本文的核心架构思路。
目标架构:链下登录 + 链上身份锚定
我们先明确要搭的系统能力:
- 用户通过 MetaMask 等钱包发起登录
- 后端生成一次性 nonce,防止重放攻击
- 用户签名登录消息
- 后端验签后签发业务会话 token
- 首次登录时,将用户身份注册到链上身份合约
- 后续可通过合约读取用户角色、认证状态、声明摘要
- 前端或其他服务可根据链上身份做访问控制
这个架构的关键是:
会话快路径走后端,可信身份锚点走智能合约。
架构总览
flowchart LR
U[用户钱包]
FE[前端 DApp]
API[认证后端]
DB[(Nonce/Session 数据库)]
IC[IdentityRegistry 合约]
RC[Role/Claim 读取层]
U --> FE
FE --> API
API --> DB
FE --> U
U --> FE
FE --> API
API --> IC
API --> DB
FE --> RC
RC --> IC
模块职责拆分
| 模块 | 作用 | 是否上链 |
|---|---|---|
| 前端 DApp | 发起连接钱包、请求 nonce、发起签名、展示身份状态 | 否 |
| 认证后端 | 生成 nonce、验签、签发 JWT/Session、触发链上注册 | 否 |
| 数据库 | 存 nonce、登录状态、业务资料映射 | 否 |
| IdentityRegistry 合约 | 记录地址是否注册、角色摘要、认证声明哈希 | 是 |
| 链上读取层 | 聚合合约状态返回给前端 | 可链下封装 |
方案对比与取舍分析
在真正写代码前,我建议先把几种常见方案分清楚。
方案 A:纯钱包签名 + 后端 Session
优点:
- 实现最快
- 成本最低
- 用户体验最好
缺点:
- 身份不可组合
- 其他系统无法信任你的认证结果
- 难做链上权限协同
方案 B:纯链上身份认证
优点:
- 强可验证
- 全链上透明
- 容易和其他合约协同
缺点:
- 登录链上化很重
- Gas 成本高
- 不适合高频请求
方案 C:链下登录 + 链上身份锚定(本文方案)
优点:
- 登录体验接近 Web2
- 身份可信锚定在链上
- 适合业务扩展
- 适合多应用互认
缺点:
- 架构更复杂
- 需要处理链上链下一致性
- 对 nonce、签名消息格式、安全策略要求更高
如果你做的是:
- 社区平台
- 链上工具后台
- NFT / GameFi 用户中心
- 面向多系统协同的账号体系
我通常会优先推荐方案 C。
核心原理
这一套系统要稳定运行,核心在 4 个点。
1. 钱包签名证明“地址控制权”
用户不输入密码,而是签署一段消息:
Login to MyDApp
Address: 0x...
Nonce: abc123
ChainId: 11155111
IssuedAt: ...
后端拿到签名后,可以恢复签名者地址,并验证它是否等于用户声称的钱包地址。
这件事本质上不是“登录”,而是:
证明当前请求发起者拥有某地址私钥控制权。
2. Nonce 防重放攻击
如果没有 nonce,攻击者截获旧签名后就能重复登录。
所以服务端必须:
- 每次登录前生成随机 nonce
- nonce 只能使用一次
- nonce 要设置过期时间
- 验证成功后立刻失效
这是最容易被“图省事”做坏的地方。我见过一些项目直接让用户签固定文案,那基本等于给了攻击者一张长期可复用门票。
3. 链上身份合约只做“可信最小集”
身份合约不要什么都存。建议只存:
- 是否注册
- 注册时间
- 角色位图 / 角色 hash
- 声明摘要(claim hash)
- 可选的管理员签发认证状态
不要把大段用户资料、头像、邮箱明文直接上链。
链上最适合存的是:可验证、低频变更、适合公开审计的数据。
4. 会话与权限分层
一个典型误区是“既然有链上身份,所有接口都去读链”。
不建议这么做。正确姿势通常是:
- 登录态:用 JWT / Session Cookie 管理
- 实时关键权限:必要时读取链上
- 低风险接口:可读缓存或数据库镜像
- 高价值操作:要求再次钱包签名或链上交易确认
这样系统才不会卡在 RPC、确认时间和链上抖动上。
登录与身份注册时序
sequenceDiagram
participant U as 用户
participant FE as 前端
participant API as 后端
participant DB as 数据库
participant SC as 身份合约
U->>FE: 连接钱包
FE->>API: 请求 nonce(address)
API->>DB: 保存 nonce + ttl
API-->>FE: 返回登录消息
FE->>U: 请求签名
U-->>FE: 返回 signature
FE->>API: 提交 address + message + signature
API->>API: 验签并校验 nonce
API->>DB: 标记 nonce 已使用
API->>SC: 若未注册则调用 register()
API-->>FE: 返回 JWT / Session
FE->>SC: 读取链上身份状态
SC-->>FE: 返回 registered / role / claimHash
智能合约设计
我们先设计一个轻量但够用的 IdentityRegistry。
它完成 3 件事:
- 注册地址
- 写入角色位图
- 写入声明摘要
这里我故意不把复杂权限系统塞进去,而是保留一个易扩展的最小模型。
Solidity 合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract IdentityRegistry {
struct Identity {
bool registered;
uint64 registeredAt;
uint256 rolesBitmap;
bytes32 claimHash;
}
mapping(address => Identity) private identities;
address public owner;
event IdentityRegistered(address indexed user, uint64 registeredAt);
event RolesUpdated(address indexed user, uint256 rolesBitmap);
event ClaimUpdated(address indexed user, bytes32 claimHash);
event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor() {
owner = msg.sender;
}
function transferOwnership(address newOwner) external onlyOwner {
require(newOwner != address(0), "zero address");
emit OwnershipTransferred(owner, newOwner);
owner = newOwner;
}
function register(address user) external onlyOwner {
require(user != address(0), "zero address");
Identity storage idn = identities[user];
require(!idn.registered, "already registered");
idn.registered = true;
idn.registeredAt = uint64(block.timestamp);
emit IdentityRegistered(user, idn.registeredAt);
}
function setRoles(address user, uint256 rolesBitmap) external onlyOwner {
require(identities[user].registered, "not registered");
identities[user].rolesBitmap = rolesBitmap;
emit RolesUpdated(user, rolesBitmap);
}
function setClaimHash(address user, bytes32 claimHash) external onlyOwner {
require(identities[user].registered, "not registered");
identities[user].claimHash = claimHash;
emit ClaimUpdated(user, claimHash);
}
function getIdentity(address user) external view returns (
bool registered,
uint64 registeredAt,
uint256 rolesBitmap,
bytes32 claimHash
) {
Identity memory idn = identities[user];
return (idn.registered, idn.registeredAt, idn.rolesBitmap, idn.claimHash);
}
function hasRole(address user, uint8 roleIndex) external view returns (bool) {
require(roleIndex < 256, "invalid role");
return (identities[user].rolesBitmap & (1 << roleIndex)) != 0;
}
}
为什么用 rolesBitmap
角色不一定要用字符串数组。
位图的好处是:
- 存储紧凑
- Gas 更低
- 读取快
- 适合固定角色集
例如:
- bit 0 = 普通用户
- bit 1 = KYC 通过
- bit 2 = VIP
- bit 3 = DAO 管理员
如果你的角色经常变化、数量动态增长,再考虑更灵活的数据结构。
身份合约的数据边界
我建议这样划分:
放链上
- 是否注册
- 注册时间
- 审核/认证结果摘要
- 角色权限
- 声明哈希
放链下
- 昵称、头像、邮箱
- 详细 KYC 材料
- 操作日志全文
- 风控标签明细
- JWT/Session 数据
一句话:
链上管可信锚点,链下管高频与隐私。
状态模型
stateDiagram-v2
[*] --> Unregistered
Unregistered --> Registered: register()
Registered --> Claimed: setClaimHash()
Registered --> RoleUpdated: setRoles()
Claimed --> RoleUpdated: setRoles()
RoleUpdated --> Claimed: setClaimHash()
Claimed --> Claimed: update claim
RoleUpdated --> RoleUpdated: update roles
实战代码(可运行)
下面给你一个最小可运行版本,技术栈:
- 合约:Solidity + Hardhat
- 后端:Node.js + Express + ethers
- 前端:浏览器 + Ethers v6
为了让示例聚焦,我把数据库换成了内存存储。你上线时请换 Redis 或 PostgreSQL。
一、Hardhat 工程与部署
1. 初始化项目
mkdir web3-identity-demo
cd web3-identity-demo
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install ethers express cors jsonwebtoken dotenv
npx hardhat
选择一个基础 JavaScript 项目结构。
2. 放入合约文件
创建 contracts/IdentityRegistry.sol,内容就是上面的 Solidity 合约。
3. 部署脚本
创建 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;
});
4. 本地启动链并部署
npx hardhat node
另开一个终端:
npx hardhat run scripts/deploy.js --network localhost
记下输出的合约地址。
二、后端认证服务
创建 server.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 || "dev_jwt_secret";
const RPC_URL = process.env.RPC_URL || "http://127.0.0.1:8545";
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const CONTRACT_ADDRESS = process.env.CONTRACT_ADDRESS;
const provider = new ethers.JsonRpcProvider(RPC_URL);
const signer = new ethers.Wallet(PRIVATE_KEY, provider);
const abi = [
"function register(address user) external",
"function getIdentity(address user) external view returns (bool registered, uint64 registeredAt, uint256 rolesBitmap, bytes32 claimHash)"
];
const contract = new ethers.Contract(CONTRACT_ADDRESS, abi, signer);
// 内存数据,仅演示用
const nonces = new Map();
function createNonce() {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
function buildMessage(address, nonce, chainId = 31337) {
return [
"Login to MyDApp",
`Address: ${address}`,
`Nonce: ${nonce}`,
`ChainId: ${chainId}`,
`IssuedAt: ${new Date().toISOString()}`
].join("\n");
}
app.post("/auth/nonce", async (req, res) => {
try {
const { address } = req.body;
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid address" });
}
const nonce = createNonce();
const expiresAt = Date.now() + 5 * 60 * 1000;
nonces.set(address.toLowerCase(), { nonce, expiresAt, used: false });
const message = buildMessage(address, nonce);
res.json({ message, nonce });
} 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 stored = nonces.get(address.toLowerCase());
if (!stored) {
return res.status(400).json({ error: "nonce not found" });
}
if (stored.used) {
return res.status(400).json({ error: "nonce already used" });
}
if (stored.expiresAt < Date.now()) {
return res.status(400).json({ error: "nonce expired" });
}
if (!message.includes(`Nonce: ${stored.nonce}`)) {
return res.status(400).json({ error: "nonce mismatch" });
}
if (!message.includes(`Address: ${address}`)) {
return res.status(400).json({ error: "address mismatch in message" });
}
const recovered = ethers.verifyMessage(message, signature);
if (recovered.toLowerCase() !== address.toLowerCase()) {
return res.status(401).json({ error: "invalid signature" });
}
stored.used = true;
const identity = await contract.getIdentity(address);
if (!identity.registered) {
const tx = await contract.register(address);
await tx.wait();
}
const token = jwt.sign(
{ sub: address.toLowerCase(), wallet: address.toLowerCase() },
JWT_SECRET,
{ expiresIn: "2h" }
);
res.json({ ok: true, token });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get("/me", async (req, res) => {
try {
const auth = req.headers.authorization || "";
const token = auth.replace("Bearer ", "");
if (!token) {
return res.status(401).json({ error: "missing token" });
}
const payload = jwt.verify(token, JWT_SECRET);
const address = payload.wallet;
const identity = await contract.getIdentity(address);
res.json({
address,
identity: {
registered: identity.registered,
registeredAt: identity.registeredAt.toString(),
rolesBitmap: identity.rolesBitmap.toString(),
claimHash: identity.claimHash
}
});
} catch (err) {
res.status(401).json({ error: err.message });
}
});
app.listen(PORT, () => {
console.log(`server running at http://localhost:${PORT}`);
});
环境变量
创建 .env:
PORT=3001
JWT_SECRET=my_super_secret
RPC_URL=http://127.0.0.1:8545
PRIVATE_KEY=你的本地测试私钥
CONTRACT_ADDRESS=你的部署合约地址
PRIVATE_KEY要用本地 Hardhat 测试账户,别在示例阶段用真实资产钱包。
启动后端:
node server.js
三、前端页面
创建一个简单的 index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Wallet Login Demo</title>
</head>
<body>
<h2>Web3 钱包登录与链上身份认证 Demo</h2>
<button id="connectBtn">连接钱包并登录</button>
<pre id="output"></pre>
<script type="module">
import { ethers } from "https://cdn.jsdelivr.net/npm/[email protected]/+esm";
const output = document.getElementById("output");
const btn = document.getElementById("connectBtn");
function log(data) {
output.textContent +=
(typeof data === "string" ? data : JSON.stringify(data, null, 2)) + "\n";
}
btn.onclick = async () => {
try {
if (!window.ethereum) {
throw new Error("请先安装 MetaMask");
}
const provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const address = await signer.getAddress();
log("钱包地址: " + address);
const nonceResp = await fetch("http://localhost:3001/auth/nonce", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address })
});
const nonceData = await nonceResp.json();
if (!nonceResp.ok) throw new Error(nonceData.error || "获取 nonce 失败");
log("待签名消息:\n" + nonceData.message);
const signature = await signer.signMessage(nonceData.message);
log("签名成功");
const verifyResp = await fetch("http://localhost:3001/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("登录成功,Token 已保存");
const meResp = await fetch("http://localhost:3001/me", {
headers: {
Authorization: "Bearer " + verifyData.token
}
});
const meData = await meResp.json();
log("当前身份信息:");
log(meData);
} catch (err) {
log("错误: " + err.message);
}
};
</script>
</body>
</html>
你可以直接用任意静态服务器启动,例如:
npx serve .
打开页面后点击按钮,就能完成:
- 连接钱包
- 请求 nonce
- 钱包签名
- 后端验签
- 自动注册链上身份
- 读取当前身份状态
四、逐步验证清单
这个步骤很重要,尤其是你第一次连通整个链路时。
检查 1:钱包签名是否成功
如果 MetaMask 没有弹签名框,通常是:
- 没连上页面
- 浏览器拦截
window.ethereum不存在- 代码执行报错了
检查 2:后端 recovered 地址是否正确
可以在 /auth/verify 里打印:
console.log("recovered:", recovered, "address:", address);
如果不一致,优先排查:
- 签名的 message 是否被改动
- 地址大小写是否统一
- 前后端 message 拼接是否完全一致
检查 3:合约 owner 是否等于后端 signer
因为 register() 受 onlyOwner 限制。
如果后端私钥不是部署者,会报 not owner。
检查 4:identity.registered 是否可读
如果 getIdentity() 调用失败,大概率是:
- ABI 不匹配
- 合约地址不对
- RPC 连错网络
- 合约没部署到当前链
容量估算与扩展思路
中级系统设计不能只看“能不能跑”,还得看“跑起来之后撑不撑得住”。
链下部分容量估算
假设:
- 日活 5 万
- 每人日均登录 2 次
- 每次登录 1 次 nonce 请求 + 1 次验签请求
那么大概是:
- 10 万次 nonce 写入
- 10 万次签名验证
- token 校验则更多
这类压力对普通 Node.js + Redis 来说不算大,真正的瓶颈通常在:
- RPC 抖动
- 合约写入排队
- 后端重复注册链上身份
链上写入估算
假设首次登录需要调用 register():
- 5 万用户首次注册 = 5 万笔交易
- 如果集中发生,会造成明显的链上拥塞和成本上升
所以生产环境常见优化是:
- 首次登录不强制立刻上链
- 先链下放行
- 异步队列写链
- 批处理注册
- 改成批量函数
- 只给关键用户上链
- 普通用户链下,已认证用户上链
- 切到 L2
- 比如 Base、Arbitrum、Optimism、Polygon 等
这就是架构取舍:
不是所有身份都必须第一时间写进主网。
常见坑与排查
这一部分我尽量讲“真踩坑”的地方,而不是只列概念。
1. 把固定文案拿来登录签名
现象
用户总是签同一句:
Welcome to MyDApp
风险
攻击者拿到旧签名后可以重复使用。
正确做法
必须包含:
- nonce
- address
- chainId
- issuedAt
- 域名/应用标识
更进一步,建议直接采用 SIWE(Sign-In with Ethereum) 风格消息格式。
2. 前后端 message 不一致
现象
前端签名成功,后端验签失败。
常见原因
- 前端 message 多了空格或换行
- 后端重新拼接 message 时格式不同
IssuedAt时间不一致
排查建议
最稳的方式是:
后端生成完整 message,前端只负责签,不做二次拼接。
这也是我在示例里采用的方式。
3. MetaMask 切链导致 chainId 对不上
现象
用户在 A 链拿到 message,但签名前切到 B 链。
风险
消息上下文与实际链环境不一致。
建议
- 登录消息里加入
ChainId - 签名前读取当前链并校验
- 如果链不对,先提示切换网络
4. Nonce 过期策略太宽松
现象
nonce 10 分钟、30 分钟甚至几小时后还有效。
风险
中间人攻击窗口变大。
建议
- 5 分钟内有效比较常见
- 验签成功立即作废
- 每地址只保留最新 nonce
- 做 IP / 频率限制
5. 合约写入卡住导致登录失败
现象
验签通过,但因为链上 register() 迟迟不确认,整个登录接口超时。
问题根源
把“登录成功”与“链上写入成功”强绑定了。
更好的设计
- 登录先成功,发 token
- 链上身份异步注册
- 前端显示“身份注册处理中”
- 高敏感功能再要求链上注册完成
这是典型的架构优化点。
6. 只校验地址,不校验消息上下文
现象
后端仅通过 verifyMessage() 恢复地址后就放行。
风险
如果消息不是系统签发的,攻击者可能拿其他场景签名来冒充登录。
必须校验的内容
- message 来源是否本系统生成
- nonce 是否匹配
- 地址是否匹配
- 是否过期
- 域名/应用名是否匹配
安全最佳实践
这一部分我建议你上线前逐条过一遍。
1. 优先采用标准消息协议
如果不是做教学 Demo,推荐直接使用:
- SIWE(EIP-4361)
- 配合
ethers或专门库做消息校验
好处是:
- 格式标准
- 钱包兼容性更好
- 易于审计
- 减少自定义消息歧义
2. Nonce 存 Redis,不要只放内存
内存 Map 只能演示,生产环境会有问题:
- 服务重启后 nonce 丢失
- 多实例之间不共享
- 无法做分布式登录校验
建议:
- Redis 保存 nonce
- 设置 TTL
- 用原子操作标记已使用
3. 合约 owner 不要直接用热钱包
示例里后端私钥直接控制合约,是为了简单。
生产环境更稳的做法是:
- 用多签管理 owner
- 后端只拥有受限角色
- 或通过后台管理服务转发授权操作
否则一旦后端私钥泄漏,攻击者能直接篡改链上身份。
4. 角色更新要有审计日志
setRoles() 和 setClaimHash() 涉及身份与权限变化,建议同时记录:
- 操作人
- 操作时间
- 操作原因
- 对应业务单号
链上事件能做部分追踪,但链下审计日志同样重要。
5. 高风险操作要求二次签名
不要因为用户“已经登录”就默认他能做一切事。
比如:
- 提现
- 修改关键资料
- DAO 管理动作
- 高权限审批
建议要求再次钱包签名,必要时附带操作摘要和过期时间。
6. 隐私数据只上摘要不上明文
例如 KYC 资料,不要直接上链。
可以采用:
- 链下保存原文
- 上链存
keccak256摘要 - 验证时证明该资料与摘要一致
这是 Web3 身份系统非常常见的边界。
性能最佳实践
1. 读链缓存化
身份状态读多写少,非常适合做缓存:
- Redis 缓存
getIdentity() - 监听合约事件失效缓存
- 对低风险页面使用秒级缓存
这样比每次前端都直连 RPC 更稳定。
2. 写链异步化
首次登录注册身份、补写 claim、同步角色变更,这些都建议走异步队列:
- RabbitMQ / Kafka / BullMQ
- 重试机制
- 死信队列
- 幂等处理
特别是“注册已存在用户”这种场景,幂等设计一定要做。
3. 批量操作优先
如果你的业务是 B 端后台批量审核认证用户,建议合约提供批量接口,例如:
batchRegister(address[] users)batchSetRoles(address[] users, uint256[] roles)
这样能明显降低链上运维成本。
4. 前端减少不必要的链查询
很多页面根本不需要每次都直读链。
实际经验里常见策略是:
- 登录后先调后端聚合接口
- 后端返回缓存过的链上状态
- 关键详情页再触发链上精确查询
对用户来说,体验会好很多。
可演进架构建议
如果你把这个系统继续往生产级推进,我建议沿着下面路线演进:
阶段 1:单应用身份系统
- 钱包登录
- 链上注册
- JWT 会话
- 角色位图
阶段 2:多应用共享身份
- 抽出独立身份服务
- 多业务系统复用同一身份合约
- 统一 claim 与角色模型
阶段 3:可验证凭证化
- claimHash 升级为 VC / Attestation 模型
- 引入签发者、过期时间、撤销机制
- 支持第三方验证
阶段 4:账户抽象与智能钱包支持
- 支持智能合约钱包
- 兼容 EIP-1271 签名校验
- 加入社交恢复、会话密钥等能力
这里特别提醒一下:
本文示例默认的是 EOA 外部账户。如果你后续要兼容 Safe 等智能合约钱包,不能只用 verifyMessage(),还要支持 EIP-1271 验签逻辑。
边界条件与适用范围
这套方案很适合:
- 社区身份系统
- NFT / 游戏用户认证
- DAO 成员入口
- 多 DApp 共享身份基座
但它不适合直接解决:
- 强实名监管合规的完整流程
- 高隐私医疗/金融明文数据上链
- 高并发、超低延迟的纯链上权限判断场景
换句话说,它是一套折中而实用的身份架构,不是银弹。
总结
如果把整篇文章压缩成一句话,那就是:
Web3 登录不是“签个名就完了”,而是把地址控制权、会话管理和链上可信身份拆层设计。
本文这套方案的核心价值在于:
- 用钱包签名完成无密码登录
- 用 nonce 防重放
- 用后端 Session/JWT 承接高频请求
- 用智能合约沉淀可验证身份锚点
- 用角色与声明摘要支撑后续业务扩展
如果你现在就准备动手,我建议按这个顺序做:
- 先把签名登录链路跑通
- 再接上IdentityRegistry 合约
- 然后做角色与 claim 模型
- 最后补齐 Redis、异步写链、EIP-1271、审计日志
别一开始就想做成“全能链上身份平台”。
先把登录可信、身份可读、权限可控这三件事做扎实,你的系统就已经超过不少停留在 Demo 阶段的 Web3 项目了。