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

《Web3 中级实战:基于以太坊与 IPFS 构建去中心化身份认证(DID)登录系统》

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

Web3 中级实战:基于以太坊与 IPFS 构建去中心化身份认证(DID)登录系统

在传统登录系统里,账号和密码通常由中心化服务保存。这个模式我们都很熟:实现简单、用户习惯成熟,但问题也很明显——密码泄露、账号接管、平台垄断身份数据,几乎是反复上演的老戏码。

到了 Web3 世界,身份的控制权开始从平台迁移到用户。DID(Decentralized Identifier,去中心化身份) 的核心价值,不只是“用钱包登录”这么简单,而是让用户真正持有身份、凭证和授权关系。本文我会带你从架构角度,搭一套基于以太坊 + IPFS 的 DID 登录系统,并给出一套能跑起来的最小实现。

这篇内容默认你已经知道以下基础:

  • 会用 Node.js 写简单后端
  • 知道以太坊地址、私钥、签名是什么
  • 用过 MetaMask 或 ethers.js

本文重点不讲概念名词堆砌,而是讲:这套系统为什么这样设计、链上链下怎么分工、代码怎么串起来、上线时怎么避坑。


背景与问题

先明确一个现实问题:直接把身份数据全放链上并不现实。

原因很简单:

  1. :链上存储成本高
  2. :每次身份变更都要等待确认
  3. 隐私差:公开链天然可见,不适合放敏感资料
  4. 难扩展:一旦用户画像、凭证元数据增多,链上存储会迅速膨胀

所以一个更合理的做法是:

  • 链上存“身份锚点”和可验证状态
  • 链下/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. 身份标识生成
  2. 身份文档解析
  3. 挑战签名认证

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();
}

六、最小验证清单

如果你想确认整条链路没问题,可以按下面顺序验证:

  1. 部署 DIDRegistry
  2. 本地启动 server.js
  3. 准备 DID Document JSON 并上传 IPFS
  4. 用钱包调用 setDocumentCID
  5. 前端执行 loginWithDID()
  6. 使用 JWT 请求 /profile
  7. 查询合约 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 抖动会直接影响登录成功率。


边界条件与不适用场景

这套方案不是银弹,下面这些场景要谨慎:

不太适合的情况

  1. 纯内容站或轻应用

    • 用钱包签名登录可能比账号密码更重
  2. 强实名、强监管业务

    • DID 只能解决控制权问题,不天然解决 KYC 合规
  3. 超高频实时系统

    • 如果每次交互都要求签名,用户体验会很差

更适合的情况

  1. DApp、DAO、NFT 社区
  2. 多应用共享身份体系
  3. 用户希望掌控资料与凭证的生态型平台

总结

如果把本文压缩成一句话,那就是:

DID 登录系统的关键,不在于“把登录搬上链”,而在于合理划分链上可信锚点、链下身份文档与后端业务会话。

回到工程落地,我建议你按下面顺序推进:

  1. 先实现 nonce + 签名登录

    • 确保基础认证流程稳定
  2. 再引入 DID 规则与 IPFS 文档

    • 让身份从“地址”升级为“可解析身份对象”
  3. 最后接入链上注册合约

    • 把身份声明变成可验证状态

如果你是中级开发者,这套架构已经足够支撑一个真实 Web3 应用的身份入口。它不算最复杂,但足够实用,而且扩展空间大:后续你可以继续接入 Verifiable Credentials、社交恢复、多链 DID、EIP-712 签名等能力。

我自己的经验是,先把登录这条链路做稳,再追求协议完整性。很多 Web3 项目一开始就想一步到位做“完美身份协议”,结果最后卡在签名细节、文档可用性和会话管理上。与其追求大而全,不如先把“用户能稳定登录、身份可验证、文档能解析”这三件事做好。

这,才是一套 DID 登录系统真正可上线的起点。


分享到:

上一篇
《从源码到实践:基于开源项目 MinIO 搭建高可用对象存储服务的关键设计与部署指南》
下一篇
《自动化测试稳定性治理实战:从用例分层、环境隔离到 Flaky Test 排查优化》