Web3 中级实战:从智能合约审计到前端签名验证,构建一套安全的 DApp 登录与授权方案
很多人做 DApp 登录时,第一反应是:“让用户钱包签个名,不就登录了吗?”
这话没错,但只说对了一半。
真正上线后,你会发现问题远不止“能不能签名”这么简单:
- 前端签名内容是否会被重放?
- 后端如何校验地址归属?
- 合约里的授权逻辑会不会被滥用?
approve、permit、离线签名、会话 token 之间怎么衔接?- 用户在不同链、不同域名、不同钱包里的行为是否一致?
我自己第一次做这类链路时,就踩过一个典型坑:前端只做了 personal_sign,后端只恢复地址,不校验 nonce、domain、chainId,结果测试环境里同一个签名被重复使用,任何人拿到签名字符串都能“复登录”。这类问题在开发阶段很常见,但如果带到生产环境,风险就很真实了。
这篇文章我们就从**“安全的 DApp 登录与授权”**这个角度出发,把一套中级开发者真正会用到的链路串起来:
- 前端发起钱包签名登录
- 后端验证签名、签发会话
- 合约侧做基于签名的授权(EIP-712 / permit 风格)
- 从审计视角检查关键风险点
- 给出一套可运行的最小示例
背景与问题
传统 Web 登录依赖账号密码、短信验证码或 OAuth。
而在 Web3 里,用户最稳定的“身份载体”往往是钱包地址。
问题在于:钱包地址不是账号系统,签名也不是完整的授权系统。
一个可靠的 DApp 登录与授权方案,至少要解决以下几类问题:
1. 身份确认问题
你需要确认:
- 当前签名确实来自用户控制的钱包地址
- 签名不是历史数据重放
- 签名和当前站点、链环境有关联
2. 会话管理问题
钱包签名适合“证明地址归属”,但不适合每次请求都重新签名。
所以后端通常会在签名成功后,签发一个短期 session token / JWT。
3. 合约授权问题
很多业务不仅需要“登录”,还需要“让合约接受某个离线授权”。
例如:
- 允许某个操作员代用户执行一次动作
- 使用
permit免 gas 批准额度 - 基于 EIP-712 验证结构化数据签名
4. 安全边界问题
如果你只顾着把链路跑通,而不考虑以下点,往往就会留坑:
- nonce 是否一次性使用
- 域名绑定是否严格
- chainId 是否进入签名上下文
- 签名过期时间是否合理
- 合约是否存在重放、签名伪造、
ecrecover使用不当的问题
前置知识与环境准备
本文默认你已经了解这些基础概念:
- Ethereum 地址与私钥、公钥关系
ethers.js基本使用- Solidity 合约开发与部署
- Node.js / Express 基础
环境
本文示例使用:
- Node.js 18+
- Solidity 0.8.20
- Hardhat
- ethers v6
- Express
- MetaMask 或兼容 EIP-1193 的钱包
初始化项目:
mkdir secure-dapp-auth
cd secure-dapp-auth
npm init -y
npm install ethers express cors jsonwebtoken dotenv
npm install -D hardhat @nomicfoundation/hardhat-toolbox
npx hardhat
前端如果你想快速跑 demo,可以直接用 Vite:
npm create vite@latest frontend -- --template vanilla
cd frontend
npm install ethers
核心原理
我们先把整条链路想清楚,再写代码。
一句话概括
- 登录:用户用钱包对服务器给出的挑战消息签名,后端验证签名后发 token
- 授权:用户对结构化数据签名,合约链上验证签名并执行受限操作
这两者相关,但不要混用:
- 登录签名:通常给后端验证,建立 Web 会话
- 授权签名:通常给合约验证,影响链上状态
整体架构图
flowchart TD
A[前端请求 nonce] --> B[后端生成挑战消息]
B --> C[前端调用钱包签名]
C --> D[前端提交 address + signature]
D --> E[后端校验签名/nonce/domain/过期时间]
E --> F[签发 JWT/Session]
F --> G[前端带 token 访问业务接口]
G --> H[需要链上动作时发起 EIP-712 签名]
H --> I[Relayer 或用户提交交易]
I --> J[合约验证签名并执行]
登录链路的关键元素
1. Nonce
nonce 是一次性挑战值,用来防止重放。
后端每次登录发一个新的 nonce,验证成功后立刻作废。
2. Domain / URI 绑定
签名消息里应该包含:
- 当前站点 domain
- URI
- chainId
- 时间戳
- 到期时间
这能防止签名被其他站点复用。
3. 过期时间
签名消息必须带有效期。
否则用户一年前签过的一段消息,理论上也能继续登录。
4. 结构化签名优于随意拼字符串
能用 EIP-4361(Sign-In with Ethereum)或 EIP-712 的场景,尽量不要自己随意拼接字符串。
手写字符串最容易在“格式不一致”这件事上出事故。
登录时序图
sequenceDiagram
participant U as 用户钱包
participant F as 前端
participant B as 后端
F->>B: GET /auth/nonce?address=0xabc...
B-->>F: nonce + challenge message
F->>U: personal_sign(message)
U-->>F: signature
F->>B: POST /auth/verify {address,message,signature}
B->>B: recoverAddress + 校验 nonce/域名/时间
B-->>F: JWT token
F->>B: 携带 token 调用受保护接口
合约授权的核心原理
如果登录只是后端校验签名,那授权通常是链上校验签名。
常见模式:
- 用户签结构化数据(EIP-712)
- 交易提交者可以是用户自己,也可以是 relayer
- 合约通过
ECDSA.recover恢复签名者地址 - 校验 nonce、deadline、domain separator
- 执行受限逻辑
这种方式的优点是:
- 用户不一定要自己直接发交易
- 可以减少重复授权操作
- 签名内容可读性更强,安全性更高
合约授权状态流转图
stateDiagram-v2
[*] --> Unsigned
Unsigned --> Signed: 用户签署 EIP-712 数据
Signed --> Submitted: 提交交易
Submitted --> Executed: 签名有效 + nonce 未使用
Submitted --> Rejected: 过期/重放/签名错误
Executed --> [*]
Rejected --> [*]
实战代码(可运行)
下面我们做一个最小可运行方案,包含两部分:
- 后端钱包登录
- 合约侧 EIP-712 授权执行
一、后端:基于签名的登录验证
目录建议
secure-dapp-auth/
├─ backend/
│ ├─ server.js
│ └─ .env
├─ contracts/
│ └─ SecureAction.sol
├─ scripts/
│ └─ deploy.js
└─ frontend/
└─ index.html
1. 后端服务 backend/server.js
这个示例用内存 Map 存 nonce,方便本地演示。生产环境请放 Redis 或数据库。
import express from "express";
import cors from "cors";
import jwt from "jsonwebtoken";
import { randomUUID } from "crypto";
import { ethers } from "ethers";
import dotenv from "dotenv";
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
const nonces = new Map();
const DOMAIN = "localhost:5173";
const ORIGIN_URI = "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret-change-me";
function buildMessage({ address, nonce, chainId }) {
const issuedAt = new Date().toISOString();
const expirationTime = new Date(Date.now() + 5 * 60 * 1000).toISOString();
return `localhost:5173 wants you to sign in with your Ethereum account:
${address}
Sign in to the demo DApp.
URI: ${ORIGIN_URI}
Version: 1
Chain ID: ${chainId}
Nonce: ${nonce}
Issued At: ${issuedAt}
Expiration Time: ${expirationTime}`;
}
app.get("/auth/nonce", (req, res) => {
const { address, chainId } = req.query;
if (!address || !ethers.isAddress(address)) {
return res.status(400).json({ error: "invalid address" });
}
const nonce = randomUUID();
const message = buildMessage({
address,
nonce,
chainId: Number(chainId || 1),
});
nonces.set(address.toLowerCase(), {
nonce,
message,
createdAt: Date.now(),
used: false,
});
res.json({ nonce, 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 record = nonces.get(address.toLowerCase());
if (!record) {
return res.status(400).json({ error: "nonce not found" });
}
if (record.used) {
return res.status(400).json({ error: "nonce already used" });
}
if (record.message !== message) {
return res.status(400).json({ error: "message mismatch" });
}
const recovered = ethers.verifyMessage(message, signature);
if (recovered.toLowerCase() !== address.toLowerCase()) {
return res.status(401).json({ error: "invalid signature" });
}
const nonceLine = message.match(/Nonce: (.+)/);
const expirationLine = message.match(/Expiration Time: (.+)/);
const uriLine = message.match(/URI: (.+)/);
if (!nonceLine || nonceLine[1] !== record.nonce) {
return res.status(400).json({ error: "invalid nonce in message" });
}
if (!uriLine || uriLine[1] !== ORIGIN_URI) {
return res.status(400).json({ error: "invalid uri" });
}
if (!expirationLine || Date.now() > new Date(expirationLine[1]).getTime()) {
return res.status(400).json({ error: "message expired" });
}
record.used = true;
const token = jwt.sign(
{
sub: address,
wallet: address,
},
JWT_SECRET,
{ expiresIn: "1h" }
);
return res.json({
ok: true,
token,
address,
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
app.get("/me", (req, res) => {
const auth = req.headers.authorization || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!token) {
return res.status(401).json({ error: "missing token" });
}
try {
const payload = jwt.verify(token, JWT_SECRET);
return res.json({ ok: true, user: payload });
} catch (err) {
return res.status(401).json({ error: "invalid token" });
}
});
app.listen(3000, () => {
console.log("Backend listening on http://localhost:3000");
});
运行后端
cd backend
node server.js
.env:
JWT_SECRET=replace-with-a-long-random-string
2. 前端:请求 nonce、签名并登录
这里用最简单的原生 HTML + JS 演示,逻辑更直观。
frontend/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Secure DApp Login Demo</title>
</head>
<body>
<h2>Secure DApp Login Demo</h2>
<button id="connectBtn">连接钱包并登录</button>
<button id="meBtn">查看当前会话</button>
<pre id="output"></pre>
<script type="module">
import { ethers } from "https://cdn.jsdelivr.net/npm/ethers@6.13.1/+esm";
const output = document.getElementById("output");
const connectBtn = document.getElementById("connectBtn");
const meBtn = document.getElementById("meBtn");
function log(data) {
output.textContent =
typeof data === "string" ? data : JSON.stringify(data, null, 2);
}
connectBtn.onclick = async () => {
try {
if (!window.ethereum) {
throw new Error("未检测到钱包");
}
const provider = new ethers.BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
const address = await signer.getAddress();
const network = await provider.getNetwork();
const nonceResp = await fetch(
`http://localhost:3000/auth/nonce?address=${address}&chainId=${network.chainId}`
);
const nonceData = await nonceResp.json();
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) {
throw new Error(verifyData.error || "登录失败");
}
localStorage.setItem("token", verifyData.token);
log({
message: "登录成功",
address,
token: verifyData.token,
});
} catch (err) {
log({ error: err.message });
}
};
meBtn.onclick = async () => {
try {
const token = localStorage.getItem("token");
const resp = await fetch("http://localhost:3000/me", {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await resp.json();
log(data);
} catch (err) {
log({ error: err.message });
}
};
</script>
</body>
</html>
你应该验证什么
跑起来后,建议按这个顺序手动验证:
- 正常登录成功
- 同一个签名再次提交,应该失败
- 修改 message 中任意一行,应该失败
- 等待过期后再提交,应该失败
- 更换另一个钱包地址提交同一签名,应该失败
二、合约:基于 EIP-712 的链上授权
接下来写一个简单的合约:
用户签名授权某个 caller 执行一次动作,合约验证签名后才允许执行。
这类模式可以作为:
- 一次性操作授权
- relayer 代执行
- permit 风格动作的基础骨架
1. 智能合约 contracts/SecureAction.sol
这里使用 OpenZeppelin 的 EIP712 和 ECDSA 工具库,能少踩很多底层坑。
先安装依赖:
npm install @openzeppelin/contracts
合约代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SecureAction is EIP712 {
using ECDSA for bytes32;
string private constant SIGNING_DOMAIN = "SecureAction";
string private constant SIGNATURE_VERSION = "1";
bytes32 private constant ACTION_TYPEHASH =
keccak256("Action(address user,address caller,uint256 value,uint256 nonce,uint256 deadline)");
mapping(address => uint256) public nonces;
mapping(address => uint256) public executedValues;
event ActionExecuted(address indexed user, address indexed caller, uint256 value, uint256 nonce);
constructor() EIP712(SIGNING_DOMAIN, SIGNATURE_VERSION) {}
function executeBySig(
address user,
address caller,
uint256 value,
uint256 deadline,
bytes calldata signature
) external {
require(block.timestamp <= deadline, "signature expired");
require(msg.sender == caller, "unauthorized sender");
uint256 currentNonce = nonces[user];
bytes32 structHash = keccak256(
abi.encode(
ACTION_TYPEHASH,
user,
caller,
value,
currentNonce,
deadline
)
);
bytes32 digest = _hashTypedDataV4(structHash);
address signer = ECDSA.recover(digest, signature);
require(signer == user, "invalid signature");
nonces[user] = currentNonce + 1;
executedValues[user] += value;
emit ActionExecuted(user, caller, value, currentNonce);
}
}
2. Hardhat 配置与部署脚本
hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
module.exports = {
solidity: "0.8.20",
};
scripts/deploy.js
async function main() {
const SecureAction = await ethers.getContractFactory("SecureAction");
const contract = await SecureAction.deploy();
await contract.waitForDeployment();
console.log("SecureAction deployed to:", await contract.getAddress());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
部署
npx hardhat compile
npx hardhat run scripts/deploy.js --network hardhat
如果你要本地链调试:
npx hardhat node
然后另开终端部署到本地网络。
3. 生成 EIP-712 签名并调用合约
下面写一个脚本,模拟:
user签名caller发交易- 合约验证签名后执行
scripts/sign-and-execute.js
const { ethers } = require("hardhat");
async function main() {
const [user, caller] = await ethers.getSigners();
const contractAddress = "请替换为部署后的合约地址";
const contract = await ethers.getContractAt("SecureAction", contractAddress);
const nonce = await contract.nonces(user.address);
const deadline = Math.floor(Date.now() / 1000) + 300;
const value = 42;
const network = await ethers.provider.getNetwork();
const domain = {
name: "SecureAction",
version: "1",
chainId: Number(network.chainId),
verifyingContract: contractAddress,
};
const types = {
Action: [
{ name: "user", type: "address" },
{ name: "caller", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const message = {
user: user.address,
caller: caller.address,
value,
nonce,
deadline,
};
const signature = await user.signTypedData(domain, types, message);
console.log("signature:", signature);
const tx = await contract
.connect(caller)
.executeBySig(user.address, caller.address, value, deadline, signature);
await tx.wait();
const executed = await contract.executedValues(user.address);
console.log("executedValues:", executed.toString());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
运行:
npx hardhat run scripts/sign-and-execute.js --network localhost
从审计视角检查这份合约
这一部分很关键。
很多教程会教你“怎么签名、怎么恢复地址”,但不会告诉你审计时到底看什么。
我这里给你一个非常实用的审计思路:按攻击面拆解。
审计检查清单
1. 是否存在重放攻击
重点看:
- 是否有
nonce - nonce 是否与用户绑定
- nonce 是否在成功执行后递增
- 是否把
deadline纳入签名内容
本例里:
mapping(address => uint256) public nonces;
并且执行成功后:
nonces[user] = currentNonce + 1;
这能防止同一签名被重复使用。
2. 是否绑定了 domain separator
重点看签名域是否包含:
nameversionchainIdverifyingContract
使用 OpenZeppelin 的 EIP712 可以自动处理这些细节,避免自己拼 digest 时出错。
如果你手写:
keccak256(abi.encodePacked(...))
我会建议你非常谨慎,因为:
- 容易编码冲突
- 容易漏链 ID
- 容易漏合约地址
- 容易造成跨合约/跨链重放
3. msg.sender 是否受限
很多人写元交易或离线授权时,会忘了限制谁能提交。
比如本例里:
require(msg.sender == caller, "unauthorized sender");
这意味着签名里授权给谁,最后就只能由谁提交。
如果你的业务允许“任何 relayer 都可提交”,那就不要把 caller 纳入约束;但你要明白,这是业务选择,不是默认安全。
4. 签名恢复是否安全
不要裸用底层 ecrecover 除非你很清楚:
s值规范化v值合法性- malleability 问题
- digest 构造规范
中级项目里,我更推荐直接用:
- OpenZeppelin
ECDSA - OpenZeppelin
SignatureChecker(如果要兼容 ERC-1271 合约钱包)
5. 是否支持合约钱包
这是很多团队上线后才想起来的问题。
EOA 钱包可以直接 recover,但智能合约钱包(如 Safe)未必能这么验证。
如果你的用户群可能使用合约钱包,请考虑:
- 登录侧:后端是否支持 ERC-1271 校验
- 合约侧:使用
SignatureChecker.isValidSignatureNow
本篇为了最小示例先不展开,但这是生产环境必须评估的一点。
常见坑与排查
下面这些坑,我基本都见过,而且都不算“低级错误”,很适合中级开发者提前避坑。
坑 1:前端和后端签的不是同一份消息
现象:
- 用户明明签名成功
- 后端恢复出来的地址却不对,或 message mismatch
常见原因:
- 前端拿到 message 后又做了 trim
- 后端重建 message 时换行符不同
- 时间字段重新生成,导致消息不一致
排查建议:
- 不要让后端“重建消息”来验证
- 后端保存生成时的原始 message,并做精确比对
- 把 message 原文打印出来看换行
我一般会优先采用“后端生成、后端保存、前端原样签名、后端原样验证”的策略。
坑 2:用 personal_sign 和 signTypedData 混了
现象:
- 前端钱包弹窗显示签名成功
- 但后端或合约死活验不过
原因:
signMessage/personal_sign会加 Ethereum Signed Message 前缀signTypedData是 EIP-712,不加同样的前缀- 两者 digest 完全不同
排查方法:
先确认你到底在做哪一类签名:
- 后端登录:一般
signMessage - 链上结构化授权:一般
signTypedData
不要“前端用 A,后端按 B 验”。
坑 3:chainId 不一致
现象:
- 本地链能过,换测试网就失败
- 切链后签名失效
原因:
- EIP-712 domain 里的
chainId和链上实际环境不一致 - 前端从钱包获取的网络和合约部署网络不一致
排查建议:
const network = await provider.getNetwork();
console.log(network.chainId);
同时打印:
- 前端 domain.chainId
- 合约部署链 ID
- 钱包当前链 ID
坑 4:nonce 不是一次性消费
现象:
- 同一个签名能多次登录
- 同一个授权能多次执行
原因:
- 登录成功后 nonce 没标记 used
- 合约执行成功后 nonce 没递增
- 多节点并发下 nonce 更新不原子
排查建议:
生产环境里,nonce 消费要么:
- 数据库事务保证原子性
- Redis
SETNX/ Lua 脚本保证只消费一次
如果只是内存 Map,上线一定不够。
坑 5:只支持 EOA,不支持合约钱包
现象:
- MetaMask 正常
- Safe 用户无法登录或授权
原因:
- 你只用了
recover - 没走 ERC-1271
如果你的产品面向 DAO、机构用户,这个问题不是“以后再说”,而是一开始就要纳入设计。
安全/性能最佳实践
这一节我会把建议分成“必须做”和“按业务做”。
必须做
1. 登录消息必须包含完整上下文
至少包括:
- address
- nonce
- URI / domain
- chainId
- issuedAt
- expirationTime
不要只让用户签:
Login to DApp
这种消息几乎没有安全上下文。
2. nonce 一次性、短有效期
建议:
- nonce 只用一次
- 5 分钟左右过期
- 验证成功立刻作废
3. 合约授权一定要带 nonce + deadline
没有 nonce:容易重放
没有 deadline:签名永久有效,风险太大
4. 尽量使用成熟库
推荐:
- OpenZeppelin
EIP712 - OpenZeppelin
ECDSA - OpenZeppelin
SignatureChecker - ethers.js 官方签名接口
不要为了“少一个依赖”去重写签名恢复流程。
5. 区分登录签名和链上授权签名
我建议项目里明确分层:
/auth/*:后端登录签名/permit/*或/action/*:链上授权签名
消息模板、过期时间、验证方式都分开。
按业务做
1. 引入 SIWE 标准
如果你的登录系统要做得更规范,建议采用 Sign-In with Ethereum (EIP-4361)。
它定义了统一的登录消息格式,比自定义字符串更稳。
2. 支持 ERC-1271
如果目标用户会使用:
- Safe
- AA 钱包
- 机构托管钱包
请尽早支持 ERC-1271。
3. 会话 token 最小权限化
JWT 里不要塞太多敏感信息。
通常只放:
- 钱包地址
- 会话 ID
- 过期时间
- 必要的角色信息
不要把链上授权语义混进普通登录 token。
4. 前端签名前给用户明确展示意图
这是用户安全体验的一部分。
比如按钮不要写“确认”,而要写:
- 登录到当前站点
- 授权执行一次操作
- 授权额度为 X,有效期到 Y
让用户知道自己在签什么,比任何技术细节都重要。
5. 做好限流和审计日志
登录接口建议记录:
- address
- IP
- user-agent
- nonce 申请时间
- nonce 使用状态
- 验证失败原因
这样你在排查异常时会轻松很多。
逐步验证清单
如果你想把这套方案真正落到项目里,我建议按下面顺序推进。
第一步:打通最小登录链路
- 后端生成 nonce + message
- 前端签名
- 后端恢复地址并发 token
第二步:补齐登录安全约束
- nonce 一次性
- message 过期时间
- URI / domain 校验
- chainId 纳入消息
- token 过期与刷新机制
第三步:加入链上授权
- 合约使用 EIP-712
- 签名内容带 nonce + deadline
- 校验
msg.sender是否符合预期 - 增加事件日志
第四步:从审计视角复查
- 是否存在重放
- 是否存在跨链/跨合约重放
- 是否支持合约钱包
- 是否存在消息格式不一致问题
- 是否存在签名永久有效问题
一个实际可用的方案边界
这套方案适合:
- NFT / DeFi / DAO 类 DApp 的登录
- 钱包地址作为主身份的应用
- 需要链下登录 + 链上授权联动的业务
但它不自动等于“所有问题都解决了”,你还需要根据业务判断:
不适合完全依赖钱包签名的场景
- 强实名合规场景
- 多因素认证要求高的企业系统
- 高风险资金操作但没有额外确认流程的产品
在这些场景中,钱包签名只是身份的一层,不是全部。
总结
这篇文章想传达的核心其实就一句话:
DApp 的“登录”和“授权”是两件相关但不同的事情,安全性取决于你是否把上下文、时效性、唯一性和验证边界设计完整。
你可以把本文的实践浓缩成一套落地原则:
- 登录用挑战消息签名,后端做严格验证
- 授权用 EIP-712,合约做 nonce + deadline 校验
- 不要手搓底层签名细节,优先使用成熟库
- 从审计视角提前看重放、域隔离、链隔离、合约钱包兼容
- 先做最小可运行,再补生产级存储、限流、日志和 ERC-1271
如果你现在正准备给自己的 DApp 增加登录系统,我的建议是:
- 先按本文示例跑通最小链路
- 再把 nonce 存储替换为 Redis / DB
- 最后把登录消息标准化为 SIWE,把链上授权统一迁移到 EIP-712
这样做,既不会一上来过度设计,也不会因为“先跑通再说”把安全债拖到上线之后。
而 Web3 项目里,后者往往是最贵的。