Skip to content

5.3 验证码机制 ​

概述

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 编码的图片,可直接用于 <img src="...">

登录时验证 ​

bash
POST /api/login
Content-Type: application/json

{
    "username": "admin",
    "password": "123456",
    "code": "AB3K7M",
    "key": "550e8400-e29b-41d4-a716-446655440000"
}

错误响应 ​

场景codemsg
验证码过期/不存在1验证码错误
验证码不匹配1验证码错误
获取频率超限1请求过于频繁,请等待 N 秒

验证码错误统一提示

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

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
<template>
  <el-form-item>
    <el-input v-model="form.captchaCode" 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({ captchaKey: '', captchaCode: '' });

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

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

环境变量配置 ​

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
<template>
  <el-form :model="form" :rules="rules" ref="formRef">
    <el-form-item label="用户名" prop="username">
      <el-input v-model="form.username" placeholder="请输入用户名" />
    </el-form-item>

    <el-form-item label="密码" prop="password">
      <el-input v-model="form.password" type="password" placeholder="请输入密码" />
    </el-form-item>

    <el-form-item label="验证码" prop="code">
      <div style="display: flex; gap: 10px;">
        <el-input v-model="form.code" placeholder="请输入验证码"
                  style="flex: 1;" @keyup.enter="handleLogin" />
        <img :src="captchaImage" @click="refreshCaptcha"
             style="cursor: pointer; height: 40px;" title="点击刷新" />
      </div>
    </el-form-item>

    <el-form-item>
      <el-button type="primary" @click="handleLogin" :loading="loading">
        登录
      </el-button>
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import { getCaptcha } from '@/api/common/user';
import { useUserStore } from '@/store/modules/user';
import { ElMessage } from 'element-plus';

const userStore = useUserStore();
const router = useRouter();

const formRef = ref();
const loading = ref(false);
const captchaImage = ref('');

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

const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
  code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
};

// 获取验证码
const refreshCaptcha = async () => {
  const res = await getCaptcha();
  form.value.key = res.key;
  captchaImage.value = res.captcha;
};

// 登录
const handleLogin = async () => {
  await formRef.value.validate();
  loading.value = true;

  try {
    await userStore.login(form.value);
    ElMessage.success('登录成功');
    router.push('/');
  } catch (e: any) {
    // 登录失败(包括验证码错误),刷新验证码
    ElMessage.error(e.message || '登录失败');
    refreshCaptcha();
    form.value.code = '';
  } finally {
    loading.value = false;
  }
};

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

刷新验证码的时机 ​

时机说明
页面加载时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

小蚂蚁云团队 · 提供技术支持