# 5.3 验证码机制 :::tip 概述 CaptchaService 提供图片验证码的生成与验证,用于登录等需要人机校验的场景。验证码基于 PHP GD 库生成,采用 6 位字母数字混合字符(去除易混淆字符 0O、1lI),支持防刷限流和万能验证码。 ::: ## 工作流程 ```text ┌─────────────────────────────────────────────────────────────────────┐ │ 获取验证码 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── GET /api/captcha ──────────────────►│ │ ?clientIp=192.168.1.1 │ │ │ │ ┌────────┴────────┐ │ │ CaptchaService │ │ │ ::generate() │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 1. 防刷检查 │ │ │ checkRateLimit() │ │ │ 超限 → 返回等待 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 2. 生成随机码 │ │ │ 6位 去混淆字符 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 3. 生成 UUID key │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 4. 存入 Redis │ │ │ captcha:{key} │ │ │ TTL = 300秒 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 5. GD 库渲染图片 │ │ │ 干扰线 + 噪点 │ │ │ → Base64 编码 │ │ └────────┬────────┘ │ │ │◄── { key, captcha } ─────────────────│ │ │ │ 显示 Base64 图片 │ │ 用户输入验证码 │ ┌─────────────────────────────────────────────────────────────────────┐ │ 登录时验证 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── POST /api/login ──────────────────►│ │ { username, password, │ │ code: "AB3K7M", │ │ key: "550e8400-..." } │ │ │ │ ┌────────┴────────┐ │ │ CaptchaService │ │ │ ::verify() │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 1. 万能验证码? │ │ │ 匹配 → 直接通过 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 2. Redis 查询 │ │ │ captcha:{key} │ │ │ 不存在 → 已过期 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 3. 比较验证码 │ │ │ 大小写不敏感 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 4. 删除缓存 │ │ │ 一次性使用 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 5. 继续登录流程 │ │ │ JwtService:: │ │ │ login() │ │ └────────┬────────┘ │ │ │◄── { access_token, ... } ────────────│ ``` ## 接口说明 ### 获取验证码 ```bash GET /api/captcha ``` 响应: ```json { "code": 0, "ok": true, "msg": "操作成功", "data": { "key": "550e8400-e29b-41d4-a716-446655440000", "captcha": "data:image/png;base64,iVBOR..." } } ``` - `key`:验证码唯一标识(UUID v4 格式),登录时需回传 - `captcha`:Base64 编码的图片,可直接用于 `` ### 登录时验证 ```bash POST /api/login Content-Type: application/json { "username": "admin", "password": "123456", "code": "AB3K7M", "key": "550e8400-e29b-41d4-a716-446655440000" } ``` ### 错误响应 | 场景 | code | msg | |------|------|-----| | 验证码过期/不存在 | 1 | 验证码错误 | | 验证码不匹配 | 1 | 验证码错误 | | 获取频率超限 | 1 | 请求过于频繁,请等待 N 秒 | :::tip 验证码错误统一提示 无论验证码是过期、不存在还是不匹配,统一提示"验证码错误",不暴露具体原因,防止攻击者探测。 ::: ### Redis 存储结构 ```text Redis Key 格式: captcha:{uuid-key} 值:验证码小写(如 "ab3k7m") TTL:300 秒(5分钟) captcha_rate:{client-ip} 值:请求次数计数 TTL:60 秒(窗口期) ``` ```text 示例: captcha:550e8400-e29b-41d4-a716-446655440000 → "ab3k7m" (TTL 300s) captcha_rate:192.168.1.1 → "3" (TTL 60s) ``` ## 核心代码 ```php // app/service/CaptchaService.php class CaptchaService { /** * 生成验证码 * * @return array ['key' => 'UUID标识', 'captcha' => 'Base64图片'] */ public static function generate(): array { // 1. 从配置读取字符集和长度(默认 6 位,字符集去除易混淆字符 0O、1lI) $length = (int)env('captcha.captcha_length', 6); $chars = env('captcha.captcha_chars', 'ABCDEFGHJKLMNPQRSTUVWXYZ2346789'); $expire = (int)env('captcha.captcha_expire', 300); // 2. 生成随机字符 $code = ''; $charsLen = strlen($chars); for ($i = 0; $i < $length; $i++) { $code .= $chars[random_int(0, $charsLen - 1)]; } // 3. 生成 UUID v4 格式的唯一标识 $key = self::uuid(); // 4. 存入 Redis(小写存储,5 分钟过期) $redis = self::getRedis(); $redis->setex(self::KEY_PREFIX . $key, $expire, strtolower($code)); // 5. 从配置读取图片尺寸,渲染验证码图片并转为 base64 $width = (int)env('captcha.captcha_image_width', 160); $height = (int)env('captcha.captcha_image_height', 50); $imageBase64 = self::renderImage($code, $width, $height); return [ 'key' => $key, 'captcha' => $imageBase64, ]; } /** * 验证码防刷检查(滑动窗口计数) * * @param string $clientIp 客户端 IP * @return int 剩余等待秒数,0 表示可继续请求 */ public static function checkRateLimit(string $clientIp): int { $window = (int)env('captcha.captcha_rate_window', 60); $limit = (int)env('captcha.captcha_rate_limit', 10); $redis = self::getRedis(); $rateKey = self::RATE_KEY_PREFIX . $clientIp; $count = (int)$redis->get($rateKey); if ($count >= $limit) { $ttl = $redis->ttl($rateKey); return $ttl > 0 ? $ttl : $window; } $redis->incr($rateKey); if ($count === 0) { $redis->expire($rateKey, $window); } return 0; } /** * 验证验证码(一次性使用) * * @param string $key 验证码 key * @param string $code 用户输入的验证码 * @param bool $deleteVerify 验证后是否删除 * @return bool 是否验证通过 */ public static function verify(string $key, string $code, bool $deleteVerify = true): bool { if (empty($code)) { return false; } // 万能验证码:配置了且匹配则跳过验证 $bypassCode = env('captcha.captcha_bypass_code', ''); if (!empty($bypassCode) && strtolower($code) === strtolower($bypassCode)) { return true; } if (empty($key)) { return false; } $redis = self::getRedis(); $redisKey = self::KEY_PREFIX . $key; $cachedCode = $redis->get($redisKey); if ($cachedCode === null || $cachedCode === false) { return false; // 已过期或不存在 } // 验证后删除,确保一次性使用 if ($deleteVerify) { $redis->del($redisKey); } // 大小写不敏感比较(Redis 中已存为小写) return strtolower($code) === $cachedCode; } } ``` ## 前端集成 ```vue ``` ## 环境变量配置 ```ini ; .env [CAPTCHA] ; 验证码长度(默认 6) CAPTCHA_LENGTH = 6 ; 字符集(默认已去除易混淆字符 0O、1lI) CAPTCHA_CHARS = ABCDEFGHJKLMNPQRSTUVWXYZ2346789 ; 过期时间,秒(默认 300) CAPTCHA_EXPIRE = 300 ; 图片宽度(默认 160) CAPTCHA_IMAGE_WIDTH = 160 ; 图片高度(默认 50) CAPTCHA_IMAGE_HEIGHT = 50 ; 字体大小(默认 28) CAPTCHA_FONT_SIZE = 28 ; 干扰线数量(默认 8) CAPTCHA_NOISE_LINES = 8 ; 噪点数量(默认 500) CAPTCHA_NOISE_DOTS = 500 ; 万能验证码(为空时不生效,仅开发/调试环境使用) CAPTCHA_BYPASS_CODE = ; 防刷窗口期,秒(默认 60) CAPTCHA_RATE_WINDOW = 60 ; 窗口期内最大请求数(默认 20) CAPTCHA_RATE_LIMIT = 20 ``` ## 安全特性 | 特性 | 说明 | 防御目标 | |------|------|---------| | 一次性使用 | 验证后 `$redis->del()` 删除缓存 | 防止重放攻击 | | 过期机制 | 5 分钟 TTL,超时需重新获取 | 防止长时间尝试破解 | | 大小写不敏感 | 存储转小写,验证时小写比较 | 降低用户输入门槛 | | 去除易混淆字符 | 字符集去掉 0O、1lI | 减少输入错误 | | 防刷限流 | 滑动窗口计数,60 秒内最多 10 次 | 防止暴力获取验证码 | | 万能验证码 | `captcha_bypass_code` 配置 | 开发调试便利 | | UUID v4 key | 验证码标识不可预测 | 防止枚举攻击 | | Base64 图片 | 图片以 Base64 返回 | 避免跨域和 URL 泄露 | ## 前端集成 ### 登录页完整示例 ```vue ``` ### 刷新验证码的时机 | 时机 | 说明 | |------|------| | 页面加载时 | `onMounted` 自动获取 | | 点击图片时 | 用户手动刷新 | | 登录失败后 | 自动刷新并清空输入 | | 验证码过期后 | 用户点击图片重新获取 | ## 环境配置建议 ### 开发环境 ```ini [CAPTCHA] ; 万能验证码,方便调试(登录时输入此码即可跳过验证码) CAPTCHA_BYPASS_CODE = 888888 ; 验证码长度可以短一些 CAPTCHA_LENGTH = 4 ``` ### 生产环境 ```ini [CAPTCHA] ; 不设置万能验证码(留空) CAPTCHA_BYPASS_CODE = ; 标准 6 位验证码 CAPTCHA_LENGTH = 6 ; 适当降低防刷限制 CAPTCHA_RATE_WINDOW = 60 CAPTCHA_RATE_LIMIT = 10 ; 图片尺寸和干扰强度 CAPTCHA_IMAGE_WIDTH = 160 CAPTCHA_IMAGE_HEIGHT = 50 CAPTCHA_NOISE_LINES = 8 CAPTCHA_NOISE_DOTS = 500 ```