bulletproof-react验证码:图形验证与短信验证

bulletproof-react验证码:图形验证与短信验证

【免费下载链接】bulletproof-react 一个简单、可扩展且功能强大的架构,用于构建生产就绪的 React 应用程序。 【免费下载链接】bulletproof-react 项目地址: https://gitcode.com/GitHub_Trending/bu/bulletproof-react

在现代Web应用中,验证码(CAPTCHA)是保护系统安全、防止恶意攻击的重要防线。本文将深入探讨如何在bulletproof-react架构中实现图形验证码和短信验证码,构建安全可靠的身份验证系统。

验证码的重要性与分类

为什么需要验证码?

验证码主要用于:

  • 防止暴力攻击:限制登录尝试次数
  • 阻止机器人攻击:区分人类用户和自动化脚本
  • 保护敏感操作:如注册、密码重置、支付等
  • 减轻服务器压力:过滤无效请求

验证码类型对比

类型优点缺点适用场景
图形验证码实现简单,成本低用户体验较差,可能被OCR识别一般安全要求的场景
短信验证码安全性高,用户体验好需要短信服务,有成本高安全要求的场景
语音验证码无障碍访问成本较高,延迟较大特殊需求场景
行为验证码用户体验好,安全性高实现复杂,需要第三方服务对用户体验要求高的场景

bulletproof-react验证码架构设计

整体架构图

mermaid

核心模块划分

  1. 验证码服务层:处理验证码生成、验证逻辑
  2. UI组件层:提供可复用的验证码组件
  3. API层:与后端服务通信
  4. 状态管理:管理验证码状态和验证结果

图形验证码实现

服务端验证码生成

// src/lib/captcha.ts
import { createCanvas } from 'canvas';
import { randomBytes } from 'crypto';

export interface CaptchaData {
  text: string;
  data: string;
  expiresAt: Date;
}

export class CaptchaService {
  private static readonly EXPIRY_TIME = 5 * 60 * 1000; // 5分钟
  private static readonly WIDTH = 200;
  private static readonly HEIGHT = 80;

  static generateCaptcha(): CaptchaData {
    const canvas = createCanvas(this.WIDTH, this.HEIGHT);
    const ctx = canvas.getContext('2d');
    
    // 生成随机验证码文本
    const text = this.generateRandomText(6);
    
    // 绘制背景
    ctx.fillStyle = '#f5f5f5';
    ctx.fillRect(0, 0, this.WIDTH, this.HEIGHT);
    
    // 绘制干扰线
    for (let i = 0; i < 10; i++) {
      ctx.strokeStyle = this.getRandomColor();
      ctx.beginPath();
      ctx.moveTo(Math.random() * this.WIDTH, Math.random() * this.HEIGHT);
      ctx.lineTo(Math.random() * this.WIDTH, Math.random() * this.HEIGHT);
      ctx.stroke();
    }
    
    // 绘制验证码文本
    ctx.font = 'bold 40px Arial';
    ctx.fillStyle = '#333';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    
    // 添加文本扭曲效果
    for (let i = 0; i < text.length; i++) {
      const x = 30 + i * 30;
      const y = 40 + Math.random() * 10 - 5;
      const rotation = Math.random() * 0.4 - 0.2;
      
      ctx.save();
      ctx.translate(x, y);
      ctx.rotate(rotation);
      ctx.fillText(text[i], 0, 0);
      ctx.restore();
    }
    
    const data = canvas.toDataURL();
    const expiresAt = new Date(Date.now() + this.EXPIRY_TIME);
    
    return { text, data, expiresAt };
  }

  private static generateRandomText(length: number): string {
    const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    let result = '';
    for (let i = 0; i < length; i++) {
      result += chars[Math.floor(Math.random() * chars.length)];
    }
    return result;
  }

  private static getRandomColor(): string {
    const colors = ['#888', '#999', '#aaa', '#bbb'];
    return colors[Math.floor(Math.random() * colors.length)];
  }
}

客户端图形验证码组件

// src/components/ui/captcha/captcha.tsx
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/form';

interface CaptchaProps {
  onVerify: (captchaText: string) => Promise<boolean>;
  onRefresh: () => Promise<string>;
  className?: string;
}

export const Captcha: React.FC<CaptchaProps> = ({
  onVerify,
  onRefresh,
  className,
}) => {
  const [captchaImage, setCaptchaImage] = useState<string>('');
  const [inputValue, setInputValue] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState('');

  useEffect(() => {
    refreshCaptcha();
  }, []);

  const refreshCaptcha = async () => {
    setIsLoading(true);
    try {
      const newCaptcha = await onRefresh();
      setCaptchaImage(newCaptcha);
      setInputValue('');
      setError('');
    } catch (err) {
      setError('刷新验证码失败');
    } finally {
      setIsLoading(false);
    }
  };

  const handleVerify = async () => {
    if (!inputValue.trim()) {
      setError('请输入验证码');
      return false;
    }

    setIsLoading(true);
    try {
      const isValid = await onVerify(inputValue);
      if (!isValid) {
        setError('验证码错误');
        return false;
      }
      setError('');
      return true;
    } catch (err) {
      setError('验证失败');
      return false;
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className={`space-y-3 ${className}`}>
      <div className="flex items-center space-x-3">
        {captchaImage && (
          <img
            src={captchaImage}
            alt="验证码"
            className="h-12 border rounded-md"
            onClick={refreshCaptcha}
          />
        )}
        <Button
          type="button"
          variant="outline"
          onClick={refreshCaptcha}
          disabled={isLoading}
          className="h-12"
        >
          刷新
        </Button>
      </div>
      
      <Input
        type="text"
        placeholder="请输入验证码"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        error={error}
        disabled={isLoading}
      />
    </div>
  );
};

短信验证码实现

短信服务集成

// src/lib/sms-service.ts
import { z } from 'zod';

export const smsConfigSchema = z.object({
  provider: z.enum(['aliyun', 'tencent', 'custom']),
  apiKeyId: z.string(),
  apiKeySecret: z.string(),
  signName: z.string(),
  templateCode: z.string(),
  endpoint: z.string().optional(),
});

export type SmsConfig = z.infer<typeof smsConfigSchema>;

export interface SmsResponse {
  success: boolean;
  messageId?: string;
  error?: string;
}

export class SmsService {
  private config: SmsConfig;

  constructor(config: SmsConfig) {
    this.config = smsConfigSchema.parse(config);
  }

  async sendVerificationCode(phoneNumber: string, code: string): Promise<SmsResponse> {
    try {
      switch (this.config.provider) {
        case 'aliyun':
          return await this.sendWithAliyun(phoneNumber, code);
        case 'tencent':
          return await this.sendWithTencent(phoneNumber, code);
        case 'custom':
          return await this.sendWithCustom(phoneNumber, code);
        default:
          throw new Error('不支持的短信服务商');
      }
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : '发送短信失败',
      };
    }
  }

  private async sendWithAliyun(phoneNumber: string, code: string): Promise<SmsResponse> {
    // 阿里云短信服务实现
    const params = {
      PhoneNumbers: phoneNumber,
      SignName: this.config.signName,
      TemplateCode: this.config.templateCode,
      TemplateParam: JSON.stringify({ code }),
    };

    // 实际调用阿里云SDK
    return { success: true, messageId: `aliyun_${Date.now()}` };
  }

  private async sendWithTencent(phoneNumber: string, code: string): Promise<SmsResponse> {
    // 腾讯云短信服务实现
    return { success: true, messageId: `tencent_${Date.now()}` };
  }

  private async sendWithCustom(phoneNumber: string, code: string): Promise<SmsResponse> {
    // 自定义短信服务实现
    return { success: true, messageId: `custom_${Date.now()}` };
  }
}

短信验证码管理

// src/lib/verification-code.ts
import { randomInt } from 'crypto';

export interface VerificationCode {
  code: string;
  phoneNumber: string;
  expiresAt: Date;
  attempts: number;
  verified: boolean;
}

export class VerificationCodeService {
  private static readonly EXPIRY_TIME = 10 * 60 * 1000; // 10分钟
  private static readonly MAX_ATTEMPTS = 5;
  private static readonly CODE_LENGTH = 6;

  private codes: Map<string, VerificationCode> = new Map();

  generateCode(phoneNumber: string): VerificationCode {
    const code = this.generateRandomCode();
    const expiresAt = new Date(Date.now() + VerificationCodeService.EXPIRY_TIME);
    
    const verificationCode: VerificationCode = {
      code,
      phoneNumber,
      expiresAt,
      attempts: 0,
      verified: false,
    };

    this.codes.set(phoneNumber, verificationCode);
    
    // 清理过期验证码
    this.cleanupExpiredCodes();

    return verificationCode;
  }

  verifyCode(phoneNumber: string, inputCode: string): boolean {
    const storedCode = this.codes.get(phoneNumber);
    
    if (!storedCode || storedCode.verified) {
      return false;
    }

    if (Date.now() > storedCode.expiresAt.getTime()) {
      this.codes.delete(phoneNumber);
      return false;
    }

    if (storedCode.attempts >= VerificationCodeService.MAX_ATTEMPTS) {
      this.codes.delete(phoneNumber);
      return false;
    }

    storedCode.attempts++;

    if (storedCode.code === inputCode) {
      storedCode.verified = true;
      return true;
    }

    return false;
  }

  getRemainingAttempts(phoneNumber: string): number {
    const storedCode = this.codes.get(phoneNumber);
    if (!storedCode) return 0;
    return Math.max(0, VerificationCodeService.MAX_ATTEMPTS - storedCode.attempts);
  }

  private generateRandomCode(): string {
    return randomInt(0, 10 ** VerificationCodeService.CODE_LENGTH)
      .toString()
      .padStart(VerificationCodeService.CODE_LENGTH, '0');
  }

  private cleanupExpiredCodes(): void {
    const now = Date.now();
    for (const [phoneNumber, code] of this.codes.entries()) {
      if (now > code.expiresAt.getTime()) {
        this.codes.delete(phoneNumber);
      }
    }
  }
}

短信验证码组件

// src/components/ui/sms-verification/sms-verification.tsx
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/form';

interface SmsVerificationProps {
  phoneNumber: string;
  onSendCode: (phone: string) => Promise<boolean>;
  onVerify: (phone: string, code: string) => Promise<boolean>;
  cooldown?: number;
  className?: string;
}

export const SmsVerification: React.FC<SmsVerificationProps> = ({
  phoneNumber,
  onSendCode,
  onVerify,
  cooldown = 60,
  className,
}) => {
  const [code, setCode] = useState('');
  const [countdown, setCountdown] = useState(0);
  const [isSending, setIsSending] = useState(false);
  const [isVerifying, setIsVerifying] = useState(false);
  const [error, setError] = useState('');

  useEffect(() => {
    let timer: NodeJS.Timeout;
    if (countdown > 0) {
      timer = setTimeout(() setCountdown(countdown - 1), 1000);
    }
    return () => clearTimeout(timer);
  }, [countdown]);

  const handleSendCode = async () => {
    if (!phoneNumber) {
      setError('请输入手机号码');
      return;
    }

    setIsSending(true);
    setError('');
    
    try {
      const success = await onSendCode(phoneNumber);
      if (success) {
        setCountdown(cooldown);
      } else {
        setError('发送验证码失败');
      }
    } catch (err) {
      setError('发送验证码失败');
    } finally {
      setIsSending(false);
    }
  };

  const handleVerify = async () => {
    if (!code.trim()) {
      setError('请输入验证码');
      return;
    }

    setIsVerifying(true);
    setError('');
    
    try {
      const isValid = await onVerify(phoneNumber, code);
      if (!isValid) {
        setError('验证码错误');
      }
    } catch (err) {
      setError('验证失败');
    } finally {
      setIsVerifying(false);
    }
  };

  return (
    <div className={`space-y-3 ${className}`}>
      <div className="flex space-x-2">
        <Input
          type="text"
          placeholder="请输入验证码"
          value={code}
          onChange={(e) => setCode(e.target.value)}
          error={error}
          disabled={isVerifying}
          className="flex-1"
        />
        <Button
          type="button"
          onClick={handleSendCode}
          disabled={isSending || countdown > 0}
          className="w-32"
        >
          {countdown > 0 ? `${countdown}s` : '发送验证码'}
        </Button>
      </div>
      
      <Button
        type="button"
        onClick={handleVerify}
        isLoading={isVerifying}
        className="w-full"
      >
        验证
      </Button>
    </div>
  );
};

API层集成

验证码相关API

// src/features/auth/api/captcha.ts
import { api } from '@/lib/api-client';

export const captchaApi = {
  getCaptcha: async (): Promise<{ image: string; token: string }> => {
    const response = await api.get('/auth/captcha');
    return response.data;
  },

  verifyCaptcha: async (token: string, code: string): Promise<boolean> => {
    const response = await api.post('/auth/captcha/verify', { token, code });
    return response.data.valid;
  },

  sendSmsCode: async (phoneNumber: string): Promise<{ success: boolean }> => {
    const response = await api.post('/auth/sms/send', { phoneNumber });
    return response.data;
  },

  verifySmsCode: async (phoneNumber: string, code: string): Promise<boolean> => {
    const response = await api.post('/auth/sms/verify', { phoneNumber, code });
    return response.data.valid;
  },
};

React Query Hooks

// src/features/auth/hooks/use-captcha.ts
import { useMutation, useQuery } from '@tanstack/react-query';
import { captchaApi } from '../api/captcha';

export const useCaptcha = () => {
  const {
    data: captchaData,
    refetch: refreshCaptcha,
    isLoading,
  } = useQuery({
    queryKey: ['captcha'],
    queryFn: captchaApi.getCaptcha,
    enabled: false, // 不自动获取
  });

  const verifyMutation = useMutation({
    mutationFn: (code: string) =>
      captchaApi.verifyCaptcha(captchaData?.token || '', code),
  });

  return {
    captchaImage: captchaData?.image,
    refreshCaptcha: () => refreshCaptcha(),
    verifyCaptcha: verifyMutation.mutateAsync,
    isLoading: isLoading || verifyMutation.isPending,
  };
};

export const useSmsVerification = () => {
  const sendMutation = useMutation({
    mutationFn: captchaApi.sendSmsCode,
  });

  const verifyMutation = useMutation({
    mutationFn: ({ phoneNumber, code }: { phoneNumber: string; code: string }) =>
      captchaApi.verifySmsCode(phoneNumber, code),
  });

  return {
    sendCode: sendMutation.mutateAsync,
    verifyCode: verifyMutation.mutateAsync,
    isSending: sendMutation.isPending,
    isVerifying: verifyMutation.isPending,
  };
};

安全最佳实践

1. 防止重放攻击

// 使用一次性token防止重放攻击
interface CaptchaSession {
  token: string;
  code: string;
  createdAt: Date;
  attempts: number;

【免费下载链接】bulletproof-react 一个简单、可扩展且功能强大的架构,用于构建生产就绪的 React 应用程序。 【免费下载链接】bulletproof-react 项目地址: https://gitcode.com/GitHub_Trending/bu/bulletproof-react

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值