Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_AntdVue/zh/core/captcha.md
T
2026-10-08 17:50:29 +08:00

15 KiB
Raw Blame History

5.3 验证码机制

:::tip 概述 CaptchaService 提供图片验证码的生成与验证,用于登录等需要人机校验的场景。验证码基于 PHP GD 库生成,采用 6 位字母数字混合字符(去除易混淆字符 0O、1lI),Redis 存储、Lua 脚本原子校验(一次性),支持防刷限流和万能验证码(仅调试模式生效)。 :::

工作流程

┌─────────────────────────────────────────────────────────────────────┐
│                         获取验证码                                   │
└─────────────────────────────────────────────────────────────────────┘

前端                                    后端
 │                                       │
 │── 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, ... } ─────────────│

接口说明

获取验证码

GET /api/captcha

响应:

{
    "code": 0,
    "ok": true,
    "msg": "操作成功",
    "data": {
        "key": "550e8400-e29b-41d4-a716-446655440000",
        "captcha": "data:image/png;base64,iVBOR..."
    }
}
  • key:验证码唯一标识(UUID v4 格式),登录时需回传
  • captcha:Base64 编码的图片,可直接用于 <img src="...">

获取超限时:

{ "code": 1, "ok": false, "msg": "请求过于频繁,请 43 秒后重试", "data": null }

登录时验证

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 秒后重试
未配置验证码长度(=0) — 跳过验证码校验(仅调试用途)

:::tip 验证码错误统一提示 无论验证码是过期、不存在还是不匹配,统一提示"验证码错误",不暴露具体原因,防止攻击者探测。 :::

Redis 存储结构

Redis Key 格式:

captcha:{uuid-key}                 ← KEY_PREFIX = 'captcha:'
  值:验证码小写(如 "ab3k7m")
  TTL:300 秒(5分钟)

captcha:rate:{client-ip}           ← RATE_KEY_PREFIX = 'captcha:rate:'
  值:请求次数计数
  TTL:60 秒(窗口期)
示例:
  captcha:550e8400-e29b-41d4-a716-446655440000 → "ab3k7m"  (TTL 300s)
  captcha:rate:192.168.1.1 → "3"                           (TTL 60s)

核心代码

// 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(取值并删除)——即使两个并发请求携带同一验证码,也只有一个能通过,彻底杜绝验证阶段的重放。 :::

前端集成

<template>
  <el-form-item>
    <el-input v-model="form.code" placeholder="验证码" />
    <img :src="captchaImage" @click="refreshCaptcha" style="cursor: pointer;" />
  </el-form-item>
</template>

<script setup>
import { getCaptcha } from '@/api/common/user';

const captchaImage = ref('');
const form = ref({ key: '', code: '' });

const refreshCaptcha = async () => {
  const res = await getCaptcha();
  form.value.key = res.key;
  captchaImage.value = res.captcha;  // 返回字段是 captcha(Base64),不是 image
};

onMounted(() => refreshCaptcha());
</script>

刷新验证码的时机

时机 说明
页面加载时 onMounted 自动获取
点击图片时 用户手动刷新
登录失败后 自动刷新并清空输入(一次性已消费)
验证码过期后 用户点击图片重新获取

配置项

# .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 头绕过(受信代理校验)

环境配置建议

开发环境

# 万能验证码,方便调试(登录时输入此码即可跳过验证码,仅 debug 生效)
CAPTCHA_BYPASS_CODE=888888
# 验证码长度可以短一些
CAPTCHA_LENGTH=4

生产环境

CAPTCHA_BYPASS_CODE=                   # 必须留空
CAPTCHA_LENGTH=6
CAPTCHA_RATE_WINDOW=60
CAPTCHA_RATE_LIMIT=10
CAPTCHA_NOISE_LINES=6
CAPTCHA_NOISE_DOTS=300

常见问题

现象 原因 处理
验证码接口 500 Redis 未连接 / phpredis 扩展缺失 见 2.2 Laragon 环境搭建 Redis 章节
图片显示空白 字体文件缺失 确认 public/static/fonts/Vera.ttf 存在
一直提示验证码错误 前端传参名错误 必须是 key / code(非 captchaKey/captchaCode)
频繁提示"请求过于频繁" 同 IP 短时间多次获取 等待窗口期结束,或调整 CAPTCHA_RATE_LIMIT
万能验证码不生效 APP_DEBUG=false 或未配置 仅调试环境可用,生产无法启用