背景
上个月,我在参与一个Web3内容社区"CryptoPulse"的前端开发。这个社区允许用户发表关于项目的分析、评论,并有一个积分激励系统。一个最基础的需求是:用户必须登录后才能发帖和评论。
一开始,我们沿用了最熟悉的Web2方案:用户连接钱包后,前端将钱包地址发给后端,后端生成一个JWT(JSON Web Token)返回,前端将其存入localStorage或Cookie,后续每次请求都带上这个Token。这个方案跑起来没问题,但总感觉哪里不对劲。产品经理和社区用户都反馈:"我们明明是Web3应用,为什么登录流程和传统网站一样?还要依赖你们服务器的中心化认证?"
问题的核心矛盾在于:在Web3的世界里,身份(钱包地址)和授权(私钥签名)本应是用户自己掌控的。我们后端的JWT签发,本质上又成了一个中心化的"发证机构"。我们需要一种方式,让用户用自己钱包的签名能力,来证明"我就是这个地址的持有者",并且这个证明能被我们的后端验证,同时整个过程不涉及私钥的传输。
问题分析
我的第一反应是:这不就是personal_sign吗?让用户对一段消息签名,后端用ecrecover验证签名和地址是否匹配。但具体到我们的"发帖"场景,需要签名的"消息"是什么?
最初的想法很简单:让用户对固定的消息,比如"Login to CryptoPulse"签名。但这立刻带来了安全问题:签名重用(Replay Attack)。如果攻击者截获了这个签名,他可以在任何时间、任何地点用它来冒充用户。这个签名必须是一次性的、与当前操作上下文绑定的。
那么,把签名和具体的表单数据绑定呢?比如,用户提交一篇包含title和content的帖子时,让用户对 title + content 的字符串签名。这解决了重用问题,但带来了新麻烦:
- 用户体验差:用户每次发帖、评论都要弹一次钱包签名,非常繁琐。
- 数据耦合过紧:如果用户签名后,在请求发送前网络波动导致内容丢失,或者他想稍作修改,整个签名就无效了,需要重签。
- 后端验证逻辑复杂:后端需要完整重构帖子数据来验证签名,任何字段顺序或格式的差异都会导致验证失败。
经过一番搜索和与后端同事的讨论,我们确定了方向:采用 "挑战-响应"(Challenge-Response) 模式,但需要优化。核心思路是:后端生成一个一次性、有时效性的随机字符串(Challenge),前端让用户钱包对其签名,然后将签名和用户地址一起送回后端验证。验证通过后,后端颁发一个短期有效的会话凭证。在凭证有效期内,用户进行发帖、评论等操作不再需要签名。
这样一来,签名的动作从"每次提交表单"前置到了"登录会话建立时",平衡了安全性和用户体验。接下来,就是具体的实现和踩坑之旅了。
核心实现
第一步:设计后端API与前端状态管理
首先,我和后端同学约定好了两个关键接口:
GET /api/auth/challenge:获取挑战码。请求参数为钱包地址address,后端返回一个结构如{ challenge: string, expiresAt: number }的对象。后端会将该挑战码与该地址绑定,并设置一个短的过期时间(如5分钟)。POST /api/auth/verify:验证签名。请求体为{ address: string, signature: string, challenge: string }。验证成功后,后端在响应头设置HttpOnly的Session Cookie(或返回一个短期Token),并返回用户基本信息。
前端的状态管理,我选择用 wagmi + @tanstack/react-query。wagmi 管理钱包连接和签名,react-query 管理异步的认证状态。
typescript
// hooks/useAuth.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useAccount, useSignMessage } from 'wagmi';
import { apiClient } from '../lib/api'; // 封装好的axios实例
export const useAuth = () => {
const { address, isConnected } = useAccount();
const queryClient = useQueryClient();
const { signMessageAsync } = useSignMessage();
// 1. 获取挑战码
const fetchChallenge = useQuery({
queryKey: ['auth-challenge', address],
queryFn: () => apiClient.get(`/auth/challenge?address=${address}`),
enabled: !!address, // 只有连接钱包后才启用
staleTime: 4 * 60 * 1000, // 挑战码4分钟内有效
});
// 2. 验证签名的Mutation
const verifySignature = useMutation({
mutationFn: async (params: { signature: string; challenge: string }) => {
return apiClient.post('/auth/verify', {
address,
signature: params.signature,
challenge: params.challenge,
});
},
onSuccess: () => {
// 验证成功,使所有用户相关查询失效,触发重新获取
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
},
});
// 3. 封装登录动作
const login = async () => {
if (!fetchChallenge.data) {
throw new Error('No challenge available');
}
const challenge = fetchChallenge.data.data.challenge;
// 这里有个坑:一定要让用户知道他在签什么,消息格式要清晰
const signature = await signMessageAsync({
message: `CryptoPulse Login\n\nChallenge: ${challenge}`,
});
await verifySignature.mutateAsync({ signature, challenge });
};
return {
isConnected,
address,
challenge: fetchChallenge.data?.data,
login,
isLoggingIn: verifySignature.isPending,
};
};
第二步:实现签名与消息格式化
签名本身很简单,但消息的格式化是安全性和用户体验的关键 。直接让用户签一串随机字符(挑战码)非常不友好,且容易被钓鱼。最佳实践是遵循 EIP-4361(Sign-In with Ethereum)规范,将消息格式化为人类可读的结构。
由于项目时间紧,我们先实现一个简化但清晰的版本:
typescript
// utils/signMessage.ts
export const formatLoginMessage = (challenge: string, address: string) => {
const domain = window.location.host; // 当前域名
const statement = 'Welcome to CryptoPulse. Click to sign in.';
const uri = window.location.origin;
const version = '1';
const nonce = challenge; // 使用后端下发的挑战码作为nonce
const issuedAt = new Date().toISOString();
return `${statement}\n\n` +
`URI: ${uri}\n` +
`Version: ${version}\n` +
`Chain ID: 1\n` +
`Nonce: ${nonce}\n` +
`Issued At: ${issuedAt}\n` +
`Resources:\n` +
`- https://${domain}`;
};
然后在登录函数中使用它:
typescript
const login = async () => {
if (!challenge || !address) return;
const message = formatLoginMessage(challenge, address);
const signature = await signMessageAsync({ message });
// ... 后续验证
};
这样,用户在MetaMask等钱包里看到的是一个结构清晰、包含我们域名和意图的请求,大大降低了被钓鱼的风险。
第三步:处理钱包连接与登录流程的联动
这里遇到了第一个流程上的坑。最初的逻辑是:用户点击"连接钱包" -> 连接成功 -> 自动触发fetchChallenge -> 自动弹出签名。这导致了糟糕的用户体验,用户连上钱包后还没看清页面,签名请求就弹出来了。
我们调整了流程,将"连接钱包"和"登录认证"解耦:
- "连接钱包"按钮只负责连接。
- 连接成功后,页面上显示一个独立的"登录/签名"按钮。
- 只有用户点击这个按钮,才去获取挑战码并触发签名。
tsx
// components/LoginButton.tsx
import { useAuth } from '../hooks/useAuth';
export const LoginButton = () => {
const { isConnected, address, login, isLoggingIn, challenge } = useAuth();
if (!isConnected) {
return <button onClick={connectWallet}>Connect Wallet</button>;
}
// 连接后,显示登录按钮
return (
<div>
<p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
<button
onClick={login}
disabled={isLoggingIn || !challenge}
>
{isLoggingIn ? 'Signing...' : 'Sign In to Post'}
</button>
{!challenge && <p>Preparing login...</p>}
</div>
);
};
第四步:会话管理与请求拦截
登录成功后,后端通过HttpOnly Cookie管理会话。前端需要知道当前的登录状态以更新UI。我们通过一个简单的 GET /api/auth/me 接口来获取当前用户信息。
typescript
// hooks/useUser.ts
export const useUser = () => {
return useQuery({
queryKey: ['user-profile'],
queryFn: () => apiClient.get('/auth/me'),
retry: false, // 401时不要重试
staleTime: 5 * 60 * 1000, // 5分钟
});
};
然后,在应用的根组件或布局组件中,我们可以根据 useUser 的返回状态来显示不同的UI(如显示用户名或显示登录按钮)。同时,需要在 apiClient(axios实例)中设置请求拦截器,自动处理401未授权错误,比如跳转到登录页或静默刷新Token(如果实现的是Token方案)。
完整代码示例
以下是一个简化但可运行的React组件示例,集成了上述核心逻辑:
tsx
// App.tsx
import { WagmiConfig, createConfig, mainnet } from 'wagmi';
import { createPublicClient, http } from 'viem';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { LoginFlow } from './components/LoginFlow';
const queryClient = new QueryClient();
const config = createConfig({
autoConnect: true,
publicClient: createPublicClient({
chain: mainnet,
transport: http(),
}),
});
function App() {
return (
<WagmiConfig config={config}>
<QueryClientProvider client={queryClient}>
<div className="App">
<h1>CryptoPulse</h1>
<LoginFlow />
</div>
</QueryClientProvider>
</WagmiConfig>
);
}
export default App;
tsx
// components/LoginFlow.tsx
import { useState } from 'react';
import { useAccount, useConnect, useDisconnect, useSignMessage } from 'wagmi';
import { injected } from 'wagmi/connectors';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient, formatLoginMessage } from '../lib';
export const LoginFlow = () => {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const { signMessageAsync } = useSignMessage();
const queryClient = useQueryClient();
// 获取挑战码
const { data: challengeData } = useQuery({
queryKey: ['challenge', address],
queryFn: () => apiClient.get(`/auth/challenge?address=${address}`),
enabled: !!address,
});
// 验证签名
const { mutateAsync: verifySig, isPending: isVerifying } = useMutation({
mutationFn: (data: { signature: string; challenge: string }) =>
apiClient.post('/auth/verify', { address, ...data }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }),
});
// 获取用户信息(代表登录状态)
const { data: user } = useQuery({
queryKey: ['me'],
queryFn: () => apiClient.get('/auth/me'),
});
const handleLogin = async () => {
if (!challengeData?.data?.challenge) return;
const challenge = challengeData.data.challenge;
const message = formatLoginMessage(challenge, address!);
try {
const signature = await signMessageAsync({ message });
await verifySig({ signature, challenge });
} catch (err) {
console.error('Login failed:', err);
}
};
if (user?.data) {
return (
<div>
<p>Welcome, {user.data.username || address?.slice(0, 6)}!</p>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}
if (isConnected) {
return (
<div>
<p>Connected: {address}</p>
<button onClick={handleLogin} disabled={isVerifying || !challengeData}>
{isVerifying ? 'Signing In...' : 'Sign In'}
</button>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}
return (
<button onClick={() => connect({ connector: injected() })}>
Connect Wallet
</button>
);
};
typescript
// lib/index.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.REACT_APP_API_URL,
withCredentials: true, // 重要!允许携带Cookie
});
export const formatLoginMessage = (challenge: string, address: string) => {
return `Welcome to CryptoPulse.\n\n` +
`Sign this message to authenticate.\n` +
`Challenge: ${challenge}\n` +
`Address: ${address}`;
};
踩坑记录
-
"Sign message rejected" 用户拒绝签名 :这是最常见的坑。最初我把获取挑战码和签名做成了自动连续操作,用户连接钱包后立刻弹窗,很多人下意识就拒绝了。解决方案:将连接和登录明确分离,给用户一个明确的"Sign In"按钮,并附上友好的解释文字,告知签名是安全的且不会消耗Gas。
-
跨域(CORS)与Cookie问题 :前端在
localhost:3000,后端API在localhost:8080。即使后端设置了CORS头Access-Control-Allow-Origin: http://localhost:3000和Access-Control-Allow-Credentials: true,前端axios请求如果不设置withCredentials: true,浏览器也不会发送或接收Cookie。解决方案 :确保前后端CORS配置正确,并在前端HTTP客户端中显式开启withCredentials。 -
消息编码与验证失败 :在测试时,后端始终报告签名验证失败。排查后发现,
wagmi/viem的signMessage会对消息进行 EIP-191 标准的预处理(添加\x19Ethereum Signed Message:\n前缀和长度),而我的后端验证库(如ethers.js的verifyMessage)也期望同样的预处理。解决方案 :确保前后端使用同一套消息预处理逻辑。大多数成熟的库(如ethers.verifyMessage,viem的verifyMessage)都默认处理好了,关键在于前端签名和后端验证要使用兼容的库或相同的处理函数。 -
挑战码过期与重试 :用户可能打开页面后很久才点击登录,此时挑战码已过期。最初的处理只是报错,体验不好。解决方案:在登录函数中捕获验证失败的错误,如果错误提示是"挑战码无效或过期",则自动重新获取一次挑战码并让用户重签。但要注意避免无限循环,通常重试一次即可。
小结
这次重构让我深刻体会到,Web3前端开发不仅仅是调用智能合约,更重要的是设计出符合去中心化精神的用户流程。基于签名的身份认证,将信任的锚点从我们的服务器转移到了用户的钱包和区块链上,这才是真正的Web3原生体验。下一步,可以深入研究EIP-4361标准,实现更规范、兼容性更好的"以太坊登录"功能,并考虑如何将这套认证系统扩展到更多链上操作中。