从“签名即密码”到安全登录:我在Web3项目中实现表单签名验证的完整踩坑记录

3 阅读1分钟

背景

上个月,我在参与一个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”,签名永远有效。

我意识到需要解决两个核心问题:

  1. 签名消息需要包含防重放攻击的机制
  2. 需要统一签名的格式,让后端好处理

核心实现

第一步:设计防重放攻击的签名消息

防重放攻击的常见做法是加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.jssigner.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 };
};

注意这个细节etherssignMessage会自动在消息前添加\x19Ethereum Signed Message:\n前缀和消息长度。这意味着后端验证时也必须用同样的方式重建这个消息,否则ecrecover会得到错误的地址。很多验证失败都是因为前后端消息重建不一致造成的。

第三步:与后端API的完整交互流程

有了签名能力,还需要设计完整的前后端交互流程:

  1. 用户点击“登录”按钮
  2. 前端向后端请求一个nonce(关联到用户地址)
  3. 前端让用户签名(包含这个nonce
  4. 前端将签名、原始消息、地址发送到后端验证
  5. 后端验证通过后返回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>
//   );
// }

踩坑记录

在实际开发中,我遇到了几个典型的坑:

  1. 消息格式不一致导致验证失败

    • 现象:前端显示签名成功,但后端总是验证失败
    • 原因:我用了模板字符串的换行,但有些钱包在显示签名消息时把\n渲染成了空格。用户实际签的消息和前端传给后端验证的消息不一致。
    • 解决:统一使用显式的\n字符,并确保前后端用完全相同的方式构造消息字符串。
  2. 用户切换钱包账户后签名无效

    • 现象:用户连接了钱包A,获取了nonce,然后切换到钱包B签名,后端验证失败
    • 原因:nonce是和地址绑定的,切换地址后应该重新获取nonce
    • 解决:监听钱包的accountsChanged事件,当检测到地址变化时,清除本地的nonce并提示用户重新登录。
  3. 签名对话框不弹出

    • 现象:在移动端某些浏览器中,signMessage调用后钱包应用没有弹出签名请求
    • 原因:有些钱包对移动浏览器的支持不完善,或者需要特定的触发方式
    • 解决:确保所有钱包操作都在用户明确的点击事件中触发(不能是onLoadsetTimeout),并添加fallback提示引导用户手动打开钱包应用。
  4. 签名后的消息被篡改

    • 现象:在发送到后端的过程中,签名数据被中间代理修改(特别是开发环境)
    • 原因:某些开发工具或代理会“美化”JSON数据,修改空格或换行符
    • 解决:对敏感数据(特别是消息原文)进行Base64编码后再传输,或者计算消息的哈希值一起传输供后端验证完整性。

小结

通过这次实战,我深刻理解了“签名即身份”在Web3前端中的实现细节。核心收获是:签名验证不仅仅是调用signMessage那么简单,需要考虑防重放、错误处理、用户体验等完整流程。这个方案现在已经在生产环境稳定运行,为用户提供了无缝的Web3登录体验。后续还可以探索EIP-712结构化签名,让用户在钱包里看到更友好的签名界面。