DApp用户认证与会话管理最佳实践:从钱包连接到身份验证

DApp用户认证系统示意图,钱包连接与身份验证的安全交互流程

引言

用户认证是任何应用的基础功能,但在去中心化应用(DApp)世界中,传统的用户名密码认证模式并不适用。DApp依赖区块链钱包作为身份标识,用户通过签名消息来证明身份。这种认证方式带来了新的安全考量,也提出了独特的用户体验挑战。

本文将系统讲解DApp用户认证与会话管理的完整方案,从基础的钱包连接,到高级的多因素认证和会话管理,帮助你构建既安全又流畅的用户认证体验。

Web3认证流程图,展示挑战签名机制与会话令牌管理全链路

一、DApp认证基础概念

1.1 区块链身份与钱包

在DApp中,用户的区块链地址就是其身份标识。与传统应用的账户体系相比,区块链身份有以下特点:

  • 无需注册:用户无需填写表单注册,连接钱包即完成”注册”
  • 自主托管:用户完全控制自己的私钥和应用访问权限
  • 密码学验证:通过签名消息验证身份,无需服务器存储密码

typescript

// 连接钱包获取用户地址
import { ethers } from 'ethers';

async function connectWallet(): Promise<string> {
  // 检查浏览器是否有钱包扩展
  if (typeof window.ethereum !== 'undefined') {
    try {
      // 请求用户授权连接
      const accounts = await window.ethereum.request({
        method: 'eth_requestAccounts'
      });
      
      if (accounts.length > 0) {
        const address = accounts[0];
        console.log('Connected wallet:', address);
        return address;
      }
    } catch (error) {
      console.error('Connection rejected:', error);
      throw new Error('User rejected connection');
    }
  } else {
    throw new Error('No wallet detected');
  }
}

// 获取当前已连接的账户
async function getCurrentAccount(): Promise<string | null> {
  if (typeof window.ethereum !== 'undefined') {
    const accounts = await window.ethereum.request({
      method: 'eth_accounts'
    });
    return accounts.length > 0 ? accounts[0] : null;
  }
  return null;
}

1.2 Web3认证流程

标准的Web3认证流程包括以下步骤:

  1. 连接请求:前端请求用户连接钱包
  2. 签名验证:前端生成随机挑战,用户签名
  3. 服务端验证:服务端验证签名,恢复用户地址
  4. 会话创建:验证通过后创建用户会话

plaintext

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   前端      │     │   钱包       │     │   服务端    │
└──────┬──────┘     └──────┬───────┘     └──────┬──────┘
       │                    │                    │
       │  1. 请求连接       │                    │
       │──────────────────>│                    │
       │                    │                    │
       │  2. 签名挑战       │                    │
       │<──────────────────│                    │
       │                    │                    │
       │  3. 签名结果       │                    │
       │──────────────────>│                    │
       │                    │                    │
       │                    │  4. 转发签名       │
       │                    │──────────────────>│
       │                    │                    │
       │                    │  5. 验证并返回JWT  │
       │                    │<──────────────────│
       │                    │                    │
       │  6. 返回认证令牌   │                    │
       │<────────────────────────────────────────│

二、签名消息认证实现

2.1 服务端挑战生成

服务端需要生成唯一的挑战值,防止重放攻击:

typescript

// 服务端:生成和管理认证挑战
import { randomBytes, createHash } from 'crypto';

interface AuthChallenge {
  challenge: string;
  expiresAt: Date;
  nonce: string;
}

// 内存存储,实际生产应使用Redis
const pendingChallenges = new Map<string, AuthChallenge>();

export class AuthChallengeService {
  // 生成新的挑战
  generateChallenge(address: string): AuthChallenge {
    // 生成随机挑战
    const challenge = this.createChallengeString();
    const nonce = randomBytes(32).toString('hex');
    const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5分钟有效期
    
    const authChallenge: AuthChallenge = {
      challenge,
      expiresAt,
      nonce
    };
    
    // 存储挑战,关联到用户地址
    pendingChallenges.set(address.toLowerCase(), authChallenge);
    
    return authChallenge;
  }
  
  // 创建标准格式的挑战消息
  private createChallengeString(): string {
    const domain = 'your-dapp-domain.com';
    const uri = 'https://your-dapp-domain.com/auth';
    const version = '1';
    const chainId = 1; // Ethereum mainnet
    
    return `Sign this message to authenticate with ${domain}.

This request will not trigger a blockchain transaction or cost any gas fees.

Wallet address:
[Your wallet address]

Nonce: ${randomBytes(16).toString('hex')}

Version: ${version}
Chain ID: ${chainId}
Issued At: ${new Date().toISOString()}
Expiry: ${new Date(Date.now() + 5 * 60 * 1000).toISOString()}`;
  }
  
  // 验证并消费挑战
  async consumeChallenge(
    address: string, 
    signature: string
  ): Promise<{ valid: boolean; error?: string }> {
    const normalizedAddress = address.toLowerCase();
    const challenge = pendingChallenges.get(normalizedAddress);
    
    if (!challenge) {
      return { valid: false, error: 'No pending challenge' };
    }
    
    // 检查过期
    if (new Date() > challenge.expiresAt) {
      pendingChallenges.delete(normalizedAddress);
      return { valid: false, error: 'Challenge expired' };
    }
    
    // 验证签名
    const isValid = await this.verifySignature(
      address,
      challenge.challenge,
      signature
    );
    
    if (isValid) {
      // 消费后删除挑战(防止重放)
      pendingChallenges.delete(normalizedAddress);
      return { valid: true };
    }
    
    return { valid: false, error: 'Invalid signature' };
  }
  
  // 验证ECDSA签名
  private async verifySignature(
    address: string,
    message: string,
    signature: string
  ): Promise<boolean> {
    // 使用ethers.js验证签名
    const recoveredAddress = ethers.utils.verifyMessage(message, signature);
    return recoveredAddress.toLowerCase() === address.toLowerCase();
  }
}

2.2 前端签名流程

typescript

// 前端:发起签名认证请求
import { ethers } from 'ethers';

interface AuthResponse {
  success: boolean;
  token?: string;
  error?: string;
}

interface Challenge {
  challenge: string;
  expiresAt: string;
}

export class Web3AuthService {
  private provider: ethers.providers.Web3Provider | null = null;
  
  // 初始化Provider
  async initializeProvider(): Promise<void> {
    if (typeof window.ethereum !== 'undefined') {
      this.provider = new ethers.providers.Web3Provider(window.ethereum);
    } else {
      throw new Error('No wallet detected');
    }
  }
  
  // 获取挑战
  async requestChallenge(address: string): Promise<Challenge> {
    const response = await fetch('/api/auth/challenge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address })
    });
    
    if (!response.ok) {
      throw new Error('Failed to get challenge');
    }
    
    return response.json();
  }
  
  // 执行签名认证
  async authenticate(): Promise<AuthResponse> {
    if (!this.provider) {
      await this.initializeProvider();
    }
    
    try {
      // 1. 获取签名者地址
      const signer = this.provider!.getSigner();
      const address = await signer.getAddress();
      
      // 2. 请求服务端获取挑战
      const challenge = await this.requestChallenge(address);
      
      // 3. 用户签名挑战
      const signature = await signer.signMessage(challenge.challenge);
      
      // 4. 发送签名到服务端验证
      const verifyResponse = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          address,
          signature,
          challenge: challenge.challenge
        })
      });
      
      const result = await verifyResponse.json();
      
      if (result.success) {
        // 存储认证令牌
        this.storeAuthToken(result.token);
        return { success: true, token: result.token };
      }
      
      return { success: false, error: result.error };
      
    } catch (error) {
      console.error('Authentication failed:', error);
      return { 
        success: false, 
        error: error instanceof Error ? error.message : 'Unknown error' 
      };
    }
  }
  
  // 存储认证令牌
  private storeAuthToken(token: string): void {
    // 使用HttpOnly cookie更安全,这里简化处理
    localStorage.setItem('auth_token', token);
  }
}

三、会话管理方案

3.1 JWT会话令牌

认证通过后,使用JWT管理用户会话:

typescript

// JWT生成与验证
import jwt from 'jsonwebtoken';
import { createHmac } from 'crypto';

interface SessionPayload {
  address: string;
  issuedAt: number;
  expiresAt: number;
  sessionId: string;
}

class SessionManager {
  private readonly secret: string;
  private readonly issuer = 'your-dapp';
  
  constructor() {
    // 生产环境从环境变量获取
    this.secret = process.env.JWT_SECRET || 'your-secret-key';
  }
  
  // 生成JWT
  generateToken(address: string): string {
    const now = Math.floor(Date.now() / 1000);
    
    const payload: SessionPayload = {
      address: address.toLowerCase(),
      issuedAt: now,
      expiresAt: now + 24 * 60 * 60, // 24小时
      sessionId: this.generateSessionId()
    };
    
    return jwt.sign(payload, this.secret, {
      algorithm: 'HS256',
      issuer: this.issuer
    });
  }
  
  // 验证JWT
  verifyToken(token: string): { valid: boolean; payload?: SessionPayload; error?: string } {
    try {
      const payload = jwt.verify(token, this.secret, {
        algorithms: ['HS256'],
        issuer: this.issuer
      }) as SessionPayload;
      
      // 检查是否过期
      const now = Math.floor(Date.now() / 1000);
      if (payload.expiresAt < now) {
        return { valid: false, error: 'Token expired' };
      }
      
      return { valid: true, payload };
      
    } catch (error) {
      return { 
        valid: false, 
        error: error instanceof Error ? error.message : 'Invalid token' 
      };
    }
  }
  
  // 刷新令牌
  refreshToken(oldToken: string): { success: boolean; token?: string; error?: string } {
    const verifyResult = this.verifyToken(oldToken);
    
    if (!verifyResult.valid || !verifyResult.payload) {
      return { success: false, error: verifyResult.error };
    }
    
    // 检查是否在刷新窗口内(过期前2小时内)
    const now = Math.floor(Date.now() / 1000);
    const refreshWindow = verifyResult.payload.expiresAt - 2 * 60 * 60;
    
    if (now < refreshWindow) {
      return { success: false, error: 'Too early to refresh' };
    }
    
    // 生成新令牌
    const newToken = this.generateToken(verifyResult.payload.address);
    return { success: true, token: newToken };
  }
  
  // 撤销令牌
  async revokeToken(token: string): Promise<void> {
    // 将token加入黑名单
    const { payload } = this.verifyToken(token);
    if (payload) {
      await this.addToBlacklist(payload.sessionId, payload.expiresAt);
    }
  }
  
  // 生成会话ID
  private generateSessionId(): string {
    return createHmac('sha256', this.secret)
      .update(Math.random().toString())
      .digest('hex');
  }
  
  // 黑名单管理(使用Redis)
  private async addToBlacklist(sessionId: string, expiresAt: number): Promise<void> {
    // TTL设为token的剩余有效期
    const ttl = expiresAt - Math.floor(Date.now() / 1000);
    // redis.setex(`blacklist:${sessionId}`, ttl, 'revoked');
    console.log(`Blacklisting session ${sessionId} for ${ttl}s`);
  }
}

3.2 安全的会话存储

typescript

// 前端会话管理
class SessionStorage {
  private readonly TOKEN_KEY = 'web3_session';
  private readonly ADDRESS_KEY = 'web3_address';
  private refreshTimer: NodeJS.Timeout | null = null;
  
  // 保存会话
  saveSession(token: string, address: string): void {
    const session = {
      token,
      address,
      createdAt: Date.now()
    };
    
    localStorage.setItem(this.TOKEN_KEY, JSON.stringify(session));
    localStorage.setItem(this.ADDRESS_KEY, address);
    
    // 设置自动刷新
    this.scheduleRefresh(token);
  }
  
  // 获取当前会话
  getSession(): { token: string; address: string } | null {
    const stored = localStorage.getItem(this.TOKEN_KEY);
    if (!stored) return null;
    
    try {
      const session = JSON.parse(stored);
      return {
        token: session.token,
        address: session.address
      };
    } catch {
      this.clearSession();
      return null;
    }
  }
  
  // 检查会话是否有效
  async isSessionValid(): Promise<boolean> {
    const session = this.getSession();
    if (!session) return false;
    
    try {
      const response = await fetch('/api/auth/validate', {
        headers: {
          'Authorization': `Bearer ${session.token}`
        }
      });
      
      return response.ok;
    } catch {
      return false;
    }
  }
  
  // 清除会话
  clearSession(): void {
    localStorage.removeItem(this.TOKEN_KEY);
    localStorage.removeItem(this.ADDRESS_KEY);
    
    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer);
      this.refreshTimer = null;
    }
  }
  
  // 定期刷新令牌
  private scheduleRefresh(token: string): void {
    // 在过期前1小时刷新
    const refreshTime = 60 * 60 * 1000; // 1小时
    const tokenData = this.parseJwt(token);
    
    if (tokenData && tokenData.exp) {
      const expiresIn = tokenData.exp * 1000 - Date.now();
      const timeUntilRefresh = expiresIn - refreshTime;
      
      if (timeUntilRefresh > 0) {
        this.refreshTimer = setTimeout(
          () => this.refreshSession(),
          timeUntilRefresh
        );
      }
    }
  }
  
  // 刷新会话
  private async refreshSession(): Promise<void> {
    const session = this.getSession();
    if (!session) return;
    
    try {
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${session.token}`
        }
      });
      
      if (response.ok) {
        const { token } = await response.json();
        this.saveSession(token, session.address);
      } else {
        this.clearSession();
        // 触发重新认证
        window.dispatchEvent(new CustomEvent('auth:required'));
      }
    } catch {
      this.clearSession();
    }
  }
  
  // 解析JWT(不验证)
  private parseJwt(token: string): any {
    try {
      const base64 = token.split('.')[1];
      return JSON.parse(atob(base64));
    } catch {
      return null;
    }
  }
}

四、React中的认证实现

4.1 认证上下文

tsx

// React认证上下文
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

interface User {
  address: string;
  isConnected: boolean;
  isAuthenticated: boolean;
}

interface AuthContextValue {
  user: User | null;
  isLoading: boolean;
  error: string | null;
  connect: () => Promise<void>;
  disconnect: () => void;
  authenticate: () => Promise<boolean>;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  
  // 初始化检测
  useEffect(() => {
    const initAuth = async () => {
      if (typeof window.ethereum !== 'undefined') {
        const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
        setProvider(web3Provider);
        
        // 检查已连接的账户
        try {
          const accounts = await window.ethereum.request({
            method: 'eth_accounts'
          });
          
          if (accounts.length > 0) {
            const address = accounts[0];
            const token = localStorage.getItem('auth_token');
            
            // 验证现有令牌
            if (token) {
              const isValid = await validateToken(token);
              if (isValid) {
                setUser({
                  address,
                  isConnected: true,
                  isAuthenticated: true
                });
              } else {
                localStorage.removeItem('auth_token');
                setUser({
                  address,
                  isConnected: true,
                  isAuthenticated: false
                });
              }
            } else {
              setUser({
                address,
                isConnected: true,
                isAuthenticated: false
              });
            }
          }
        } catch (err) {
          console.error('Init auth error:', err);
        }
      }
      
      setIsLoading(false);
    };
    
    initAuth();
    
    // 监听账户变化
    if (typeof window.ethereum !== 'undefined') {
      window.ethereum.on('accountsChanged', handleAccountsChanged);
      window.ethereum.on('chainChanged', handleChainChanged);
    }
    
    return () => {
      if (typeof window.ethereum !== 'undefined') {
        window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
        window.ethereum.removeListener('chainChanged', handleChainChanged);
      }
    };
  }, []);
  
  const handleAccountsChanged = useCallback((accounts: string[]) => {
    if (accounts.length === 0) {
      setUser(null);
    } else if (user) {
      setUser(prev => prev ? {
        ...prev,
        address: accounts[0],
        isAuthenticated: false
      } : null);
    }
  }, [user]);
  
  const handleChainChanged = useCallback(() => {
    // 链变化后刷新页面
    window.location.reload();
  }, []);
  
  const connect = async () => {
    if (!provider) {
      setError('No wallet detected');
      return;
    }
    
    setIsLoading(true);
    setError(null);
    
    try {
      await provider.send('eth_requestAccounts', []);
      const accounts = await provider.listAccounts();
      
      if (accounts.length > 0) {
        setUser({
          address: accounts[0].address,
          isConnected: true,
          isAuthenticated: false
        });
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Connection failed');
    } finally {
      setIsLoading(false);
    }
  };
  
  const disconnect = () => {
    localStorage.removeItem('auth_token');
    setUser(null);
  };
  
  const authenticate = async (): Promise<boolean> => {
    if (!provider || !user) return false;
    
    setIsLoading(true);
    setError(null);
    
    try {
      const signer = provider.getSigner();
      const address = await signer.getAddress();
      
      // 1. 获取挑战
      const challengeRes = await fetch('/api/auth/challenge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ address })
      });
      
      if (!challengeRes.ok) throw new Error('Failed to get challenge');
      
      const { challenge } = await challengeRes.json();
      
      // 2. 签名
      const signature = await signer.signMessage(challenge);
      
      // 3. 验证
      const verifyRes = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ address, signature, challenge })
      });
      
      if (!verifyRes.ok) throw new Error('Verification failed');
      
      const { token } = await verifyRes.json();
      localStorage.setItem('auth_token', token);
      
      setUser(prev => prev ? {
        ...prev,
        isAuthenticated: true
      } : null);
      
      return true;
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Authentication failed');
      return false;
    } finally {
      setIsLoading(false);
    }
  };
  
  const validateToken = async (token: string): Promise<boolean> => {
    try {
      const res = await fetch('/api/auth/validate', {
        headers: { 'Authorization': `Bearer ${token}` }
      });
      return res.ok;
    } catch {
      return false;
    }
  };
  
  return (
    <AuthContext.Provider value={{
      user,
      isLoading,
      error,
      connect,
      disconnect,
      authenticate
    }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

4.2 认证组件

tsx

// 连接钱包按钮组件
import { useAuth } from './AuthContext';
import { Button } from './ui/Button';

export function WalletConnectButton() {
  const { user, isLoading, error, connect, authenticate } = useAuth();
  
  if (isLoading) {
    return (
      <Button disabled>
        <span className="animate-spin mr-2">⏳</span>
        Loading...
      </Button>
    );
  }
  
  if (!user?.isConnected) {
    return (
      <Button onClick={connect}>
        Connect Wallet
      </Button>
    );
  }
  
  if (!user.isAuthenticated) {
    return (
      <Button onClick={authenticate}>
        Sign In
      </Button>
    );
  }
  
  return (
    <div className="flex items-center gap-3">
      <span className="text-sm text-gray-400">
        {user.address.slice(0, 6)}...{user.address.slice(-4)}
      </span>
      <Button variant="ghost">
        Disconnect
      </Button>
    </div>
  );
}

五、安全最佳实践

5.1 防止常见攻击

typescript

// 安全检查工具
export class SecurityUtils {
  // 验证地址格式
  static isValidAddress(address: string): boolean {
    return /^0x[a-fA-F0-9]{40}$/.test(address);
  }
  
  // 验证签名格式
  static isValidSignature(signature: string): boolean {
    return /^0x[a-fA-F0-9]{130}$/.test(signature);
  }
  
  // 防止重放攻击:检查nonce
  static async checkNonce(address: string): Promise<boolean> {
    const response = await fetch(`/api/auth/nonce/${address}`);
    const { used } = await response.json();
    return !used;
  }
  
  // 检测钓鱼风险
  static checkDomainMismatch(domain: string): boolean {
    const expectedDomain = process.env.REACT_APP_DOMAIN;
    return domain !== expectedDomain;
  }
  
  // 签名消息时显示清晰警告
  static getSignMessageWarning(domain: string): string {
    return `
⚠️ Security Notice:

Before signing, please verify:
1. URL is correct: https://${domain}
2. You are not on a phishing site
3. The message doesn't request token transfers

This signature will NOT trigger any blockchain transaction.
    `.trim();
  }
}

5.2 认证中间件

typescript

// Express认证中间件
import { Request, Response, NextFunction } from 'express';
import { SessionManager } from './SessionManager';

const sessionManager = new SessionManager();

export interface AuthenticatedRequest extends Request {
  user?: {
    address: string;
    sessionId: string;
  };
}

export function authMiddleware(
  req: AuthenticatedRequest, 
  res: Response, 
  next: NextFunction
): void {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'No token provided' });
    return;
  }
  
  const token = authHeader.substring(7);
  const result = sessionManager.verifyToken(token);
  
  if (!result.valid || !result.payload) {
    res.status(401).json({ error: result.error });
    return;
  }
  
  req.user = {
    address: result.payload.address,
    sessionId: result.payload.sessionId
  };
  
  next();
}

// 可选认证中间件(不强制要求登录)
export function optionalAuth(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
): void {
  const authHeader = req.headers.authorization;
  
  if (!authHeader?.startsWith('Bearer ')) {
    next();
    return;
  }
  
  const token = authHeader.substring(7);
  const result = sessionManager.verifyToken(token);
  
  if (result.valid && result.payload) {
    req.user = {
      address: result.payload.address,
      sessionId: result.payload.sessionId
    };
  }
  
  next();
}

六、总结

本文系统讲解了DApp用户认证与会话管理的完整解决方案:

  1. 认证基础:理解了区块链身份与钱包的关系
  2. 签名认证:掌握了基于签名消息的认证流程
  3. 会话管理:学会了JWT令牌的生成、验证和刷新
  4. React集成:完成了认证上下文和组件的实现
  5. 安全实践:了解了常见攻击防护和最佳实践

一个好的DApp认证系统应该在安全性和用户体验之间找到平衡。记住以下原则:

  • 始终使用挑战-响应的认证模式
  • JWT令牌要有合理的过期时间
  • 前端要处理好链变化和账户切换
  • 对敏感操作考虑二次验证

相关推荐

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注