Become a sponsor

概述
CaptchaService 提供图片验证码的生成与验证,用于登录等需要人机校验的场景。验证码基于 PHP GD 库生成,采用 6 位字母数字混合字符(去除易混淆字符 0O、1lI),支持防刷限流和万能验证码。
┌─────────────────────────────────────────────────────────────────────┐
│ 获取验证码 │
└─────────────────────────────────────────────────────────────────────┘
前端 后端
│ │
│── 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, ... } ────────────│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="...">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 秒 |
验证码错误统一提示
无论验证码是过期、不存在还是不匹配,统一提示"验证码错误",不暴露具体原因,防止攻击者探测。
Redis Key 格式:
captcha:{uuid-key}
值:验证码小写(如 "ab3k7m")
TTL:300 秒(5分钟)
captcha_rate:{client-ip}
值:请求次数计数
TTL:60 秒(窗口期)示例:
captcha:550e8400-e29b-41d4-a716-446655440000 → "ab3k7m" (TTL 300s)
captcha_rate:192.168.1.1 → "3" (TTL 60s)// 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;
}
}<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>; .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 泄露 |
<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 自动获取 |
| 点击图片时 | 用户手动刷新 |
| 登录失败后 | 自动刷新并清空输入 |
| 验证码过期后 | 用户点击图片重新获取 |
[CAPTCHA]
; 万能验证码,方便调试(登录时输入此码即可跳过验证码)
CAPTCHA_BYPASS_CODE = 888888
; 验证码长度可以短一些
CAPTCHA_LENGTH = 4[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