# 5.3 验证码机制 :::tip 概述 CaptchaService 提供图片验证码的生成与验证,用于登录等需要人机校验的场景。验证码基于 PHP GD 库生成,采用 6 位字母数字混合字符(去除易混淆字符 0O、1lI),Redis 存储、Lua 脚本原子校验(一次性),支持防刷限流和万能验证码(仅调试模式生效)。 ::: ## 工作流程 ```text ┌─────────────────────────────────────────────────────────────────────┐ │ 获取验证码 │ └─────────────────────────────────────────────────────────────────────┘ 前端 后端 │ │ │── GET /api/captcha ──────────────────►│ (公开接口,无需认证) │ │ │ ┌────────┴────────┐ │ │ CaptchaController│ │ │ ::generate() │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 1. 防刷检查 │ │ │ checkRateLimit( │ │ │ request()->ip())│ ← IP 经 ProxyMiddleware 还原 │ │ 超限 → 返回等待 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 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. 万能验证码? │ │ │ (仅 app.debug) │ │ │ 匹配 → 直接通过 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 2. Lua 原子脚本 │ │ │ GETDEL │ │ │ captcha:{key} │ │ │ 不存在 → 已过期 │ │ │ (取值即删除, │ │ │ 一次性使用) │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 3. 比较验证码 │ │ │ 大小写不敏感 │ │ └────────┬────────┘ │ │ │ ┌────────┴────────┐ │ │ 4. 继续登录流程 │ │ │ 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 编码的图片,可直接用于 `` 获取超限时: ```json { "code": 1, "ok": false, "msg": "请求过于频繁,请 43 秒后重试", "data": null } ``` ### 登录时验证 ```bash POST /api/login Content-Type: application/json { "username": "admin", "password": "123456", "code": "AB3K7M", "key": "550e8400-e29b-41d4-a716-446655440000" } ``` :::tip 参数命名约定 登录参数 `key` / `code` 与 `/api/captcha` 响应字段命名保持一致(不是 `captchaKey` / `captchaCode`)。 ::: ### 错误响应 | 场景 | code | msg | |------|------|-----| | 验证码过期/不存在 | 1 | 验证码错误 | | 验证码不匹配 | 1 | 验证码错误 | | 获取频率超限 | 1 | 请求过于频繁,请 N 秒后重试 | | Redis 未连接(密码错误/服务未启) | 1 | 缓存服务(Redis)连接失败,请检查 Redis 配置(地址、端口、密码)后重试 | | 验证码生成的其它异常 | 1 | 验证码生成失败,请稍后重试 | | 未配置验证码长度(=0) | — | 跳过验证码校验(仅调试用途) | :::tip 验证码错误统一提示 无论验证码是过期、不存在还是不匹配,统一提示"验证码错误",不暴露具体原因,防止攻击者探测。 ::: :::tip Redis 不可用的检测与提示 生成验证码写入 Redis 失败时,会再执行一次 `PING` 复检(同样走地址、端口、密码鉴权):连接确实不通才返回「缓存服务(Redis)连接失败…」这条可操作的提示,其余情况返回通用提示;真实错误(如密码不匹配、连接拒绝)记录在日志 `验证码写入 Redis 失败` 中。 ::: ### Redis 存储结构 ```text Redis Key 格式: captcha:{uuid-key} ← KEY_PREFIX = 'captcha:' 值:验证码小写(如 "ab3k7m") TTL:300 秒(5分钟) captcha:rate:{client-ip} ← RATE_KEY_PREFIX = 'captcha:rate:' 值:请求次数计数 TTL:60 秒(窗口期) ``` ```text 示例: captcha:550e8400-e29b-41d4-a716-446655440000 → "ab3k7m" (TTL 300s) captcha:rate:192.168.1.1 → "3" (TTL 60s) ``` ## 核心代码 ```php // app/Services/CaptchaService.php(节选) class CaptchaService { const KEY_PREFIX = 'captcha:'; const RATE_KEY_PREFIX = 'captcha:rate:'; /** * 生成验证码 * * @return array ['key' => 'UUID标识', 'captcha' => 'Base64图片'] */ public static function generate(): array { // 1. 从配置读取字符集和长度(默认 6 位,字符集去除易混淆字符 0O、1lI) $length = config('captcha.length', 6); $chars = config('captcha.chars', 'ABCDEFGHJKLMNPQRSTUVWXYZ2346789'); $expire = config('captcha.expire', 300); // 2. 生成随机字符(random_int 密码学安全随机) // 3. 生成 UUID v4 格式的唯一标识 // 4. 存入 Redis(小写存储,5 分钟过期) Redis::setex(self::KEY_PREFIX.$key, $expire, strtolower($code)); // 5. 从配置读取图片尺寸,GD 渲染验证码图片并转为 base64 $width = config('captcha.image_width', 160); $height = config('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 = config('captcha.rate_window', 60); $limit = config('captcha.rate_limit', 10); $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; } /** * 验证验证码(Lua 原子取值即删除,一次性使用) */ public static function verify(string $key, string $code): bool { if (empty($code)) { return false; } // 万能验证码:配置了且匹配则跳过验证(仅 app.debug=true 时生效) $bypassCode = config('captcha.bypass_code', ''); if (! empty($bypassCode) && config('app.debug', false) && strcasecmp($code, $bypassCode) === 0) { return true; } if (empty($key)) { return false; } // Lua 脚本原子完成"读取并删除",杜绝并发重放 $cached = Redis::eval($script, 1, self::KEY_PREFIX.$key); if (! $cached) { return false; // 已过期或不存在 } // 大小写不敏感比较(Redis 中已存为小写) return strcasecmp($code, $cached) === 0; } } ``` :::tip Lua 原子性 校验使用 `Redis::eval` 执行 Lua 脚本原子完成 GETDEL(取值并删除)——即使两个并发请求携带同一验证码,也只有一个能通过,彻底杜绝验证阶段的重放。 ::: ## 前端集成 ```vue ``` ### 刷新验证码的时机 | 时机 | 说明 | |------|------| | 页面加载时 | `onMounted` 自动获取 | | 点击图片时 | 用户手动刷新 | | 登录失败后 | 自动刷新并清空输入(一次性已消费) | | 验证码过期后 | 用户点击图片重新获取 | ## 配置项 ```ini # .env(config/captcha.php 读取) CAPTCHA_LENGTH=6 # 验证码字符长度(4-6 位) CAPTCHA_CHARS=ABCDEFGHJKLMNPQRSTUVWXYZ2346789 # 字符集(已排除 0O、1lI) CAPTCHA_EXPIRE=300 # 有效期(秒),默认 5 分钟 CAPTCHA_IMAGE_WIDTH=160 # 图片宽度 CAPTCHA_IMAGE_HEIGHT=50 # 图片高度 CAPTCHA_FONT_SIZE=28 # 字体大小 CAPTCHA_FONT_PATH=static/fonts/Vera.ttf # 字体路径(public/ 下) CAPTCHA_NOISE_LINES=6 # 干扰线数量 CAPTCHA_NOISE_DOTS=300 # 噪点数量 CAPTCHA_RATE_WINDOW=60 # 防刷窗口期(秒) CAPTCHA_RATE_LIMIT=10 # 窗口期内最大获取次数 CAPTCHA_BYPASS_CODE= # 万能验证码(仅 APP_DEBUG=true 时生效) ``` :::warning 万能验证码安全边界 `CAPTCHA_BYPASS_CODE` 双重保护:**仅 `APP_DEBUG=true` 时生效**,且生产环境必须留空。即使误配,`APP_DEBUG=false` 也会自动失效。 ::: ## 安全特性 | 特性 | 说明 | 防御目标 | |------|------|---------| | 原子一次性 | Lua GETDEL 取值即删除 | 防止并发重放 | | 过期机制 | 5 分钟 TTL,超时需重新获取 | 防止长时间尝试破解 | | 大小写不敏感 | 存储转小写,验证时忽略大小写 | 降低用户输入门槛 | | 去除易混淆字符 | 字符集去掉 0O、1lI | 减少输入错误 | | 防刷限流 | 滑动窗口计数,60 秒内最多 10 次/IP | 防止暴力获取验证码 | | 万能验证码 | 配置 + `app.debug` 双重开关 | 开发调试便利、生产安全 | | UUID v4 key | 验证码标识不可预测 | 防止枚举攻击 | | Base64 图片 | 图片以 Base64 返回 | 避免跨域和 URL 泄露 | | 真实 IP 限流 | `request()->ip()`(ProxyMiddleware 还原) | 防止伪造 IP 头绕过(受信代理校验) | ## 环境配置建议 ### 开发环境 ```ini # 万能验证码,方便调试(登录时输入此码即可跳过验证码,仅 debug 生效) CAPTCHA_BYPASS_CODE=888888 # 验证码长度可以短一些 CAPTCHA_LENGTH=4 ``` ### 生产环境 ```ini CAPTCHA_BYPASS_CODE= # 必须留空 CAPTCHA_LENGTH=6 CAPTCHA_RATE_WINDOW=60 CAPTCHA_RATE_LIMIT=10 CAPTCHA_NOISE_LINES=6 CAPTCHA_NOISE_DOTS=300 ``` ## 常见问题 | 现象 | 原因 | 处理 | |------|------|------| | 验证码接口返回「缓存服务(Redis)连接失败…」 | Redis 未连接 / 密码不匹配 / phpredis 扩展缺失 | 见 [2.2 Laragon 环境搭建](/zh/quickstart/laragon.html) Redis 章节 | | 图片显示空白 | 字体文件缺失 | 确认 `public/static/fonts/Vera.ttf` 存在 | | 一直提示验证码错误 | 前端传参名错误 | 必须是 `key` / `code`(非 captchaKey/captchaCode) | | 频繁提示"请求过于频繁" | 同 IP 短时间多次获取 | 等待窗口期结束,或调整 `CAPTCHA_RATE_LIMIT` | | 万能验证码不生效 | `APP_DEBUG=false` 或未配置 | 仅调试环境可用,生产无法启用 |