背景
上个月,我在参与一个NFT社区平台的前端重构。这个平台有个需求:用户除了连接钱包查看NFT外,还需要在链下完成一些操作,比如在论坛发帖、修改个人资料、参与社区投票。这些数据都存在我们的中心化数据库里,但我们需要一个安全的方式来验证“这个操作确实是这个钱包地址的主人发起的”。
传统的Web2做法很简单:用户注册个账号密码,每次操作后端用Session或JWT验证一下就行。但这是Web3项目,用户已经用钱包连接了,再让他们记一套密码体验太差,也不符合“钱包即身份”的哲学。产品经理提了个需求:“能不能让用户签个名就当登录了?”
我当时第一反应是:这想法不错啊!用钱包对一条特定消息签名,后端用ecrecover验证签名是否来自声称的地址,不就能证明用户拥有私钥了吗?听起来比密码安全,还不用存密码哈希。但真动手做起来,才发现里面坑不少。
问题分析
我最开始的思路特别简单:前端让用户签个名,比如签“Login to MyApp”这个字符串,然后把签名和地址一起发给后端,后端验证一下,通过就发个Token。
我快速用ethers.js写了个原型:
// 第一版原型代码
const signMessage = async () => {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const message = "Login to MyApp";
const signature = await signer.signMessage(message);
const address = await signer.getAddress();
// 发送到后端
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ address, signature, message })
});
};
测试的时候发现第一个问题:签名是成功了,但不同钱包签出来的格式不一样。MetaMask返回的签名是65字节的hex字符串,但有些硬件钱包或者别的库可能返回{r, s, v}对象。后端验证的时候得处理多种格式,太麻烦。
更严重的问题是:这个签名可以被重放攻击。如果黑客截获了这次请求的{address, signature, message},他可以直接用这个数据冒充用户登录,因为消息内容永远都是“Login to MyApp”,签名永远有效。
我意识到需要解决两个核心问题:
- 签名消息需要包含防重放攻击的机制
- 需要统一签名的格式,让后端好处理
核心实现
第一步:设计防重放攻击的签名消息
防重放攻击的常见做法是加nonce(一次性随机数)和时间戳。我参考了EIP-191和EIP-712的标准思路,决定采用这样的消息格式:
请登录MyApp。
唯一标识:0x123...abc
时间:2024-01-15T10:30:00Z
这里的“唯一标识”由后端生成,每次登录请求前,前端先向后端要一个nonce。后端把这个nonce和用户地址绑定,验证成功后立即作废。这样即使签名被截获,也因为nonce失效而无法重放。
时间戳的作用是设置签名的有效期,比如只允许5分钟内的签名,防止很久以前的签名被利用。
我写了个生成消息的辅助函数:
// utils/signMessage.ts
interface LoginMessageParams {
nonce: string;
issuedAt?: Date;
}
export const generateLoginMessage = ({
nonce,
issuedAt = new Date()
}: LoginMessageParams): string => {
return `
请登录MyApp。
唯一标识:${nonce}
时间:${issuedAt.toISOString()}
`;
};
这里有个坑:消息的格式一定要固定,换行符、空格都要严格一致。我一开始用模板字符串,换行符是\n,但后来发现有些钱包显示的时候会把\n渲染成空格,导致用户看到的消息和实际签的消息不一致,验证就会失败。所以最好用明确的\n而不是模板字符串的换行。
第二步:统一签名处理与错误捕获
不同钱包的签名行为不一样,需要统一处理。我用ethers.js的signer.signMessage(),它会自动处理EIP-191的\x19Ethereum Signed Message:\n前缀,并返回标准的65字节签名。
但这里又遇到一个实际项目中的问题:用户可能拒绝签名或者钱包出错。需要完善的错误处理:
// hooks/useSignLogin.ts
import { ethers } from 'ethers';
import { useCallback } from 'react';
export const useSignLogin = () => {
const signLoginMessage = useCallback(async (
nonce: string
): Promise<{ signature: string; message: string; address: string } | null> => {
try {
if (!window.ethereum) {
throw new Error('请安装钱包插件');
}
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const address = await signer.getAddress();
// 生成标准格式的消息
const message = generateLoginMessage({ nonce });
// 这里ethers会自动添加EIP-191前缀
const signature = await signer.signMessage(message);
return { signature, message, address };
} catch (error: any) {
// 区分用户拒绝签名和其他错误
if (error.code === 4001) {
console.log('用户拒绝了签名请求');
return null;
}
if (error.code === -32603) {
console.error('钱包内部错误,可能是网络问题');
throw new Error('钱包签名失败,请检查网络');
}
// 其他未知错误
console.error('签名过程中出现未知错误:', error);
throw error;
}
}, []);
return { signLoginMessage };
};
注意这个细节:ethers的signMessage会自动在消息前添加\x19Ethereum Signed Message:\n前缀和消息长度。这意味着后端验证时也必须用同样的方式重建这个消息,否则ecrecover会得到错误的地址。很多验证失败都是因为前后端消息重建不一致造成的。
第三步:与后端API的完整交互流程
有了签名能力,还需要设计完整的前后端交互流程:
- 用户点击“登录”按钮
- 前端向后端请求一个
nonce(关联到用户地址) - 前端让用户签名(包含这个
nonce) - 前端将签名、原始消息、地址发送到后端验证
- 后端验证通过后返回Token,前端存储Token用于后续API调用
我封装了一个完整的登录Hook:
// hooks/useWeb3Login.ts
import { useState } from 'react';
import { useSignLogin } from './useSignLogin';
export const useWeb3Login = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { signLoginMessage } = useSignLogin();
const login = async () => {
setIsLoading(true);
setError(null);
try {
// 1. 获取nonce
const nonceResponse = await fetch('/api/auth/nonce');
if (!nonceResponse.ok) {
throw new Error('获取登录凭证失败');
}
const { nonce } = await nonceResponse.json();
// 2. 签名
const signResult = await signLoginMessage(nonce);
if (!signResult) {
// 用户取消了签名
setIsLoading(false);
return { success: false, cancelled: true };
}
const { signature, message, address } = signResult;
// 3. 验证签名
const verifyResponse = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, signature, message })
});
if (!verifyResponse.ok) {
const errorData = await verifyResponse.json();
throw new Error(errorData.message || '登录验证失败');
}
const { token, user } = await verifyResponse.json();
// 4. 存储token(这里用localStorage,实际项目可能用httpOnly cookie更安全)
localStorage.setItem('auth_token', token);
return { success: true, user };
} catch (err: any) {
setError(err.message || '登录过程中出现错误');
return { success: false, error: err.message };
} finally {
setIsLoading(false);
}
};
return { login, isLoading, error };
};
第四步:集成到React组件中
最后,我把这个Hook集成到实际的登录组件中:
// components/Web3LoginButton.tsx
import React from 'react';
import { useWeb3Login } from '../hooks/useWeb3Login';
export const Web3LoginButton: React.FC = () => {
const { login, isLoading, error } = useWeb3Login();
const handleLogin = async () => {
const result = await login();
if (result.success) {
console.log('登录成功!', result.user);
// 这里可以跳转页面或更新全局状态
window.location.href = '/dashboard';
} else if (result.cancelled) {
console.log('用户取消了登录');
} else {
// 错误已经在hook中处理了,这里可以做一些UI反馈
console.error('登录失败:', result.error);
}
};
return (
<div>
<button
onClick={handleLogin}
disabled={isLoading}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? '签名中...' : '使用钱包登录'}
</button>
{error && (
<div className="mt-2 text-red-600 text-sm">
登录失败: {error}
</div>
)}
</div>
);
};
完整代码
下面是一个完整可运行的示例,包含所有关键部分:
// 完整实现:Web3表单签名验证
// 文件结构:
// - utils/signMessage.ts
// - hooks/useSignLogin.ts
// - hooks/useWeb3Login.ts
// - components/Web3LoginButton.tsx
// utils/signMessage.ts
export interface LoginMessageParams {
nonce: string;
issuedAt?: Date;
}
export const generateLoginMessage = ({
nonce,
issuedAt = new Date()
}: LoginMessageParams): string => {
// 注意:这里用显式的 \n 而不是模板字符串换行
// 确保在所有钱包中显示一致
return `请登录MyApp。\n唯一标识:${nonce}\n时间:${issuedAt.toISOString()}`;
};
// hooks/useSignLogin.ts
import { ethers } from 'ethers';
import { useCallback } from 'react';
import { generateLoginMessage } from '../utils/signMessage';
export const useSignLogin = () => {
const signLoginMessage = useCallback(async (
nonce: string
): Promise<{ signature: string; message: string; address: string } | null> => {
try {
// 检查钱包是否可用
if (!window.ethereum) {
throw new Error('请安装MetaMask或其他EVM兼容钱包');
}
// 请求账户访问权限(如果还没连接)
await window.ethereum.request({ method: 'eth_requestAccounts' });
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const address = await signer.getAddress();
// 生成签名消息
const message = generateLoginMessage({ nonce });
console.log('签名消息内容:', message);
console.log('消息长度:', message.length);
// 使用ethers进行签名(会自动添加EIP-191前缀)
const signature = await signer.signMessage(message);
console.log('生成的签名:', signature);
console.log('签名长度:', signature.length); // 应该是130个字符(65字节的hex)
return { signature, message, address };
} catch (error: any) {
// 处理用户拒绝签名
if (error.code === 4001 || error.message?.includes('rejected')) {
console.warn('用户拒绝了签名请求');
return null;
}
// 处理钱包未连接
if (error.code === -32002) {
throw new Error('请先解锁钱包并连接账户');
}
// 处理网络错误
if (error.code === 'NETWORK_ERROR' || error.code === -32603) {
throw new Error('网络错误,请检查钱包连接');
}
// 其他错误
console.error('签名错误详情:', error);
throw new Error(`签名失败: ${error.message || '未知错误'}`);
}
}, []);
return { signLoginMessage };
};
// hooks/useWeb3Login.ts
import { useState } from 'react';
import { useSignLogin } from './useSignLogin';
export const useWeb3Login = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { signLoginMessage } = useSignLogin();
const login = async () => {
setIsLoading(true);
setError(null);
try {
// 步骤1: 从后端获取nonce
// 注意:实际项目中应该把当前地址传给后端,让后端生成关联的nonce
const nonceResponse = await fetch('/api/auth/nonce', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include' // 如果需要cookie
});
if (!nonceResponse.ok) {
const text = await nonceResponse.text();
throw new Error(`获取nonce失败: ${nonceResponse.status} ${text}`);
}
const nonceData = await nonceResponse.json();
const { nonce } = nonceData;
if (!nonce) {
throw new Error('服务器返回的nonce无效');
}
// 步骤2: 让用户签名
const signResult = await signLoginMessage(nonce);
if (!signResult) {
// 用户主动取消了签名
return { success: false, cancelled: true };
}
const { signature, message, address } = signResult;
// 步骤3: 验证签名
const verifyResponse = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address,
signature,
message,
nonce // 把nonce也传回去,方便后端验证
})
});
if (!verifyResponse.ok) {
const errorData = await verifyResponse.json().catch(() => ({}));
throw new Error(errorData.message || `验证失败: ${verifyResponse.status}`);
}
const authData = await verifyResponse.json();
const { token, user, expiresIn } = authData;
// 步骤4: 存储认证信息
// 实际项目中考虑安全性,可能用httpOnly cookie而不是localStorage
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
localStorage.setItem('auth_expires', (Date.now() + expiresIn * 1000).toString());
// 步骤5: 更新应用状态(这里简单reload,实际应该用状态管理)
console.log('登录成功,用户:', user);
return {
success: true,
user,
token
};
} catch (err: any) {
console.error('登录流程错误:', err);
setError(err.message || '登录过程中出现未知错误');
return {
success: false,
error: err.message,
cancelled: false
};
} finally {
setIsLoading(false);
}
};
return { login, isLoading, error, setError };
};
// components/Web3LoginButton.tsx
import React from 'react';
import { useWeb3Login } from '../hooks/useWeb3Login';
interface Web3LoginButtonProps {
onSuccess?: (user: any) => void;
onError?: (error: string) => void;
className?: string;
}
export const Web3LoginButton: React.FC<Web3LoginButtonProps> = ({
onSuccess,
onError,
className = ''
}) => {
const { login, isLoading, error } = useWeb3Login();
const handleClick = async () => {
const result = await login();
if (result.success) {
onSuccess?.(result.user);
} else if (!result.cancelled && result.error) {
onError?.(result.error);
}
};
return (
<div className={`flex flex-col items-center ${className}`}>
<button
onClick={handleClick}
disabled={isLoading}
className={`
px-6 py-3 rounded-lg font-medium transition-all
bg-gradient-to-r from-blue-500 to-purple-600
text-white hover:from-blue-600 hover:to-purple-700
disabled:opacity-50 disabled:cursor-not-allowed
shadow-lg hover:shadow-xl
min-w-[200px]
`}
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
等待钱包确认...
</span>
) : (
'使用钱包登录'
)}
</button>
{error && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg max-w-md">
<p className="text-red-700 text-sm font-medium">登录失败</p>
<p className="text-red-600 text-xs mt-1">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 text-red-600 text-xs underline hover:text-red-800"
>
重试
</button>
</div>
)}
<p className="mt-3 text-gray-500 text-sm text-center max-w-md">
点击按钮后,您的钱包会弹出签名请求。
此签名仅用于验证身份,不会消耗Gas费用。
</p>
</div>
);
};
// 使用示例
// function App() {
// const handleLoginSuccess = (user) => {
// console.log('用户登录成功:', user);
// // 更新全局状态或跳转页面
// };
//
// const handleLoginError = (error) => {
// console.error('登录错误:', error);
// // 显示错误提示
// };
//
// return (
// <div className="min-h-screen flex items-center justify-center">
// <Web3LoginButton
// onSuccess={handleLoginSuccess}
// onError={handleLoginError}
// />
// </div>
// );
// }
踩坑记录
在实际开发中,我遇到了几个典型的坑:
-
消息格式不一致导致验证失败
- 现象:前端显示签名成功,但后端总是验证失败
- 原因:我用了模板字符串的换行,但有些钱包在显示签名消息时把
\n渲染成了空格。用户实际签的消息和前端传给后端验证的消息不一致。 - 解决:统一使用显式的
\n字符,并确保前后端用完全相同的方式构造消息字符串。
-
用户切换钱包账户后签名无效
- 现象:用户连接了钱包A,获取了nonce,然后切换到钱包B签名,后端验证失败
- 原因:nonce是和地址绑定的,切换地址后应该重新获取nonce
- 解决:监听钱包的
accountsChanged事件,当检测到地址变化时,清除本地的nonce并提示用户重新登录。
-
签名对话框不弹出
- 现象:在移动端某些浏览器中,
signMessage调用后钱包应用没有弹出签名请求 - 原因:有些钱包对移动浏览器的支持不完善,或者需要特定的触发方式
- 解决:确保所有钱包操作都在用户明确的点击事件中触发(不能是
onLoad或setTimeout),并添加fallback提示引导用户手动打开钱包应用。
- 现象:在移动端某些浏览器中,
-
签名后的消息被篡改
- 现象:在发送到后端的过程中,签名数据被中间代理修改(特别是开发环境)
- 原因:某些开发工具或代理会“美化”JSON数据,修改空格或换行符
- 解决:对敏感数据(特别是消息原文)进行Base64编码后再传输,或者计算消息的哈希值一起传输供后端验证完整性。
小结
通过这次实战,我深刻理解了“签名即身份”在Web3前端中的实现细节。核心收获是:签名验证不仅仅是调用signMessage那么简单,需要考虑防重放、错误处理、用户体验等完整流程。这个方案现在已经在生产环境稳定运行,为用户提供了无缝的Web3登录体验。后续还可以探索EIP-712结构化签名,让用户在钱包里看到更友好的签名界面。