背景:用户提交地址,后端凭什么相信?
几个月前,我在做一个 DeFi 策略管理平台的前端。用户可以在上面创建"自动复投"策略,然后通过我们的合约执行。流程很简单:前端收集用户输入的策略参数(比如目标池地址、复投频率),然后调用合约。
但问题出在"用户身份"上。后端需要记录每个用户创建了哪些策略,但用户并没有注册流程,也没有密码。他们只是连接了钱包(MetaMask 或 WalletConnect),然后直接操作。后端收到的请求里,用户传一个 userAddress 字段,比如 0x1234...。
我当时就想:这太不安全了。如果某个恶意用户伪造一个请求,把 userAddress 改成别人的地址,后端怎么知道这个地址真的是当前操作者?更糟的是,我们的后端还依赖这个地址来查询用户的历史数据,如果地址被篡改,数据就全乱了。
我需要一种方法:让后端能够验证"当前请求确实来自某个地址的持有者",而且这个过程不能依赖密码,必须完全基于区块链钱包的签名机制。
问题分析:为什么简单的签名不行?
我最初的想法很简单:让前端用 ethers.js 对一段固定字符串签名,然后把签名和地址一起发给后端,后端用 ethers.utils.verifyMessage 验证。
typescript
// 最初的错误思路
const message = "I am the owner of this address";
const signature = await signer.signMessage(message);
// 然后发 { address, signature } 给后端
这看起来没问题,但实际跑起来就发现一堆坑:
- 重放攻击:如果签名被截获,攻击者可以重复使用这个签名来冒充用户。因为消息是固定的,签名永远有效。
- 过期问题:没有时间戳,后端不知道这个签名是什么时候签的。如果用户忘记断开连接,别人拿到这个签名可以一直用。
- 跨域问题:如果用户在不同 dApp 上签名了同样的消息,攻击者可以拿到签名后在我们的后端使用。
我当时就踩了这个坑:上线第一天,团队安全审计就说"这个方案不能上线,太脆弱了"。后来我才知道,社区早就有一个标准解决方案------EIP-4361,也就是"Sign-In with Ethereum"(SIWE)。
核心实现:用 siwe 构造防重放签名
SIWE 的核心思想是:把签名消息变成一个结构化的对象,包含 domain(域名)、uri(当前页面)、nonce(随机数)、issuedAt(签发时间)等字段。这样每个签名都是唯一的、有时效的、绑定到特定网站的。
我选择了 siwe 这个 npm 包,配合 wagmi v2 的 useSignMessage hook 来实现。
第一步:前端生成 nonce 并让用户签名
这里有个关键点:nonce 必须由后端生成,否则前端自己生成的 nonce 没有意义。所以我先向后端请求一个 nonce。
typescript
// 1. 从后端获取 nonce
const getNonce = async (): Promise<string> => {
const res = await fetch('/api/auth/nonce');
const data = await res.json();
return data.nonce;
};
// 2. 构造 SIWE 消息
import { SiweMessage } from 'siwe';
import { useSignMessage, useAccount } from 'wagmi';
function LoginButton() {
const { address, chainId } = useAccount();
const { signMessageAsync } = useSignMessage();
const handleLogin = async () => {
if (!address || !chainId) return;
// 注意:domain 必须和你的前端域名一致,否则验证会失败
const domain = window.location.host;
const origin = window.location.origin;
const nonce = await getNonce();
const siweMessage = new SiweMessage({
domain,
address,
statement: 'Sign in to DeFi Dashboard to manage your strategies.',
uri: origin,
version: '1',
chainId,
nonce,
issuedAt: new Date().toISOString(),
});
const message = siweMessage.prepareMessage();
const signature = await signMessageAsync({ message });
// 发送给后端验证
const verifyRes = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, signature }),
});
if (verifyRes.ok) {
// 登录成功,后端返回一个 session token
const { token } = await verifyRes.json();
localStorage.setItem('auth_token', token);
}
};
return <button onClick={handleLogin}>Sign in with Ethereum</button>;
}
这里有个坑 :domain 字段必须精确匹配。我当时在本地开发时用的是 localhost:3000,但部署后域名变成了 app.example.com,结果生产环境一直报 Domain mismatch。后来我把 domain 从 window.location.host 获取,问题解决。
第二步:后端验证签名并创建 session
后端我用 Node.js + Express 实现,使用 siwe 包进行验证。验证通过后,我生成一个 JWT token 返回给前端,后续的 API 请求都带上这个 token。
typescript
// 后端:验证签名
import { SiweMessage } from 'siwe';
import express from 'express';
const app = express();
app.use(express.json());
// 存储 nonce(生产环境应该用 Redis)
const nonceStore: Set<string> = new Set();
// 生成 nonce 接口
app.get('/api/auth/nonce', (req, res) => {
const nonce = generateRandomNonce(); // 使用 crypto.randomBytes 生成
nonceStore.add(nonce);
// 设置过期时间,比如 5 分钟
setTimeout(() => nonceStore.delete(nonce), 5 * 60 * 1000);
res.json({ nonce });
});
// 验证签名接口
app.post('/api/auth/verify', async (req, res) => {
const { message, signature } = req.body;
try {
const siweMessage = new SiweMessage(message);
const fields = await siweMessage.verify({
signature,
// 这里传入 nonce 是为了验证 nonce 是否有效
nonce: siweMessage.nonce,
// 这里传入 domain 是为了验证域名
domain: siweMessage.domain,
});
// 验证成功后,从存储中删除 nonce,防止重放
nonceStore.delete(siweMessage.nonce);
// 生成 JWT token
const token = jwt.sign(
{ address: fields.data.address, chainId: fields.data.chainId },
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token });
} catch (error) {
console.error('Verification failed:', error);
res.status(401).json({ error: 'Invalid signature' });
}
});
注意这个细节 :siweMessage.verify 方法内部会自动检查 nonce、domain、过期时间等。如果 nonce 已经被使用过(比如重放攻击),就会抛出异常。我一开始没理解这个机制,以为需要手动检查,后来发现包已经帮我做了。
第三步:session 持久化与自动登录
用户每次刷新页面都要重新签名,体验很差。所以我用 JWT token 做 session 持久化。前端在初始化时检查 localStorage 中是否有 token,如果有就自动恢复登录状态。
typescript
// 封装一个 hook 来管理认证状态
import { useAccount, useDisconnect } from 'wagmi';
import { useState, useEffect } from 'react';
export function useAuth() {
const { address, isConnected } = useAccount();
const { disconnect } = useDisconnect();
const [isAuthenticated, setIsAuthenticated] = useState(false);
// 检查是否有有效的 token
useEffect(() => {
const token = localStorage.getItem('auth_token');
if (token && address) {
// 可以验证 token 是否过期(简单做法:解码 payload 检查 exp)
setIsAuthenticated(true);
}
}, [address]);
// 登出
const logout = () => {
localStorage.removeItem('auth_token');
setIsAuthenticated(false);
disconnect();
};
return { isAuthenticated, logout };
}
这里有个坑:JWT token 过期后,用户需要重新签名。我最初没有处理 token 过期的情况,结果用户操作到一半突然报 401 错误,体验非常糟糕。后来我加了一个"静默刷新"机制:在 API 请求拦截器中检查 token 是否即将过期,如果是,就弹出一个轻提示让用户重新签名。
完整代码:一个可运行的 React 组件
下面是一个完整的登录组件,包含签名验证和 session 管理。假设你已经配置好了 wagmi 的 provider。
typescript
// LoginWithSiwe.tsx
import { useState } from 'react';
import { useAccount, useSignMessage, useDisconnect } from 'wagmi';
import { SiweMessage } from 'siwe';
import { useAuth } from './useAuth';
export default function LoginWithSiwe() {
const { address, isConnected, chainId } = useAccount();
const { signMessageAsync } = useSignMessage();
const { disconnect } = useDisconnect();
const { isAuthenticated, logout } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleLogin = async () => {
if (!address || !chainId) {
setError('Please connect your wallet first');
return;
}
setLoading(true);
setError('');
try {
// 1. 获取 nonce
const nonceRes = await fetch('/api/auth/nonce');
const { nonce } = await nonceRes.json();
// 2. 构造 SIWE 消息
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in to access your dashboard.',
uri: window.location.origin,
version: '1',
chainId,
nonce,
issuedAt: new Date().toISOString(),
});
// 3. 签名
const signature = await signMessageAsync({
message: message.prepareMessage(),
});
// 4. 发送给后端验证
const verifyRes = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: message.prepareMessage(),
signature,
}),
});
if (!verifyRes.ok) {
throw new Error('Verification failed');
}
const { token } = await verifyRes.json();
localStorage.setItem('auth_token', token);
// 触发状态更新
window.location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
if (isAuthenticated) {
return (
<div>
<p>Logged in as: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
<button onClick={logout}>Logout</button>
</div>
);
}
return (
<div>
{!isConnected ? (
<p>Please connect your wallet first</p>
) : (
<button onClick={handleLogin} disabled={loading}>
{loading ? 'Signing...' : 'Sign in with Ethereum'}
</button>
)}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
踩坑记录
-
Domain mismatch错误 :在本地开发时,domain 是localhost:3000,但部署到生产环境后,domain 变成了app.example.com。SIWE 验证要求 domain 必须精确匹配前端域名。解决方案:使用window.location.host动态获取 domain。 -
Nonce already used错误:第一次测试时,我在短时间内连续点击登录按钮,结果第二次签名时后端报错。原因是 nonce 只能使用一次,我忘记在验证成功后删除 nonce。解决方案:在验证成功后立即从存储中删除 nonce。 -
签名弹窗不显示 :使用 wagmi 的
useSignMessage时,如果用户已经连接了钱包,但 MetaMask 不弹出签名窗口。后来发现是因为我传入了{ message }而不是{ message: siweMessage.prepareMessage() }。prepareMessage()方法会把结构化消息转成符合 EIP-4361 格式的字符串,MetaMask 才能正确识别。 -
JWT token 过期后用户无感知 :用户登录后,如果 token 过期了,API 请求会返回 401,但前端没有提示。解决方案:在 API 请求拦截器中检查 token 的
exp字段,如果即将过期,提前弹出提示让用户重新签名。
小结
通过 EIP-4361 + SIWE,我成功实现了一套无需密码、基于钱包签名的身份认证方案。核心收获是:不要自己造轮子,社区标准方案(SIWE)已经解决了重放攻击、过期、跨域等问题。如果想继续深挖,可以研究 EIP-4361 的扩展(如 EIP-5573),或者结合 SIWE 实现更细粒度的权限控制。