Web3 中级实战:基于以太坊与 IPFS 构建去中心化身份认证(DID)登录系统
在传统登录系统里,账号和密码通常由中心化服务保存。这个模式我们都很熟:实现简单、用户习惯成熟,但问题也很明显——密码泄露、账号接管、平台垄断身份数据,几乎是反复上演的老戏码。
到了 Web3 世界,身份的控制权开始从平台迁移到用户。DID(Decentralized Identifier,去中心化身份) 的核心价值,不只是“用钱包登录”这么简单,而是让用户真正持有身份、凭证和授权关系。本文我会带你从架构角度,搭一套基于以太坊 + IPFS 的 DID 登录系统,并给出一套能跑起来的最小实现。
这篇内容默认你已经知道以下基础:
- 会用 Node.js 写简单后端
- 知道以太坊地址、私钥、签名是什么
- 用过 MetaMask 或 ethers.js
本文重点不讲概念名词堆砌,而是讲:这套系统为什么这样设计、链上链下怎么分工、代码怎么串起来、上线时怎么避坑。
背景与问题
先明确一个现实问题:直接把身份数据全放链上并不现实。
原因很简单:
- 贵:链上存储成本高
- 慢:每次身份变更都要等待确认
- 隐私差:公开链天然可见,不适合放敏感资料
- 难扩展:一旦用户画像、凭证元数据增多,链上存储会迅速膨胀
所以一个更合理的做法是:
- 链上存“身份锚点”和可验证状态
- 链下/IPFS 存“身份文档”和扩展信息
- 登录时依靠钱包签名证明地址控制权
- 服务端通过挑战-响应机制完成认证
这也是很多 Web3 登录系统的实际落地路径。
传统登录 vs DID 登录
| 维度 | 传统账号密码 | DID 登录 |
|---|---|---|
| 身份主体 | 平台账号 | 用户钱包/去中心化标识 |
| 认证方式 | 密码校验 | 私钥签名 |
| 数据控制权 | 平台 | 用户 |
| 风险点 | 密码泄露、撞库 | 签名钓鱼、重放攻击 |
| 可移植性 | 弱 | 强 |
如果你只是做一个 DApp,最简单的登录方式当然是“连接钱包即可”。但一旦你要支持:
- 登录态管理
- 绑定角色、权限、邀请码
- 用户资料与凭证引用
- 多端统一会话
- 后端 API 鉴权
那么你就需要一套比“读地址”更完整的 DID 登录架构。
方案目标与架构取舍
本文实现的目标是:
- 用户使用 MetaMask 登录
- 系统基于以太坊地址生成 DID
- DID 文档存储在 IPFS
- 智能合约负责维护 DID 与文档 CID 的映射
- 后端使用挑战签名完成登录并签发 JWT
- 前端可查询 DID 文档并展示身份信息
为什么是“以太坊 + IPFS + 后端认证”这套组合?
因为它在工程上比较平衡:
- 以太坊:提供可信状态、身份绑定、可验证更新记录
- IPFS:提供低成本的内容寻址存储
- 后端认证服务:负责 Web2 应用仍然需要的会话、权限和业务规则
链上链下边界
放链上:
- 地址与 DID 的绑定关系
- DID 文档的 IPFS CID
- 更新时间、拥有者校验
- 可选:吊销状态、版本号
放 IPFS:
- DID Document
- 公钥列表
- service endpoint
- 业务扩展元数据
- 可验证凭证引用
放后端数据库:
- 登录 nonce
- JWT/Session
- 风控日志
- 用户偏好等非共识数据
核心原理
这一套登录系统,核心其实是三件事:
- 身份标识生成
- 身份文档解析
- 挑战签名认证
1. DID 生成规则
我们定义一个简单 DID:
did:ethr:0xAbC123...
这类 DID 直接使用以太坊地址作为标识主体。更严格一点,也可以加链 ID:
did:ethr:1:0xAbC123...
这里的 1 表示 Ethereum Mainnet。测试网可换成其他链 ID。
2. DID 文档结构
DID 本身只是标识符,真正可解析的是 DID Document。一个简化版结构如下:
{
"id": "did:ethr:1:0x1234567890abcdef",
"verificationMethod": [
{
"id": "did:ethr:1:0x1234567890abcdef#owner",
"type": "EcdsaSecp256k1RecoveryMethod2020",
"controller": "did:ethr:1:0x1234567890abcdef",
"blockchainAccountId": "eip155:1:0x1234567890abcdef"
}
],
"authentication": [
"did:ethr:1:0x1234567890abcdef#owner"
],
"service": [
{
"id": "did:ethr:1:0x1234567890abcdef#profile",
"type": "UserProfile",
"serviceEndpoint": "ipfs://bafy..."
}
]
}
这个文档说明:
- 这个 DID 由哪个链上账户控制
- 哪个验证方法可以用于认证
- 对外提供了哪些服务入口
3. 登录认证流程
用户登录时,不是“把私钥交给服务器”,而是:
- 服务端生成一次性 nonce
- 用户用钱包签名
- 服务端恢复签名地址
- 核对该地址是否与 DID 一致
- 成功后签发业务 JWT
流程图
flowchart TD
A[用户连接钱包] --> B[前端请求 nonce]
B --> C[后端生成 nonce 并保存]
C --> D[前端拼接登录消息]
D --> E[MetaMask 签名]
E --> F[前端提交 address + signature + did]
F --> G[后端验签恢复地址]
G --> H{地址是否匹配 DID}
H -- 是 --> I[签发 JWT]
H -- 否 --> J[拒绝登录]
时序图
sequenceDiagram
participant U as User
participant FE as Frontend
participant BE as Backend
participant ETH as Ethereum
participant IPFS as IPFS
U->>FE: 连接 MetaMask
FE->>BE: GET /auth/nonce?address=0x...
BE-->>FE: nonce
FE->>U: 请求钱包签名
U-->>FE: signature
FE->>BE: POST /auth/verify
BE->>BE: 验证签名并生成 JWT
BE-->>FE: token
FE->>ETH: 查询 DIDRegistry.getDocumentCID(address)
ETH-->>FE: CID
FE->>IPFS: 获取 DID Document
IPFS-->>FE: 返回身份文档
方案对比与取舍分析
做 DID 登录时,常见方案不止一种。这里我把几种思路放一起比较。
方案 A:纯钱包签名登录,不上链
做法: 只做 nonce + 签名 + JWT,不维护 DID 文档。
优点:
- 最简单
- 上线快
- 几乎没有链上成本
缺点:
- 缺少标准化身份文档
- 无法沉淀去中心化身份元数据
- 不利于多应用复用身份
方案 B:链上注册 DID,文档放 IPFS
做法: 本文采用的方案。链上合约保存地址到 CID 的映射,IPFS 存 DID Document。
优点:
- 成本与能力平衡较好
- 可验证、可更新
- 扩展性强
缺点:
- 比纯签名登录复杂
- 需要维护链上合约与 IPFS 节点/服务
方案 C:全部身份信息上链
优点:
- 极致透明
- 状态统一
缺点:
- 成本高
- 隐私差
- 不适合中等复杂业务
结论: 对大多数中级 Web3 项目来说,方案 B 是最实用的工程选型。
系统架构设计
整体组件如下:
- 前端:React/Vue,负责钱包交互和展示
- 认证后端:Express,负责 nonce、验签、JWT
- DIDRegistry 合约:保存地址 -> CID 映射
- IPFS:存储 DID Document
- 数据库:保存 nonce 和账号辅助信息
架构图
flowchart LR
FE[前端 DApp]
BE[认证后端 Express]
DB[(PostgreSQL/SQLite)]
SC[以太坊 DIDRegistry 合约]
IPFS[IPFS 网关/节点]
FE --> BE
BE --> DB
FE --> SC
FE --> IPFS
BE --> SC
实战代码(可运行)
下面我给出一套最小可运行版本:
- Solidity 合约
- Node.js 后端
- 前端核心登录代码
为了降低门槛,后端这里用内存 nonce 存储;实际生产再换 Redis 或数据库。
一、智能合约:DIDRegistry
功能很简单:
- 用户注册自己的 DID 文档 CID
- 用户更新 CID
- 外部查询地址对应 CID
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract DIDRegistry {
struct DIDRecord {
string cid;
uint256 updatedAt;
}
mapping(address => DIDRecord) private records;
event DIDDocumentUpdated(address indexed owner, string cid, uint256 updatedAt);
function setDocumentCID(string calldata cid) external {
require(bytes(cid).length > 0, "CID cannot be empty");
records[msg.sender] = DIDRecord({
cid: cid,
updatedAt: block.timestamp
});
emit DIDDocumentUpdated(msg.sender, cid, block.timestamp);
}
function getDocumentCID(address owner) external view returns (string memory, uint256) {
DIDRecord memory record = records[owner];
return (record.cid, record.updatedAt);
}
}
如果你用 Hardhat,可以这样部署。
scripts/deploy.js
const hre = require("hardhat");
async function main() {
const DIDRegistry = await hre.ethers.getContractFactory("DIDRegistry");
const registry = await DIDRegistry.deploy();
await registry.waitForDeployment();
console.log("DIDRegistry deployed to:", await registry.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
二、后端:Express + ethers 实现挑战登录
安装依赖
npm init -y
npm install express cors jsonwebtoken ethers
server.js
const express = require("express");
const cors = require("cors");
const jwt = require("jsonwebtoken");
const { ethers } = require("ethers");
const crypto = require("crypto");
const app = express();
app.use(cors());
app.use(express.json());
const nonces = new Map();
const JWT_SECRET = "replace-with-a-strong-secret";
function createNonce() {
return crypto.randomBytes(16).toString("hex");
}
function buildLoginMessage({ did, address, nonce }) {
return [
"Web3 DID Login",
`DID: ${did}`,
`Address: ${address}`,
`Nonce: ${nonce}`
].join("\n");
}
app.get("/auth/nonce", (req, res) => {
const { address } = req.query;
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid address" });
}
const nonce = createNonce();
nonces.set(address.toLowerCase(), {
nonce,
createdAt: Date.now()
});
res.json({ nonce });
});
app.post("/auth/verify", async (req, res) => {
try {
const { did, address, signature } = req.body;
if (!did || !address || !signature) {
return res.status(400).json({ error: "missing fields" });
}
if (!ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid address" });
}
const normalizedAddress = address.toLowerCase();
const record = nonces.get(normalizedAddress);
if (!record) {
return res.status(400).json({ error: "nonce not found" });
}
if (Date.now() - record.createdAt > 5 * 60 * 1000) {
nonces.delete(normalizedAddress);
return res.status(400).json({ error: "nonce expired" });
}
const expectedDid = `did:ethr:1:${address}`;
if (did !== expectedDid) {
return res.status(400).json({ error: "did does not match address" });
}
const message = buildLoginMessage({
did,
address,
nonce: record.nonce
});
const recoveredAddress = ethers.verifyMessage(message, signature);
if (recoveredAddress.toLowerCase() !== normalizedAddress) {
return res.status(401).json({ error: "signature verification failed" });
}
nonces.delete(normalizedAddress);
const token = jwt.sign(
{
sub: did,
address
},
JWT_SECRET,
{ expiresIn: "2h" }
);
res.json({
token,
did,
address
});
} catch (err) {
console.error(err);
res.status(500).json({ error: "internal server error" });
}
});
app.get("/profile", (req, res) => {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
try {
const payload = jwt.verify(token, JWT_SECRET);
res.json({
message: "authorized",
user: payload
});
} catch (e) {
res.status(401).json({ error: "invalid token" });
}
});
app.listen(3001, () => {
console.log("Server running at http://localhost:3001");
});
运行:
node server.js
三、前端:钱包签名登录
下面给出一个最小版浏览器端逻辑。你可以直接嵌进 React 页面里。
import { ethers } from "ethers";
const API_BASE = "http://localhost:3001";
function buildLoginMessage({ did, address, nonce }) {
return [
"Web3 DID Login",
`DID: ${did}`,
`Address: ${address}`,
`Nonce: ${nonce}`
].join("\n");
}
export async function loginWithDID() {
if (!window.ethereum) {
throw new Error("MetaMask not found");
}
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const did = `did:ethr:1:${address}`;
const nonceResp = await fetch(`${API_BASE}/auth/nonce?address=${address}`);
const nonceData = await nonceResp.json();
if (!nonceResp.ok) {
throw new Error(nonceData.error || "failed to get nonce");
}
const message = buildLoginMessage({
did,
address,
nonce: nonceData.nonce
});
const signature = await signer.signMessage(message);
const verifyResp = await fetch(`${API_BASE}/auth/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
did,
address,
signature
})
});
const verifyData = await verifyResp.json();
if (!verifyResp.ok) {
throw new Error(verifyData.error || "login failed");
}
localStorage.setItem("token", verifyData.token);
return verifyData;
}
四、上传 DID Document 到 IPFS
如果你使用 Pinata、web3.storage 或本地 IPFS 节点,本质上都是上传 JSON,拿到 CID。
一个 DID Document 示例:
{
"id": "did:ethr:1:0xYourAddress",
"verificationMethod": [
{
"id": "did:ethr:1:0xYourAddress#owner",
"type": "EcdsaSecp256k1RecoveryMethod2020",
"controller": "did:ethr:1:0xYourAddress",
"blockchainAccountId": "eip155:1:0xYourAddress"
}
],
"authentication": [
"did:ethr:1:0xYourAddress#owner"
],
"service": [
{
"id": "did:ethr:1:0xYourAddress#profile",
"type": "UserProfile",
"serviceEndpoint": "https://example.com/users/0xYourAddress"
}
]
}
拿到 CID 后,调用合约 setDocumentCID(cid) 即可。
五、前端读取链上 CID 并解析 DID 文档
import { ethers } from "ethers";
const CONTRACT_ADDRESS = "0xYourContractAddress";
const ABI = [
"function getDocumentCID(address owner) view returns (string memory, uint256)"
];
export async function resolveDidDocument(address) {
if (!window.ethereum) {
throw new Error("wallet not found");
}
const provider = new ethers.BrowserProvider(window.ethereum);
const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, provider);
const [cid] = await contract.getDocumentCID(address);
if (!cid) {
throw new Error("DID document not registered");
}
const url = `https://ipfs.io/ipfs/${cid}`;
const resp = await fetch(url);
if (!resp.ok) {
throw new Error("failed to fetch DID document");
}
return await resp.json();
}
六、最小验证清单
如果你想确认整条链路没问题,可以按下面顺序验证:
- 部署
DIDRegistry - 本地启动
server.js - 准备 DID Document JSON 并上传 IPFS
- 用钱包调用
setDocumentCID - 前端执行
loginWithDID() - 使用 JWT 请求
/profile - 查询合约 CID 并拉取 IPFS 文档
只要第 5 步能拿到 token,第 7 步能拿到文档,最小 DID 登录链路就已经通了。
容量估算与扩展建议
虽然这是一个“登录系统”,但一旦你开始承载真实用户,就不能只盯着功能,还要考虑规模。
1. 后端 nonce 存储规模
假设:
- 峰值并发登录请求:1000 QPS
- nonce 有效期:5 分钟
则理论最大活跃 nonce 数量约为:
1000 * 300 = 300000
如果每个 nonce 记录按 200B 估算,大约是:
300000 * 200B ≈ 60MB
这说明:
- 小规模项目内存存储可勉强支撑
- 中大型系统建议改成 Redis,并设置 TTL 自动过期
2. 链上写入成本
DID 文档更新不是高频操作,所以适合放链上锚定。但如果你把“每次登录都写链”,那 gas 成本会非常夸张。
经验上:登录只验签,不上链;身份变更才上链。
3. IPFS 可用性
IPFS 不是“上传完永远高速可用”。如果没有 pin,内容可能很难稳定取回。生产环境建议:
- 至少双 pin 服务商
- 自建网关或做缓存层
- 热数据走 CDN 网关加速
常见坑与排查
这一节非常重要。很多 Web3 登录看起来逻辑简单,但一接前后端和钱包,问题就会集中冒出来。
1. 签名验证失败
现象:
后端 verifyMessage 恢复出的地址不对。
优先检查:
- 前后端拼接消息是否完全一致
- 换行符是否一致(
\n) - 地址大小写是否参与了 DID 字符串比较
- 是不是签了错误 nonce
- nonce 是否已过期或已被消费
我踩过的坑:
有一次前端消息里多了个末尾空格,后端验签死活不通过,排查了半小时。
结论:登录消息模板一定要封装成公共函数,前后端共享。
2. DID 与地址不匹配
现象:
did:ethr:1:0xabc...
和当前连接钱包地址不是同一个。
原因:
- 用户切换了钱包账户
- 前端缓存了旧 DID
- 登录过程中 chain/account changed 事件没处理
解决:
监听钱包事件,账户或网络变更后,清理登录态并重新发起认证。
if (window.ethereum) {
window.ethereum.on("accountsChanged", () => {
localStorage.removeItem("token");
window.location.reload();
});
window.ethereum.on("chainChanged", () => {
localStorage.removeItem("token");
window.location.reload();
});
}
3. IPFS 文档取不回来
现象:
- 网关超时
- 返回 404
- CID 存在但内容不可达
原因:
- 文档未 pin
- 网关不稳定
- CID 填错
- 上传的是目录 CID,不是文件 CID
排查建议:
- 先本地通过多个公共网关试拉
- 确认上传后返回的是目标 JSON 文件 CID
- 检查内容类型是否正确
- 生产环境不要只依赖单个公共网关
4. 合约读到了 CID,但文档格式不对
现象: IPFS JSON 能取回,但前端解析失败。
原因:
- DID Document 字段命名不规范
- 少了
id authentication引用了不存在的verificationMethod
建议:
- 保持文档 schema 稳定
- 加入 JSON schema 校验
- 文档版本升级时引入
version字段
5. 重放攻击风险
现象: 同一个签名被重复提交也能登录。
原因: nonce 没有一次性消费,或者有效期过长。
解决:
- nonce 单次使用
- 验签成功立即删除
- nonce 设置 TTL
- 在消息中加入域名、时间戳、用途字段
安全最佳实践
DID 登录系统最怕的不是“写不出来”,而是“写出来但不安全”。
1. 使用挑战-响应,绝不直接信任地址
前端发来一个地址不代表用户控制它。
必须要求用户签名挑战消息。
2. 登录消息里加入域名、用途、时效
建议消息格式至少包含:
- 域名
- 地址
- DID
- nonce
- 签发时间
- 用途说明
例如:
Web3 DID Login
Domain: app.example.com
Purpose: Login
DID: did:ethr:1:0x...
Address: 0x...
Nonce: abc123
Issued At: 2024-01-01T00:00:00Z
这样可以显著降低跨站重放风险。
3. 后端 JWT 生命周期不要太长
DID 登录不代表可以无限放大 session 生命周期。
经验上:
- access token:30 分钟到 2 小时
- refresh token:按业务需求设置,并谨慎绑定设备
如果是高敏操作,建议再次钱包签名,而不是只靠 JWT。
4. 私钥安全不在你的服务器,但钓鱼风险仍在你的产品里
你虽然不保存私钥,但用户仍可能在恶意页面签错消息。
所以前端要:
- 明确展示签名用途
- 消息内容尽量可读
- 不要请求不必要签名
- 高风险操作使用结构化签名(如 EIP-712)
5. 合约权限最小化
本文示例里 setDocumentCID 只能由 msg.sender 更新自己的记录,这已经是一层天然权限控制。
如果你后续加管理员、代理更新、恢复机制,一定要谨慎设计,否则身份所有权会被中心化回收。
性能最佳实践
安全之外,性能也很关键。毕竟登录是高频入口。
1. 合约查询尽量走只读 RPC
查询 DID 文档 CID 时走 eth_call,不要产生交易。
并且建议:
- 前端加本地缓存
- 后端增加只读聚合层
- 对热门 DID 做短时缓存
2. IPFS 文档增加缓存层
实际项目里,IPFS 最好经过一层:
- 自有网关
- CDN
- 应用层缓存
尤其是首页展示用户资料时,别每次都直接打公共网关。
3. nonce 使用 Redis 替代进程内存
进程内存的几个问题:
- 服务重启丢失
- 无法多实例共享
- 不适合水平扩容
中大型部署建议:
- Redis
SETEX保存 nonce - 验证成功后原子删除
- 配合限流防刷
4. 将 DID 解析与登录鉴权解耦
不要把“登录验签”和“DID 文档解析”强绑在同一个链路里。
更合理的是:
- 登录只做签名验证和会话签发
- DID 文档解析异步/按需加载
否则 IPFS 或 RPC 抖动会直接影响登录成功率。
边界条件与不适用场景
这套方案不是银弹,下面这些场景要谨慎:
不太适合的情况
-
纯内容站或轻应用
- 用钱包签名登录可能比账号密码更重
-
强实名、强监管业务
- DID 只能解决控制权问题,不天然解决 KYC 合规
-
超高频实时系统
- 如果每次交互都要求签名,用户体验会很差
更适合的情况
- DApp、DAO、NFT 社区
- 多应用共享身份体系
- 用户希望掌控资料与凭证的生态型平台
总结
如果把本文压缩成一句话,那就是:
DID 登录系统的关键,不在于“把登录搬上链”,而在于合理划分链上可信锚点、链下身份文档与后端业务会话。
回到工程落地,我建议你按下面顺序推进:
-
先实现 nonce + 签名登录
- 确保基础认证流程稳定
-
再引入 DID 规则与 IPFS 文档
- 让身份从“地址”升级为“可解析身份对象”
-
最后接入链上注册合约
- 把身份声明变成可验证状态
如果你是中级开发者,这套架构已经足够支撑一个真实 Web3 应用的身份入口。它不算最复杂,但足够实用,而且扩展空间大:后续你可以继续接入 Verifiable Credentials、社交恢复、多链 DID、EIP-712 签名等能力。
我自己的经验是,先把登录这条链路做稳,再追求协议完整性。很多 Web3 项目一开始就想一步到位做“完美身份协议”,结果最后卡在签名细节、文档可用性和会话管理上。与其追求大而全,不如先把“用户能稳定登录、身份可验证、文档能解析”这三件事做好。
这,才是一套 DID 登录系统真正可上线的起点。