bulletproof-react验证码:图形验证与短信验证
在现代Web应用中,验证码(CAPTCHA)是保护系统安全、防止恶意攻击的重要防线。本文将深入探讨如何在bulletproof-react架构中实现图形验证码和短信验证码,构建安全可靠的身份验证系统。
验证码的重要性与分类
为什么需要验证码?
验证码主要用于:
- 防止暴力攻击:限制登录尝试次数
- 阻止机器人攻击:区分人类用户和自动化脚本
- 保护敏感操作:如注册、密码重置、支付等
- 减轻服务器压力:过滤无效请求
验证码类型对比
| 类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 图形验证码 | 实现简单,成本低 | 用户体验较差,可能被OCR识别 | 一般安全要求的场景 |
| 短信验证码 | 安全性高,用户体验好 | 需要短信服务,有成本 | 高安全要求的场景 |
| 语音验证码 | 无障碍访问 | 成本较高,延迟较大 | 特殊需求场景 |
| 行为验证码 | 用户体验好,安全性高 | 实现复杂,需要第三方服务 | 对用户体验要求高的场景 |
bulletproof-react验证码架构设计
整体架构图
核心模块划分
- 验证码服务层:处理验证码生成、验证逻辑
- UI组件层:提供可复用的验证码组件
- API层:与后端服务通信
- 状态管理:管理验证码状态和验证结果
图形验证码实现
服务端验证码生成
// 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;
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



