从“后端验证”到“前端签名”:我在Web3项目中重构用户身份认证的实战记录

37 阅读1分钟

背景

上个月,我在参与一个Web3内容社区“CryptoPulse”的前端开发。这个社区允许用户发表关于项目的分析、评论,并有一个积分激励系统。一个最基础的需求是:用户必须登录后才能发帖和评论。

一开始,我们沿用了最熟悉的Web2方案:用户连接钱包后,前端将钱包地址发给后端,后端生成一个JWT(JSON Web Token)返回,前端将其存入localStorage或Cookie,后续每次请求都带上这个Token。这个方案跑起来没问题,但总感觉哪里不对劲。产品经理和社区用户都反馈:“我们明明是Web3应用,为什么登录流程和传统网站一样?还要依赖你们服务器的中心化认证?”

问题的核心矛盾在于:在Web3的世界里,身份(钱包地址)和授权(私钥签名)本应是用户自己掌控的。我们后端的JWT签发,本质上又成了一个中心化的“发证机构”。我们需要一种方式,让用户用自己钱包的签名能力,来证明“我就是这个地址的持有者”,并且这个证明能被我们的后端验证,同时整个过程不涉及私钥的传输。

问题分析

我的第一反应是:这不就是personal_sign吗?让用户对一段消息签名,后端用ecrecover验证签名和地址是否匹配。但具体到我们的“发帖”场景,需要签名的“消息”是什么?

最初的想法很简单:让用户对固定的消息,比如“Login to CryptoPulse”签名。但这立刻带来了安全问题:签名重用(Replay Attack)。如果攻击者截获了这个签名,他可以在任何时间、任何地点用它来冒充用户。这个签名必须是一次性的、与当前操作上下文绑定的。

那么,把签名和具体的表单数据绑定呢?比如,用户提交一篇包含titlecontent的帖子时,让用户对 title + content 的字符串签名。这解决了重用问题,但带来了新麻烦:

  1. 用户体验差:用户每次发帖、评论都要弹一次钱包签名,非常繁琐。
  2. 数据耦合过紧:如果用户签名后,在请求发送前网络波动导致内容丢失,或者他想稍作修改,整个签名就无效了,需要重签。
  3. 后端验证逻辑复杂:后端需要完整重构帖子数据来验证签名,任何字段顺序或格式的差异都会导致验证失败。

经过一番搜索和与后端同事的讨论,我们确定了方向:采用 “挑战-响应”(Challenge-Response) 模式,但需要优化。核心思路是:后端生成一个一次性、有时效性的随机字符串(Challenge),前端让用户钱包对其签名,然后将签名和用户地址一起送回后端验证。验证通过后,后端颁发一个短期有效的会话凭证。在凭证有效期内,用户进行发帖、评论等操作不再需要签名。

这样一来,签名的动作从“每次提交表单”前置到了“登录会话建立时”,平衡了安全性和用户体验。接下来,就是具体的实现和踩坑之旅了。

核心实现

第一步:设计后端API与前端状态管理

首先,我和后端同学约定好了两个关键接口:

  1. GET /api/auth/challenge:获取挑战码。请求参数为钱包地址address,后端返回一个结构如 { challenge: string, expiresAt: number } 的对象。后端会将该挑战码与该地址绑定,并设置一个短的过期时间(如5分钟)。
  2. POST /api/auth/verify:验证签名。请求体为 { address: string, signature: string, challenge: string }。验证成功后,后端在响应头设置HttpOnly的Session Cookie(或返回一个短期Token),并返回用户基本信息。

前端的状态管理,我选择用 wagmi + @tanstack/react-querywagmi 管理钱包连接和签名,react-query 管理异步的认证状态。

// 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)规范,将消息格式化为人类可读的结构。

由于项目时间紧,我们先实现一个简化但清晰的版本:

// 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}`;
};

然后在登录函数中使用它:

const login = async () => {
  if (!challenge || !address) return;
  const message = formatLoginMessage(challenge, address);
  const signature = await signMessageAsync({ message });
  // ... 后续验证
};

这样,用户在MetaMask等钱包里看到的是一个结构清晰、包含我们域名和意图的请求,大大降低了被钓鱼的风险。

第三步:处理钱包连接与登录流程的联动

这里遇到了第一个流程上的坑。最初的逻辑是:用户点击“连接钱包” -> 连接成功 -> 自动触发fetchChallenge -> 自动弹出签名。这导致了糟糕的用户体验,用户连上钱包后还没看清页面,签名请求就弹出来了。

我们调整了流程,将“连接钱包”和“登录认证”解耦:

  1. “连接钱包”按钮只负责连接。
  2. 连接成功后,页面上显示一个独立的“登录/签名”按钮。
  3. 只有用户点击这个按钮,才去获取挑战码并触发签名。
// 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 接口来获取当前用户信息。

// 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组件示例,集成了上述核心逻辑:

// 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;
// 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>
  );
};
// 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}`;
};

踩坑记录

  1. “Sign message rejected” 用户拒绝签名:这是最常见的坑。最初我把获取挑战码和签名做成了自动连续操作,用户连接钱包后立刻弹窗,很多人下意识就拒绝了。解决方案:将连接和登录明确分离,给用户一个明确的“Sign In”按钮,并附上友好的解释文字,告知签名是安全的且不会消耗Gas。

  2. 跨域(CORS)与Cookie问题:前端在 localhost:3000,后端API在 localhost:8080。即使后端设置了CORS头 Access-Control-Allow-Origin: http://localhost:3000Access-Control-Allow-Credentials: true,前端axios请求如果不设置 withCredentials: true,浏览器也不会发送或接收Cookie。解决方案:确保前后端CORS配置正确,并在前端HTTP客户端中显式开启 withCredentials

  3. 消息编码与验证失败:在测试时,后端始终报告签名验证失败。排查后发现,wagmi/viemsignMessage 会对消息进行 EIP-191 标准的预处理(添加 \x19Ethereum Signed Message:\n 前缀和长度),而我的后端验证库(如ethers.jsverifyMessage)也期望同样的预处理。解决方案:确保前后端使用同一套消息预处理逻辑。大多数成熟的库(如ethers.verifyMessage, viemverifyMessage)都默认处理好了,关键在于前端签名和后端验证要使用兼容的库或相同的处理函数。

  4. 挑战码过期与重试:用户可能打开页面后很久才点击登录,此时挑战码已过期。最初的处理只是报错,体验不好。解决方案:在登录函数中捕获验证失败的错误,如果错误提示是“挑战码无效或过期”,则自动重新获取一次挑战码并让用户重签。但要注意避免无限循环,通常重试一次即可。

小结

这次重构让我深刻体会到,Web3前端开发不仅仅是调用智能合约,更重要的是设计出符合去中心化精神的用户流程。基于签名的身份认证,将信任的锚点从我们的服务器转移到了用户的钱包和区块链上,这才是真正的Web3原生体验。下一步,可以深入研究EIP-4361标准,实现更规范、兼容性更好的“以太坊登录”功能,并考虑如何将这套认证系统扩展到更多链上操作中。