前端验证码验证完整实战指南:滑块、图片数字、计算验证码、移动端行为验证、图片点选
适用技术栈:原生 JavaScript、Vue 3、React、Java Spring Boot、PHP
适用场景:登录、注册、短信发送、表单提交、评论、后台管理系统、商城下单、活动报名、防刷接口
目录
- 验证码是什么?为什么需要验证码?
- 验证码验证的核心安全原则
- 验证码类型总览
- 完整前后端交互流程
- 验证码接口设计规范
- 原生 JS 实现
- Vue 3 实现
- React 实现
- Java Spring Boot 后端实现
- PHP 后端实现
- 第三方验证码服务接入
- 组件和资源推荐
- 安全加固方案
- 真实项目落地建议
- 常见问题与排查
- 学习资源与官方文档
一、验证码是什么?为什么需要验证码?
验证码,英文通常叫 CAPTCHA,完整含义是:
Completely Automated Public Turing test to tell Computers and Humans Apart
简单理解:验证码是系统用来判断当前请求更像 真人操作 还是 机器脚本操作 的一种验证机制。
验证码常用于:
| 场景 | 风险 | 验证码作用 |
|---|---|---|
| 登录 | 暴力破解、撞库 | 增加自动化登录成本 |
| 注册 | 批量注册垃圾账号 | 限制机器批量注册 |
| 发送短信 | 短信轰炸、刷接口 | 防止短信接口被滥用 |
| 评论/留言 | 垃圾广告、机器人提交 | 阻止自动化表单提交 |
| 找回密码 | 批量探测账号 | 降低账号枚举风险 |
| 秒杀/活动 | 机器抢购 | 限制脚本高频请求 |
| 后台管理登录 | 账号安全 | 配合登录失败次数限制 |
二、验证码验证的核心安全原则
验证码最重要的原则:
前端只负责展示、交互、采集用户行为。
后端负责生成答案、保存答案、校验答案、签发验证票据。
2.1 错误做法
很多初学者会这样写:
// 错误:验证码答案直接放在前端
const answer = 'A7K9';
function verify(input) {
return input === answer;
}
这非常不安全,因为浏览器代码可以被查看、调试、篡改。
用户只需要打开控制台,就能看到答案或者绕过判断。
2.2 正确做法
1. 前端请求验证码。
2. 后端生成验证码答案。
3. 后端把答案保存到 Session / Redis / 数据库缓存。
4. 后端返回 token + 图片 / 挑战信息。
5. 用户完成验证。
6. 前端提交 token + 用户输入 / 行为轨迹。
7. 后端校验是否正确。
8. 后端返回一次性 captchaTicket。
9. 登录 / 注册 / 提交表单时携带 captchaTicket。
10. 后端最终业务接口再次校验 captchaTicket。
2.3 验证码必须满足的条件
| 条件 | 说明 |
|---|---|
| 服务端生成 | 答案不能由前端生成 |
| 短期有效 | 一般 60 秒到 120 秒 |
| 一次性使用 | 成功验证后立刻失效 |
| 绑定业务场景 | 登录验证码不能拿去注册使用 |
| 绑定客户端特征 | 可绑定 IP、User-Agent、设备指纹 |
| 配合限流 | 验证码不是唯一安全措施 |
| 支持刷新 | 用户看不清时可以刷新 |
| 支持无障碍 | 不能只考虑视觉用户 |
三、验证码类型总览
3.1 图片数字 / 字母验证码
用户看到一张图片:
A7K9
然后输入对应内容。
适合场景:
- 登录
- 后台管理系统
- 普通表单
- 留言提交
- 低风险注册
优点:
- 实现简单
- 后端生成难度低
- 适合初学者学习验证码原理
缺点:
- 用户体验一般
- 容易被 OCR 识别
- 对视障用户不友好
3.2 图片计算验证码
用户看到:
7 + 5 = ?
用户输入:
12
适合场景:
- 官网表单
- 留言提交
- 简单注册
- 内部后台登录
优点:
- 比普通字符验证码多一步计算
- 用户理解成本低
- 实现简单
缺点:
- 仍然可能被 OCR + 简单计算绕过
- 不适合高风险业务
3.3 滑块验证码
用户拖动滑块到指定位置。
常见形式:
拖动滑块完成拼图
适合场景:
- 登录
- 注册
- 发送短信
- 活动报名
- 移动端 H5
- 小程序登录前置校验
优点:
- 体验较好
- 可以采集用户拖动轨迹
- 适合移动端
- 比纯文本验证码更自然
缺点:
- 自研成本比图片验证码高
- 不能只校验 x 坐标
- 需要后端校验轨迹、耗时、误差范围
3.4 移动屏幕验证 / 手势轨迹验证
常见形式:
- 按住向右滑动
- 按指定方向滑动
- 在屏幕上完成简单手势
- 按住按钮一段时间
- 根据触摸轨迹判断是否真人
适合场景:
- H5 登录
- 移动端活动页
- 小程序
- App 内嵌 WebView
优点:
- 对移动端友好
- 可以采集触摸轨迹、速度、停顿
- 比输入验证码更顺滑
缺点:
- 需要适配触摸事件
- 要考虑 iOS / Android 兼容性
- 不适合键盘用户和部分辅助设备用户
3.5 图片点选验证码
例如:
请选择所有包含“红绿灯”的图片
或者:
请依次点击图片中的“医、院、咨、询”
适合场景:
- 注册
- 风控校验
- 较高风险提交
- 批量操作前校验
优点:
- 对简单脚本更难
- 比文本验证码更丰富
- 可以设计成品牌化样式
缺点:
- 图片素材库需要维护
- 无障碍体验较差
- 答案必须放在后端
3.6 无感验证码 / 风险评分验证码
例如:
- Cloudflare Turnstile
- Google reCAPTCHA v3
- 阿里云验证码 2.0
- 腾讯云验证码
- 极验 GeeTest
- 网易易盾
用户可能看不到验证码,但系统会通过行为、设备、环境、请求风险等因素判断。
适合场景:
- 商城
- 金融
- 内容平台
- 注册登录量较大的网站
- 对用户体验要求高的产品
优点:
- 用户体验好
- 风控能力强
- 运维成本低于完全自研
缺点:
- 依赖第三方服务
- 有费用、合规、网络可用性问题
- 需要前后端都接入
四、完整前后端交互流程
4.1 普通图片验证码流程
4.2 滑块验证码流程
4.3 图片点选验证码流程
五、验证码接口设计规范
5.1 创建验证码接口
GET /api/captcha/create?type=text&scene=login
响应:
{
"token": "captcha_7b40c3e2",
"type": "text",
"scene": "login",
"expire": 120,
"image": "data:image/png;base64,iVBORw0KGgo..."
}
5.2 校验验证码接口
POST /api/captcha/verify
Content-Type: application/json
普通验证码请求:
{
"token": "captcha_7b40c3e2",
"scene": "login",
"type": "text",
"code": "A7K9"
}
滑块验证码请求:
{
"token": "captcha_7b40c3e2",
"scene": "login",
"type": "slider",
"x": 186,
"track": [
{ "x": 0, "y": 0, "t": 1710000000000 },
{ "x": 15, "y": 1, "t": 1710000000080 },
{ "x": 42, "y": -1, "t": 1710000000160 }
]
}
图片点选验证码请求:
{
"token": "captcha_7b40c3e2",
"scene": "register",
"type": "image-choice",
"selectedIds": ["img_3", "img_7", "img_9"]
}
5.3 校验成功响应
{
"success": true,
"ticket": "captcha_pass_3fb119",
"expire": 300
}
5.4 校验失败响应
{
"success": false,
"code": "CAPTCHA_ERROR",
"message": "验证码错误,请重新验证"
}
5.5 登录接口携带验证码票据
POST /api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "123456",
"captchaTicket": "captcha_pass_3fb119"
}
后端登录接口必须校验:
captchaTicket 是否存在
captchaTicket 是否过期
captchaTicket 是否已使用
captchaTicket 是否属于 login 场景
captchaTicket 是否绑定当前 IP / UA
六、原生 JS 实现
6.1 封装验证码接口请求
// captchaApi.js
const API_BASE = '/api/captcha';
export async function createCaptcha(type = 'text', scene = 'login') {
const res = await fetch(`${API_BASE}/create?type=${type}&scene=${scene}`);
if (!res.ok) {
throw new Error('获取验证码失败');
}
return res.json();
}
export async function verifyCaptcha(data) {
const res = await fetch(`${API_BASE}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!res.ok) {
throw new Error('验证码校验接口异常');
}
return res.json();
}
6.2 图片数字验证码展示
<div class="captcha-panel">
<img id="captchaImage" alt="图形验证码" />
<button id="refreshCaptcha" type="button">换一张</button>
<input id="captchaInput" placeholder="请输入验证码" />
<button id="verifyCaptcha" type="button">验证</button>
</div>
<script type="module">
import { createCaptcha, verifyCaptcha } from './captchaApi.js';
let captchaToken = '';
let captchaScene = 'login';
async function loadCaptcha() {
const data = await createCaptcha('text', captchaScene);
captchaToken = data.token;
document.querySelector('#captchaImage').src = data.image;
}
async function handleVerify() {
const code = document.querySelector('#captchaInput').value.trim();
if (!code) {
alert('请输入验证码');
return;
}
const result = await verifyCaptcha({
type: 'text',
scene: captchaScene,
token: captchaToken,
code
});
if (result.success) {
alert('验证成功');
localStorage.setItem('captchaTicket', result.ticket);
} else {
alert(result.message || '验证码错误');
await loadCaptcha();
}
}
document.querySelector('#refreshCaptcha').addEventListener('click', loadCaptcha);
document.querySelector('#verifyCaptcha').addEventListener('click', handleVerify);
loadCaptcha();
</script>
6.3 前端 Canvas 演示验证码
注意:这个只是学习 Canvas 绘图,不适合直接用于正式安全校验。
<canvas id="captchaCanvas" width="130" height="46"></canvas>
<button id="drawCaptcha">刷新</button>
<script>
const canvas = document.querySelector('#captchaCanvas');
const ctx = canvas.getContext('2d');
function randomText(length = 4) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let text = '';
for (let i = 0; i < length; i++) {
text += chars[Math.floor(Math.random() * chars.length)];
}
return text;
}
function drawCaptcha() {
const text = randomText();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f5f7fa';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 干扰线
for (let i = 0; i < 8; i++) {
ctx.strokeStyle = `rgba(${Math.random() * 180}, ${Math.random() * 180}, ${Math.random() * 180}, .7)`;
ctx.beginPath();
ctx.moveTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.lineTo(Math.random() * canvas.width, Math.random() * canvas.height);
ctx.stroke();
}
// 绘制文字
for (let i = 0; i < text.length; i++) {
ctx.save();
ctx.font = 'bold 26px Arial';
ctx.fillStyle = `rgb(${Math.random() * 100}, ${Math.random() * 100}, ${Math.random() * 100})`;
ctx.translate(22 + i * 26, 30);
ctx.rotate((Math.random() - 0.5) * 0.5);
ctx.fillText(text[i], 0, 0);
ctx.restore();
}
console.log('仅演示用验证码:', text);
}
document.querySelector('#drawCaptcha').addEventListener('click', drawCaptcha);
drawCaptcha();
</script>
6.4 原生 JS 滑块验证码
这个版本使用 Pointer Events,能统一处理鼠标、触摸、触控笔。
<style>
.slider-captcha {
width: 320px;
user-select: none;
}
.slider-track {
position: relative;
height: 44px;
overflow: hidden;
border-radius: 22px;
background: #eef1f6;
}
.slider-progress {
width: 0;
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a);
}
.slider-text {
position: absolute;
inset: 0;
text-align: center;
line-height: 44px;
color: #606266;
pointer-events: none;
}
.slider-button {
position: absolute;
top: 0;
left: 0;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid #dcdfe6;
background: #fff;
line-height: 44px;
text-align: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, .16);
touch-action: none;
}
</style>
<div class="slider-captcha">
<div id="sliderTrack" class="slider-track">
<div id="sliderProgress" class="slider-progress"></div>
<div id="sliderText" class="slider-text">按住滑块向右拖动</div>
<div id="sliderButton" class="slider-button">→</div>
</div>
</div>
<script type="module">
import { createCaptcha, verifyCaptcha } from './captchaApi.js';
const trackEl = document.querySelector('#sliderTrack');
const progressEl = document.querySelector('#sliderProgress');
const buttonEl = document.querySelector('#sliderButton');
const textEl = document.querySelector('#sliderText');
let token = '';
let scene = 'login';
let startX = 0;
let moveX = 0;
let dragging = false;
let trackData = [];
async function initSlider() {
const data = await createCaptcha('slider', scene);
token = data.token;
moveX = 0;
dragging = false;
trackData = [];
progressEl.style.width = '0px';
buttonEl.style.left = '0px';
buttonEl.textContent = '→';
textEl.textContent = '按住滑块向右拖动';
}
function setMove(x) {
const max = trackEl.offsetWidth - buttonEl.offsetWidth;
moveX = Math.max(0, Math.min(x, max));
progressEl.style.width = `${moveX}px`;
buttonEl.style.left = `${moveX}px`;
}
buttonEl.addEventListener('pointerdown', event => {
dragging = true;
startX = event.clientX;
trackData = [{ x: 0, y: 0, t: Date.now() }];
buttonEl.setPointerCapture(event.pointerId);
});
buttonEl.addEventListener('pointermove', event => {
if (!dragging) return;
const diffX = event.clientX - startX;
setMove(diffX);
trackData.push({
x: Math.round(moveX),
y: Math.round(event.clientY),
t: Date.now()
});
});
buttonEl.addEventListener('pointerup', async () => {
if (!dragging) return;
dragging = false;
const result = await verifyCaptcha({
type: 'slider',
scene,
token,
x: Math.round(moveX),
track: trackData
});
if (result.success) {
textEl.textContent = '验证通过';
buttonEl.textContent = '✓';
localStorage.setItem('captchaTicket', result.ticket);
} else {
textEl.textContent = result.message || '验证失败,请重试';
setTimeout(initSlider, 600);
}
});
initSlider();
</script>
6.5 图片点选验证码
<style>
.choice-captcha {
width: 340px;
}
.choice-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.choice-item {
position: relative;
height: 96px;
overflow: hidden;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
}
.choice-item.active {
border-color: #409eff;
}
.choice-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
<div class="choice-captcha">
<p id="choiceQuestion"></p>
<div id="choiceGrid" class="choice-grid"></div>
<button id="choiceSubmit">提交验证</button>
</div>
<script type="module">
let token = '';
let selectedIds = [];
async function loadImageChoiceCaptcha() {
const res = await fetch('/api/captcha/image-choice/create?scene=register');
const data = await res.json();
token = data.token;
selectedIds = [];
document.querySelector('#choiceQuestion').textContent = data.question;
const grid = document.querySelector('#choiceGrid');
grid.innerHTML = '';
data.images.forEach(item => {
const div = document.createElement('div');
div.className = 'choice-item';
div.dataset.id = item.id;
div.innerHTML = `<img src="${item.url}" alt="验证码图片" />`;
div.addEventListener('click', () => {
const id = item.id;
if (selectedIds.includes(id)) {
selectedIds = selectedIds.filter(v => v !== id);
div.classList.remove('active');
} else {
selectedIds.push(id);
div.classList.add('active');
}
});
grid.appendChild(div);
});
}
document.querySelector('#choiceSubmit').addEventListener('click', async () => {
const res = await fetch('/api/captcha/image-choice/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, scene: 'register', selectedIds })
});
const result = await res.json();
if (result.success) {
alert('验证通过');
} else {
alert(result.message || '验证失败');
loadImageChoiceCaptcha();
}
});
loadImageChoiceCaptcha();
</script>
七、Vue 3 实现
7.1 Vue 项目目录建议
src/
├── api/
│ └── captcha.js
├── components/
│ └── captcha/
│ ├── TextCaptcha.vue
│ ├── MathCaptcha.vue
│ ├── SliderCaptcha.vue
│ └── ImageChoiceCaptcha.vue
├── views/
│ └── Login.vue
└── utils/
└── request.js
7.2 Vue 验证码接口封装
// src/api/captcha.js
import axios from 'axios';
export function createCaptcha(params) {
return axios.get('/api/captcha/create', { params });
}
export function verifyCaptcha(data) {
return axios.post('/api/captcha/verify', data);
}
7.3 Vue 图片验证码组件
<!-- src/components/captcha/TextCaptcha.vue -->
<template>
<div class="text-captcha">
<img
class="captcha-img"
:src="captchaImage"
alt="图形验证码"
@click="loadCaptcha"
/>
<input
v-model="code"
class="captcha-input"
placeholder="请输入验证码"
@keyup.enter="handleVerify"
/>
<button type="button" @click="handleVerify">验证</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { createCaptcha, verifyCaptcha } from '@/api/captcha';
const props = defineProps({
scene: {
type: String,
default: 'login'
}
});
const emit = defineEmits(['success', 'fail']);
const token = ref('');
const code = ref('');
const captchaImage = ref('');
async function loadCaptcha() {
const { data } = await createCaptcha({
type: 'text',
scene: props.scene
});
token.value = data.token;
captchaImage.value = data.image;
code.value = '';
}
async function handleVerify() {
if (!code.value.trim()) {
emit('fail', '请输入验证码');
return;
}
const { data } = await verifyCaptcha({
type: 'text',
scene: props.scene,
token: token.value,
code: code.value.trim()
});
if (data.success) {
emit('success', data.ticket);
} else {
emit('fail', data.message || '验证码错误');
loadCaptcha();
}
}
onMounted(loadCaptcha);
</script>
<style scoped>
.text-captcha {
display: flex;
align-items: center;
gap: 10px;
}
.captcha-img {
width: 120px;
height: 44px;
border-radius: 6px;
cursor: pointer;
object-fit: cover;
}
.captcha-input {
width: 140px;
height: 38px;
padding: 0 10px;
border: 1px solid #dcdfe6;
border-radius: 6px;
}
</style>
7.4 Vue 滑块验证码组件
<!-- src/components/captcha/SliderCaptcha.vue -->
<template>
<div class="slider-captcha">
<div ref="trackRef" class="slider-track">
<div class="slider-progress" :style="{ width: moveX + 'px' }"></div>
<div class="slider-text">{{ text }}</div>
<div
ref="buttonRef"
class="slider-button"
:class="{ passed }"
:style="{ left: moveX + 'px' }"
@pointerdown="onPointerDown"
>
{{ passed ? '✓' : '→' }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { createCaptcha, verifyCaptcha } from '@/api/captcha';
const props = defineProps({
scene: {
type: String,
default: 'login'
}
});
const emit = defineEmits(['success', 'fail']);
const trackRef = ref(null);
const buttonRef = ref(null);
const token = ref('');
const moveX = ref(0);
const startX = ref(0);
const dragging = ref(false);
const passed = ref(false);
const text = ref('按住滑块向右拖动');
const trackData = ref([]);
async function loadCaptcha() {
const { data } = await createCaptcha({
type: 'slider',
scene: props.scene
});
token.value = data.token;
moveX.value = 0;
startX.value = 0;
dragging.value = false;
passed.value = false;
text.value = '按住滑块向右拖动';
trackData.value = [];
}
function onPointerDown(event) {
if (passed.value) return;
dragging.value = true;
startX.value = event.clientX;
trackData.value = [{ x: 0, y: 0, t: Date.now() }];
buttonRef.value?.setPointerCapture?.(event.pointerId);
}
function onPointerMove(event) {
if (!dragging.value || passed.value) return;
const max = trackRef.value.offsetWidth - buttonRef.value.offsetWidth;
const diff = event.clientX - startX.value;
moveX.value = Math.max(0, Math.min(diff, max));
trackData.value.push({
x: Math.round(moveX.value),
y: Math.round(event.clientY),
t: Date.now()
});
}
async function onPointerUp() {
if (!dragging.value || passed.value) return;
dragging.value = false;
const { data } = await verifyCaptcha({
type: 'slider',
scene: props.scene,
token: token.value,
x: Math.round(moveX.value),
track: trackData.value
});
if (data.success) {
passed.value = true;
text.value = '验证通过';
emit('success', data.ticket);
} else {
text.value = data.message || '验证失败,请重试';
emit('fail', text.value);
setTimeout(loadCaptcha, 500);
}
}
onMounted(() => {
loadCaptcha();
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
});
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
});
</script>
<style scoped>
.slider-captcha {
width: 320px;
}
.slider-track {
position: relative;
height: 44px;
overflow: hidden;
border-radius: 22px;
background: #f1f3f5;
user-select: none;
}
.slider-progress {
height: 100%;
background: linear-gradient(90deg, #409eff, #67c23a);
}
.slider-text {
position: absolute;
inset: 0;
text-align: center;
line-height: 44px;
color: #606266;
pointer-events: none;
}
.slider-button {
position: absolute;
top: 0;
width: 44px;
height: 44px;
border-radius: 50%;
background: #fff;
border: 1px solid #dcdfe6;
line-height: 44px;
text-align: center;
cursor: pointer;
touch-action: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, .16);
}
.slider-button.passed {
color: #67c23a;
}
</style>
7.5 Vue 图片点选验证码组件
<!-- src/components/captcha/ImageChoiceCaptcha.vue -->
<template>
<div class="image-choice-captcha">
<p class="question">{{ question }}</p>
<div class="grid">
<button
v-for="item in images"
:key="item.id"
type="button"
class="item"
:class="{ active: selectedIds.includes(item.id) }"
@click="toggleSelect(item.id)"
>
<img :src="item.url" alt="验证码图片" />
</button>
</div>
<button type="button" class="submit" @click="handleVerify">提交验证</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
const props = defineProps({
scene: {
type: String,
default: 'register'
}
});
const emit = defineEmits(['success', 'fail']);
const token = ref('');
const question = ref('');
const images = ref([]);
const selectedIds = ref([]);
async function loadCaptcha() {
const { data } = await axios.get('/api/captcha/image-choice/create', {
params: { scene: props.scene }
});
token.value = data.token;
question.value = data.question;
images.value = data.images;
selectedIds.value = [];
}
function toggleSelect(id) {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter(v => v !== id);
} else {
selectedIds.value.push(id);
}
}
async function handleVerify() {
const { data } = await axios.post('/api/captcha/image-choice/verify', {
token: token.value,
scene: props.scene,
selectedIds: selectedIds.value
});
if (data.success) {
emit('success', data.ticket);
} else {
emit('fail', data.message || '验证失败');
loadCaptcha();
}
}
onMounted(loadCaptcha);
</script>
<style scoped>
.image-choice-captcha {
width: 340px;
}
.question {
margin-bottom: 12px;
font-weight: 600;
color: #303133;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.item {
padding: 0;
height: 96px;
overflow: hidden;
border: 2px solid transparent;
border-radius: 8px;
background: transparent;
cursor: pointer;
}
.item.active {
border-color: #409eff;
}
.item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.submit {
margin-top: 12px;
width: 100%;
height: 38px;
}
</style>
7.6 Vue 登录页使用验证码
<template>
<div class="login-page">
<form class="login-card" @submit.prevent="handleLogin">
<h2>后台登录</h2>
<input v-model="form.username" placeholder="用户名" />
<input v-model="form.password" type="password" placeholder="密码" />
<SliderCaptcha scene="login" @success="onCaptchaSuccess" @fail="onCaptchaFail" />
<button type="submit">登录</button>
</form>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
import axios from 'axios';
import SliderCaptcha from '@/components/captcha/SliderCaptcha.vue';
const form = reactive({
username: '',
password: ''
});
const captchaTicket = ref('');
function onCaptchaSuccess(ticket) {
captchaTicket.value = ticket;
}
function onCaptchaFail(message) {
console.warn(message);
captchaTicket.value = '';
}
async function handleLogin() {
if (!captchaTicket.value) {
alert('请先完成验证码');
return;
}
const { data } = await axios.post('/api/auth/login', {
...form,
captchaTicket: captchaTicket.value
});
if (data.success) {
alert('登录成功');
} else {
alert(data.message || '登录失败');
}
}
</script>
八、React 实现
8.1 React 项目目录建议
src/
├── api/
│ └── captcha.js
├── components/
│ └── captcha/
│ ├── TextCaptcha.jsx
│ ├── SliderCaptcha.jsx
│ └── ImageChoiceCaptcha.jsx
├── hooks/
│ └── useCaptcha.js
└── pages/
└── Login.jsx
8.2 React 验证码 API 封装
// src/api/captcha.js
export async function createCaptcha({ type, scene }) {
const params = new URLSearchParams({ type, scene });
const res = await fetch(`/api/captcha/create?${params.toString()}`);
if (!res.ok) {
throw new Error('创建验证码失败');
}
return res.json();
}
export async function verifyCaptcha(data) {
const res = await fetch('/api/captcha/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!res.ok) {
throw new Error('验证码校验失败');
}
return res.json();
}
8.3 React 图片验证码组件
// src/components/captcha/TextCaptcha.jsx
import { useEffect, useState } from 'react';
import { createCaptcha, verifyCaptcha } from '../../api/captcha';
export default function TextCaptcha({ scene = 'login', onSuccess, onFail }) {
const [token, setToken] = useState('');
const [image, setImage] = useState('');
const [code, setCode] = useState('');
async function loadCaptcha() {
const data = await createCaptcha({ type: 'text', scene });
setToken(data.token);
setImage(data.image);
setCode('');
}
async function handleVerify() {
if (!code.trim()) {
onFail?.('请输入验证码');
return;
}
const result = await verifyCaptcha({
type: 'text',
scene,
token,
code: code.trim()
});
if (result.success) {
onSuccess?.(result.ticket);
} else {
onFail?.(result.message || '验证码错误');
loadCaptcha();
}
}
useEffect(() => {
loadCaptcha();
}, []);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img
src={image}
alt="图形验证码"
onClick={loadCaptcha}
style={{ width: 120, height: 44, cursor: 'pointer', borderRadius: 6 }}
/>
<input
value={code}
onChange={e => setCode(e.target.value)}
placeholder="请输入验证码"
/>
<button type="button" onClick={handleVerify}>验证</button>
</div>
);
}
8.4 React 滑块验证码组件
// src/components/captcha/SliderCaptcha.jsx
import { useEffect, useRef, useState } from 'react';
import { createCaptcha, verifyCaptcha } from '../../api/captcha';
export default function SliderCaptcha({ scene = 'login', onSuccess, onFail }) {
const trackRef = useRef(null);
const buttonRef = useRef(null);
const trackDataRef = useRef([]);
const [token, setToken] = useState('');
const [moveX, setMoveX] = useState(0);
const [startX, setStartX] = useState(0);
const [dragging, setDragging] = useState(false);
const [passed, setPassed] = useState(false);
const [text, setText] = useState('按住滑块向右拖动');
async function loadCaptcha() {
const data = await createCaptcha({ type: 'slider', scene });
setToken(data.token);
setMoveX(0);
setStartX(0);
setDragging(false);
setPassed(false);
setText('按住滑块向右拖动');
trackDataRef.current = [];
}
function onPointerDown(event) {
if (passed) return;
setDragging(true);
setStartX(event.clientX);
trackDataRef.current = [{ x: 0, y: 0, t: Date.now() }];
buttonRef.current?.setPointerCapture?.(event.pointerId);
}
function onPointerMove(event) {
if (!dragging || passed) return;
const max = trackRef.current.offsetWidth - buttonRef.current.offsetWidth;
const diff = event.clientX - startX;
const nextX = Math.max(0, Math.min(diff, max));
setMoveX(nextX);
trackDataRef.current.push({
x: Math.round(nextX),
y: Math.round(event.clientY),
t: Date.now()
});
}
async function onPointerUp() {
if (!dragging || passed) return;
setDragging(false);
const result = await verifyCaptcha({
type: 'slider',
scene,
token,
x: Math.round(moveX),
track: trackDataRef.current
});
if (result.success) {
setPassed(true);
setText('验证通过');
onSuccess?.(result.ticket);
} else {
setText(result.message || '验证失败,请重试');
onFail?.(result.message || '验证失败');
setTimeout(loadCaptcha, 500);
}
}
useEffect(() => {
loadCaptcha();
}, []);
useEffect(() => {
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
return () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
};
});
return (
<div style={{ width: 320 }}>
<div
ref={trackRef}
style={{
position: 'relative',
height: 44,
overflow: 'hidden',
borderRadius: 22,
background: '#f1f3f5',
userSelect: 'none'
}}
>
<div
style={{
width: moveX,
height: '100%',
background: 'linear-gradient(90deg, #409eff, #67c23a)'
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
textAlign: 'center',
lineHeight: '44px',
color: '#606266',
pointerEvents: 'none'
}}
>
{text}
</div>
<div
ref={buttonRef}
onPointerDown={onPointerDown}
style={{
position: 'absolute',
top: 0,
left: moveX,
width: 44,
height: 44,
borderRadius: '50%',
background: '#fff',
border: '1px solid #dcdfe6',
lineHeight: '44px',
textAlign: 'center',
cursor: 'pointer',
touchAction: 'none',
boxShadow: '0 2px 8px rgba(0, 0, 0, .16)'
}}
>
{passed ? '✓' : '→'}
</div>
</div>
</div>
);
}
8.5 React 登录页使用验证码
// src/pages/Login.jsx
import { useState } from 'react';
import SliderCaptcha from '../components/captcha/SliderCaptcha';
export default function Login() {
const [form, setForm] = useState({ username: '', password: '' });
const [captchaTicket, setCaptchaTicket] = useState('');
function updateField(key, value) {
setForm(prev => ({ ...prev, [key]: value }));
}
async function handleSubmit(event) {
event.preventDefault();
if (!captchaTicket) {
alert('请先完成验证码');
return;
}
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, captchaTicket })
});
const data = await res.json();
if (data.success) {
alert('登录成功');
} else {
alert(data.message || '登录失败');
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={form.username}
onChange={e => updateField('username', e.target.value)}
placeholder="用户名"
/>
<input
value={form.password}
onChange={e => updateField('password', e.target.value)}
type="password"
placeholder="密码"
/>
<SliderCaptcha
scene="login"
onSuccess={ticket => setCaptchaTicket(ticket)}
onFail={() => setCaptchaTicket('')}
/>
<button type="submit">登录</button>
</form>
);
}
九、Java Spring Boot 后端实现
9.1 推荐依赖
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 正式项目推荐用 Redis 保存验证码 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
9.2 后端目录建议
src/main/java/com/example/captcha/
├── controller/
│ ├── CaptchaController.java
│ └── AuthController.java
├── service/
│ ├── CaptchaService.java
│ └── CaptchaStore.java
├── model/
│ ├── CaptchaRecord.java
│ ├── CaptchaTicket.java
│ ├── TrackPoint.java
│ └── ImageChoiceRecord.java
└── dto/
├── CaptchaCreateResponse.java
├── CaptchaVerifyRequest.java
└── LoginRequest.java
9.3 验证码记录模型
package com.example.captcha.model;
import java.time.LocalDateTime;
import java.util.List;
public class CaptchaRecord {
private String type;
private String scene;
private String answer;
private Integer targetX;
private List<String> answerIds;
private LocalDateTime expireAt;
private boolean used;
private String clientIp;
private String userAgent;
public boolean isExpired() {
return LocalDateTime.now().isAfter(expireAt);
}
public boolean unavailable() {
return used || isExpired();
}
public void markUsed() {
this.used = true;
}
// getter / setter 省略,可使用 Lombok 简化
}
如果使用 Lombok:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CaptchaRecord {
private String type;
private String scene;
private String answer;
private Integer targetX;
private List<String> answerIds;
private LocalDateTime expireAt;
private boolean used;
private String clientIp;
private String userAgent;
}
9.4 请求 DTO
package com.example.captcha.dto;
import java.util.List;
public class CaptchaVerifyRequest {
private String token;
private String type;
private String scene;
private String code;
private Integer x;
private List<TrackPoint> track;
private List<String> selectedIds;
// getter / setter
}
package com.example.captcha.dto;
public class TrackPoint {
private Integer x;
private Integer y;
private Long t;
// getter / setter
}
9.5 简单内存存储版本
适合学习和本地 Demo,不适合多节点线上环境。
package com.example.captcha.service;
import com.example.captcha.model.CaptchaRecord;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class CaptchaStore {
private final Map<String, CaptchaRecord> captchaMap = new ConcurrentHashMap<>();
private final Map<String, CaptchaRecord> ticketMap = new ConcurrentHashMap<>();
public void saveCaptcha(String token, CaptchaRecord record) {
captchaMap.put(token, record);
}
public CaptchaRecord getCaptcha(String token) {
return captchaMap.get(token);
}
public void saveTicket(String ticket, CaptchaRecord record) {
ticketMap.put(ticket, record);
}
public CaptchaRecord getTicket(String ticket) {
return ticketMap.get(ticket);
}
public void removeTicket(String ticket) {
ticketMap.remove(ticket);
}
}
9.6 图片验证码生成工具
package com.example.captcha.service;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.Random;
@Service
public class CaptchaImageService {
private final Random random = new Random();
public String randomCode(int length) {
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
}
return sb.toString();
}
public String createImageBase64(String text) {
try {
int width = 140;
int height = 48;
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
g.setColor(new Color(245, 247, 250));
g.fillRect(0, 0, width, height);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 干扰线
for (int i = 0; i < 10; i++) {
g.setColor(randomColor(80, 180));
g.drawLine(
random.nextInt(width),
random.nextInt(height),
random.nextInt(width),
random.nextInt(height)
);
}
// 噪点
for (int i = 0; i < 60; i++) {
g.setColor(randomColor(100, 220));
g.fillOval(random.nextInt(width), random.nextInt(height), 2, 2);
}
g.setFont(new Font("Arial", Font.BOLD, 28));
for (int i = 0; i < text.length(); i++) {
g.setColor(randomColor(0, 120));
int x = 18 + i * 26;
int y = 32 + random.nextInt(8) - 4;
g.rotate((random.nextDouble() - 0.5) * 0.4, x, y);
g.drawString(String.valueOf(text.charAt(i)), x, y);
g.rotate(-(random.nextDouble() - 0.5) * 0.4, x, y);
}
g.dispose();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "png", outputStream);
return "data:image/png;base64," + Base64.getEncoder().encodeToString(outputStream.toByteArray());
} catch (Exception e) {
throw new RuntimeException("生成验证码图片失败", e);
}
}
private Color randomColor(int min, int max) {
return new Color(
min + random.nextInt(max - min),
min + random.nextInt(max - min),
min + random.nextInt(max - min)
);
}
}
9.7 CaptchaService 核心逻辑
package com.example.captcha.service;
import com.example.captcha.dto.CaptchaVerifyRequest;
import com.example.captcha.dto.TrackPoint;
import com.example.captcha.model.CaptchaRecord;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
@Service
public class CaptchaService {
private final CaptchaStore store;
private final CaptchaImageService imageService;
private final Random random = new Random();
public CaptchaService(CaptchaStore store, CaptchaImageService imageService) {
this.store = store;
this.imageService = imageService;
}
public Map<String, Object> create(String type, String scene, String ip, String userAgent) {
return switch (type) {
case "text" -> createText(scene, ip, userAgent);
case "math" -> createMath(scene, ip, userAgent);
case "slider" -> createSlider(scene, ip, userAgent);
case "image-choice" -> createImageChoice(scene, ip, userAgent);
default -> throw new IllegalArgumentException("不支持的验证码类型");
};
}
private Map<String, Object> createText(String scene, String ip, String userAgent) {
String code = imageService.randomCode(4);
String token = UUID.randomUUID().toString();
CaptchaRecord record = new CaptchaRecord();
record.setType("text");
record.setScene(scene);
record.setAnswer(code.toLowerCase());
record.setExpireAt(LocalDateTime.now().plusSeconds(120));
record.setClientIp(ip);
record.setUserAgent(userAgent);
store.saveCaptcha(token, record);
return Map.of(
"token", token,
"type", "text",
"scene", scene,
"expire", 120,
"image", imageService.createImageBase64(code)
);
}
private Map<String, Object> createMath(String scene, String ip, String userAgent) {
int a = random.nextInt(9) + 1;
int b = random.nextInt(9) + 1;
String expression = a + " + " + b + " = ?";
String answer = String.valueOf(a + b);
String token = UUID.randomUUID().toString();
CaptchaRecord record = new CaptchaRecord();
record.setType("math");
record.setScene(scene);
record.setAnswer(answer);
record.setExpireAt(LocalDateTime.now().plusSeconds(120));
record.setClientIp(ip);
record.setUserAgent(userAgent);
store.saveCaptcha(token, record);
return Map.of(
"token", token,
"type", "math",
"scene", scene,
"expire", 120,
"image", imageService.createImageBase64(expression)
);
}
private Map<String, Object> createSlider(String scene, String ip, String userAgent) {
String token = UUID.randomUUID().toString();
int targetX = 150 + random.nextInt(90);
CaptchaRecord record = new CaptchaRecord();
record.setType("slider");
record.setScene(scene);
record.setTargetX(targetX);
record.setExpireAt(LocalDateTime.now().plusSeconds(120));
record.setClientIp(ip);
record.setUserAgent(userAgent);
store.saveCaptcha(token, record);
return Map.of(
"token", token,
"type", "slider",
"scene", scene,
"expire", 120,
"bgImage", "/static/captcha/bg-demo.png",
"sliderImage", "/static/captcha/slider-demo.png"
);
}
private Map<String, Object> createImageChoice(String scene, String ip, String userAgent) {
String token = UUID.randomUUID().toString();
List<Map<String, String>> images = List.of(
Map.of("id", "img_1", "url", "/captcha/cat-1.jpg"),
Map.of("id", "img_2", "url", "/captcha/dog-1.jpg"),
Map.of("id", "img_3", "url", "/captcha/cat-2.jpg"),
Map.of("id", "img_4", "url", "/captcha/car-1.jpg"),
Map.of("id", "img_5", "url", "/captcha/tree-1.jpg"),
Map.of("id", "img_6", "url", "/captcha/cat-3.jpg"),
Map.of("id", "img_7", "url", "/captcha/road-1.jpg"),
Map.of("id", "img_8", "url", "/captcha/house-1.jpg"),
Map.of("id", "img_9", "url", "/captcha/cat-4.jpg")
);
CaptchaRecord record = new CaptchaRecord();
record.setType("image-choice");
record.setScene(scene);
record.setAnswerIds(new ArrayList<>(List.of("img_1", "img_3", "img_6", "img_9")));
record.setExpireAt(LocalDateTime.now().plusSeconds(120));
record.setClientIp(ip);
record.setUserAgent(userAgent);
store.saveCaptcha(token, record);
return Map.of(
"token", token,
"type", "image-choice",
"scene", scene,
"expire", 120,
"question", "请选择所有包含猫的图片",
"images", images
);
}
public Map<String, Object> verify(CaptchaVerifyRequest request, String ip, String userAgent) {
CaptchaRecord record = store.getCaptcha(request.getToken());
if (record == null || record.unavailable()) {
return fail("验证码已过期,请重新获取");
}
if (!Objects.equals(record.getScene(), request.getScene())) {
return fail("验证码场景不匹配");
}
boolean ok = switch (record.getType()) {
case "text", "math" -> verifyTextOrMath(record, request);
case "slider" -> verifySlider(record, request);
case "image-choice" -> verifyImageChoice(record, request);
default -> false;
};
if (!ok) {
return fail("验证码错误,请重新验证");
}
record.markUsed();
String ticket = UUID.randomUUID().toString();
CaptchaRecord ticketRecord = new CaptchaRecord();
ticketRecord.setType(record.getType());
ticketRecord.setScene(record.getScene());
ticketRecord.setExpireAt(LocalDateTime.now().plusSeconds(300));
ticketRecord.setClientIp(ip);
ticketRecord.setUserAgent(userAgent);
store.saveTicket(ticket, ticketRecord);
return Map.of(
"success", true,
"ticket", ticket,
"expire", 300
);
}
private boolean verifyTextOrMath(CaptchaRecord record, CaptchaVerifyRequest request) {
if (request.getCode() == null) return false;
return record.getAnswer().equalsIgnoreCase(request.getCode().trim());
}
private boolean verifySlider(CaptchaRecord record, CaptchaVerifyRequest request) {
if (request.getX() == null || request.getTrack() == null) return false;
int distanceError = Math.abs(record.getTargetX() - request.getX());
boolean positionOk = distanceError <= 5;
boolean trackOk = checkTrack(request.getTrack());
return positionOk && trackOk;
}
private boolean checkTrack(List<TrackPoint> track) {
if (track.size() < 5) return false;
long start = track.get(0).getT();
long end = track.get(track.size() - 1).getT();
long duration = end - start;
if (duration < 500) return false;
if (duration > 15000) return false;
int sameDiffCount = 0;
Integer lastDiff = null;
for (int i = 1; i < track.size(); i++) {
int diff = track.get(i).getX() - track.get(i - 1).getX();
if (lastDiff != null && diff == lastDiff) {
sameDiffCount++;
}
lastDiff = diff;
}
// 过于匀速,像脚本
return sameDiffCount < track.size() / 2;
}
private boolean verifyImageChoice(CaptchaRecord record, CaptchaVerifyRequest request) {
if (request.getSelectedIds() == null) return false;
List<String> answerIds = new ArrayList<>(record.getAnswerIds());
List<String> selectedIds = new ArrayList<>(request.getSelectedIds());
Collections.sort(answerIds);
Collections.sort(selectedIds);
return answerIds.equals(selectedIds);
}
public boolean verifyTicket(String ticket, String scene, String ip, String userAgent) {
CaptchaRecord record = store.getTicket(ticket);
if (record == null || record.unavailable()) return false;
if (!Objects.equals(record.getScene(), scene)) return false;
record.markUsed();
store.removeTicket(ticket);
return true;
}
private Map<String, Object> fail(String message) {
return Map.of(
"success", false,
"message", message
);
}
}
9.8 CaptchaController
package com.example.captcha.controller;
import com.example.captcha.dto.CaptchaVerifyRequest;
import com.example.captcha.service.CaptchaService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/captcha")
public class CaptchaController {
private final CaptchaService captchaService;
public CaptchaController(CaptchaService captchaService) {
this.captchaService = captchaService;
}
@GetMapping("/create")
public Map<String, Object> create(
@RequestParam(defaultValue = "text") String type,
@RequestParam(defaultValue = "login") String scene,
HttpServletRequest request
) {
return captchaService.create(
type,
scene,
getClientIp(request),
request.getHeader("User-Agent")
);
}
@PostMapping("/verify")
public Map<String, Object> verify(
@RequestBody CaptchaVerifyRequest body,
HttpServletRequest request
) {
return captchaService.verify(
body,
getClientIp(request),
request.getHeader("User-Agent")
);
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
9.9 登录接口校验 captchaTicket
package com.example.captcha.controller;
import com.example.captcha.dto.LoginRequest;
import com.example.captcha.service.CaptchaService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final CaptchaService captchaService;
public AuthController(CaptchaService captchaService) {
this.captchaService = captchaService;
}
@PostMapping("/login")
public Map<String, Object> login(@RequestBody LoginRequest request, HttpServletRequest httpRequest) {
boolean captchaOk = captchaService.verifyTicket(
request.getCaptchaTicket(),
"login",
getClientIp(httpRequest),
httpRequest.getHeader("User-Agent")
);
if (!captchaOk) {
return Map.of(
"success", false,
"message", "请先完成验证码验证"
);
}
// 这里继续写账号密码校验逻辑
if ("admin".equals(request.getUsername()) && "123456".equals(request.getPassword())) {
return Map.of("success", true, "message", "登录成功");
}
return Map.of("success", false, "message", "用户名或密码错误");
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
package com.example.captcha.dto;
public class LoginRequest {
private String username;
private String password;
private String captchaTicket;
// getter / setter
}
十、PHP 后端实现
10.1 PHP 目录建议
captcha-demo/
├── api/
│ ├── captcha_create.php
│ ├── captcha_verify.php
│ ├── slider_create.php
│ ├── slider_verify.php
│ └── login.php
├── static/
│ └── captcha/
└── index.html
10.2 PHP 图片验证码生成
<?php
// api/captcha_create.php
session_start();
header('Content-Type: application/json; charset=utf-8');
$type = $_GET['type'] ?? 'text';
$scene = $_GET['scene'] ?? 'login';
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= $chars[random_int(0, strlen($chars) - 1)];
}
$token = bin2hex(random_bytes(16));
$_SESSION['captcha'][$token] = [
'type' => $type,
'scene' => $scene,
'answer' => strtolower($code),
'expire' => time() + 120,
'used' => false,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? ''
];
$width = 140;
$height = 48;
$image = imagecreatetruecolor($width, $height);
$bg = imagecolorallocate($image, 245, 247, 250);
imagefill($image, 0, 0, $bg);
// 干扰线
for ($i = 0; $i < 10; $i++) {
$color = imagecolorallocate(
$image,
random_int(80, 180),
random_int(80, 180),
random_int(80, 180)
);
imageline(
$image,
random_int(0, $width),
random_int(0, $height),
random_int(0, $width),
random_int(0, $height),
$color
);
}
// 噪点
for ($i = 0; $i < 60; $i++) {
$color = imagecolorallocate(
$image,
random_int(120, 220),
random_int(120, 220),
random_int(120, 220)
);
imagesetpixel($image, random_int(0, $width - 1), random_int(0, $height - 1), $color);
}
// 文字
for ($i = 0; $i < strlen($code); $i++) {
$color = imagecolorallocate(
$image,
random_int(0, 100),
random_int(0, 100),
random_int(0, 100)
);
imagestring(
$image,
5,
20 + $i * 28,
random_int(14, 20),
$code[$i],
$color
);
}
ob_start();
imagepng($image);
$imageData = ob_get_clean();
imagedestroy($image);
echo json_encode([
'token' => $token,
'type' => $type,
'scene' => $scene,
'expire' => 120,
'image' => 'data:image/png;base64,' . base64_encode($imageData)
], JSON_UNESCAPED_UNICODE);
10.3 PHP 计算验证码生成
<?php
// api/math_captcha_create.php
session_start();
header('Content-Type: application/json; charset=utf-8');
$scene = $_GET['scene'] ?? 'login';
$a = random_int(1, 9);
$b = random_int(1, 9);
$expression = "$a + $b = ?";
$answer = (string)($a + $b);
$token = bin2hex(random_bytes(16));
$_SESSION['captcha'][$token] = [
'type' => 'math',
'scene' => $scene,
'answer' => $answer,
'expire' => time() + 120,
'used' => false
];
$width = 140;
$height = 48;
$image = imagecreatetruecolor($width, $height);
$bg = imagecolorallocate($image, 245, 247, 250);
$textColor = imagecolorallocate($image, 40, 40, 40);
imagefill($image, 0, 0, $bg);
imagestring($image, 5, 28, 16, $expression, $textColor);
ob_start();
imagepng($image);
$imageData = ob_get_clean();
imagedestroy($image);
echo json_encode([
'token' => $token,
'type' => 'math',
'scene' => $scene,
'expire' => 120,
'image' => 'data:image/png;base64,' . base64_encode($imageData)
], JSON_UNESCAPED_UNICODE);
10.4 PHP 图片验证码验证
<?php
// api/captcha_verify.php
session_start();
header('Content-Type: application/json; charset=utf-8');
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$token = $data['token'] ?? '';
$type = $data['type'] ?? '';
$scene = $data['scene'] ?? '';
$code = strtolower(trim($data['code'] ?? ''));
$record = $_SESSION['captcha'][$token] ?? null;
if (!$record) {
echo json_encode(['success' => false, 'message' => '验证码不存在'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($record['used'] || time() > $record['expire']) {
echo json_encode(['success' => false, 'message' => '验证码已过期'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($record['scene'] !== $scene) {
echo json_encode(['success' => false, 'message' => '验证码场景不匹配'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($record['answer'] !== $code) {
echo json_encode(['success' => false, 'message' => '验证码错误'], JSON_UNESCAPED_UNICODE);
exit;
}
$_SESSION['captcha'][$token]['used'] = true;
$ticket = bin2hex(random_bytes(16));
$_SESSION['captcha_ticket'][$ticket] = [
'scene' => $scene,
'expire' => time() + 300,
'used' => false
];
echo json_encode([
'success' => true,
'ticket' => $ticket,
'expire' => 300
], JSON_UNESCAPED_UNICODE);
10.5 PHP 滑块验证码生成
<?php
// api/slider_create.php
session_start();
header('Content-Type: application/json; charset=utf-8');
$scene = $_GET['scene'] ?? 'login';
$token = bin2hex(random_bytes(16));
$targetX = random_int(150, 240);
$_SESSION['slider_captcha'][$token] = [
'type' => 'slider',
'scene' => $scene,
'targetX' => $targetX,
'expire' => time() + 120,
'used' => false
];
echo json_encode([
'token' => $token,
'type' => 'slider',
'scene' => $scene,
'expire' => 120,
'bgImage' => '/static/captcha/bg-demo.png',
'sliderImage' => '/static/captcha/slider-demo.png'
], JSON_UNESCAPED_UNICODE);
10.6 PHP 滑块验证码验证
<?php
// api/slider_verify.php
session_start();
header('Content-Type: application/json; charset=utf-8');
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$token = $data['token'] ?? '';
$scene = $data['scene'] ?? '';
$x = intval($data['x'] ?? 0);
$track = $data['track'] ?? [];
$record = $_SESSION['slider_captcha'][$token] ?? null;
if (!$record || $record['used'] || time() > $record['expire']) {
echo json_encode(['success' => false, 'message' => '验证码已过期'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($record['scene'] !== $scene) {
echo json_encode(['success' => false, 'message' => '验证码场景不匹配'], JSON_UNESCAPED_UNICODE);
exit;
}
function checkTrack($track) {
if (!is_array($track) || count($track) < 5) {
return false;
}
$start = intval($track[0]['t']);
$end = intval($track[count($track) - 1]['t']);
$duration = $end - $start;
if ($duration < 500 || $duration > 15000) {
return false;
}
$sameDiffCount = 0;
$lastDiff = null;
for ($i = 1; $i < count($track); $i++) {
$diff = intval($track[$i]['x']) - intval($track[$i - 1]['x']);
if ($lastDiff !== null && $diff === $lastDiff) {
$sameDiffCount++;
}
$lastDiff = $diff;
}
return $sameDiffCount < count($track) / 2;
}
$positionOk = abs($x - intval($record['targetX'])) <= 5;
$trackOk = checkTrack($track);
if (!$positionOk || !$trackOk) {
echo json_encode(['success' => false, 'message' => '验证失败'], JSON_UNESCAPED_UNICODE);
exit;
}
$_SESSION['slider_captcha'][$token]['used'] = true;
$ticket = bin2hex(random_bytes(16));
$_SESSION['captcha_ticket'][$ticket] = [
'scene' => $scene,
'expire' => time() + 300,
'used' => false
];
echo json_encode([
'success' => true,
'ticket' => $ticket,
'expire' => 300
], JSON_UNESCAPED_UNICODE);
10.7 PHP 登录接口校验 ticket
<?php
// api/login.php
session_start();
header('Content-Type: application/json; charset=utf-8');
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
$username = $data['username'] ?? '';
$password = $data['password'] ?? '';
$captchaTicket = $data['captchaTicket'] ?? '';
$ticketRecord = $_SESSION['captcha_ticket'][$captchaTicket] ?? null;
if (!$ticketRecord || $ticketRecord['used'] || time() > $ticketRecord['expire']) {
echo json_encode(['success' => false, 'message' => '请先完成验证码验证'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($ticketRecord['scene'] !== 'login') {
echo json_encode(['success' => false, 'message' => '验证码场景错误'], JSON_UNESCAPED_UNICODE);
exit;
}
$_SESSION['captcha_ticket'][$captchaTicket]['used'] = true;
if ($username === 'admin' && $password === '123456') {
echo json_encode(['success' => true, 'message' => '登录成功'], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode(['success' => false, 'message' => '用户名或密码错误'], JSON_UNESCAPED_UNICODE);
十一、第三方验证码服务接入
自研验证码适合学习和中小项目,但如果项目有明显风控压力,建议使用第三方服务。
11.1 常见第三方验证码服务对比
| 服务 | 适合场景 | 特点 | 官方链接 |
|---|---|---|---|
| Cloudflare Turnstile | 官网、登录、注册、表单 | 无感/低打扰,隐私友好,接入简单 | Cloudflare Turnstile Docs |
| Google reCAPTCHA | 海外网站、国际业务 | v2/v3/Enterprise,生态成熟 | Google reCAPTCHA Docs |
| hCaptcha | 海外网站、替代 reCAPTCHA | 支持多种集成,隐私方向较强 | hCaptcha Docs |
| Friendly Captcha | 欧洲业务、重视隐私和无障碍 | 自动化验证、隐私友好 | Friendly Captcha Docs |
| GeeTest 极验 | 国内外行为验证 | 滑块、点选、无感验证较成熟 | GeeTest Docs |
| 腾讯云验证码 | 国内业务、腾讯云生态 | 支持 Web、App、小程序等场景 | Tencent Cloud Captcha Docs |
| 阿里云验证码 2.0 | 阿里云项目、国内业务 | 支持 Web/H5/APP,服务端二次校验 | 阿里云验证码文档 |
| 网易易盾 | 内容风控、注册登录、App/H5 | 国内业务风控能力较强 | 网易易盾官网 |
11.2 Cloudflare Turnstile 前端示例
HTML:
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form id="loginForm">
<input name="username" placeholder="用户名" />
<input name="password" type="password" placeholder="密码" />
<div
class="cf-turnstile"
data-sitekey="你的 site key"
data-callback="onTurnstileSuccess"
></div>
<button type="submit">登录</button>
</form>
<script>
let turnstileToken = '';
window.onTurnstileSuccess = function(token) {
turnstileToken = token;
};
document.querySelector('#loginForm').addEventListener('submit', async event => {
event.preventDefault();
if (!turnstileToken) {
alert('请先完成验证');
return;
}
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: event.target.username.value,
password: event.target.password.value,
turnstileToken
})
});
const data = await res.json();
console.log(data);
});
</script>
11.3 Spring Boot 校验 Turnstile Token
@PostMapping("/verify-turnstile")
public Map<String, Object> verifyTurnstile(@RequestBody Map<String, String> body) {
String token = body.get("token");
String secret = "你的 secret key";
RestTemplate restTemplate = new RestTemplate();
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("secret", secret);
form.add("response", token);
Map response = restTemplate.postForObject(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
form,
Map.class
);
boolean success = Boolean.TRUE.equals(response.get("success"));
return Map.of("success", success, "raw", response);
}
11.4 React 使用 Turnstile 组件
可以使用:
npm install @marsidev/react-turnstile
import { Turnstile } from '@marsidev/react-turnstile';
import { useState } from 'react';
export default function Login() {
const [token, setToken] = useState('');
async function handleLogin() {
if (!token) {
alert('请先完成验证');
return;
}
await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turnstileToken: token })
});
}
return (
<div>
<Turnstile
siteKey="你的 site key"
onSuccess={setToken}
/>
<button onClick={handleLogin}>登录</button>
</div>
);
}
十二、组件和资源推荐
12.1 自研组件推荐思路
如果你是前端开发工程师,建议自己至少实现一次下面几个组件:
| 组件 | 学习价值 | 项目价值 |
|---|---|---|
| TextCaptcha | 学习前后端验证码基础 | 后台登录可用 |
| MathCaptcha | 学习计算型验证码 | 官网表单可用 |
| SliderCaptcha | 学习拖拽、轨迹采集 | 移动端/注册登录可用 |
| ImageChoiceCaptcha | 学习图片选择和答案比对 | 风控场景可用 |
| CaptchaModal | 学习弹窗式验证码 | 不破坏原页面布局 |
| useCaptcha Hook | React 复用逻辑 | 表单统一接入 |
| useCaptcha composable | Vue 复用逻辑 | 多页面复用 |
12.2 Vue 相关组件 / 资源
| 名称 | 类型 | 说明 | 链接 |
|---|---|---|---|
| AJ-Captcha | 行为验证码方案 | 包含滑动拼图、文字点选,后端 Java,前端多端示例 | GitHub - AJ-Captcha |
| go-captcha-vue | Vue 验证码组件 | 支持点击、滑块、拖拽、旋转等模式 | GitHub - go-captcha-vue |
| vue-slider-captcha | Vue 滑块验证码 | 简单滑动验证组件 | jsDelivr - vue-slider-captcha |
| Vue 官方文档 | Vue 基础 | 组件、v-model、事件、组合式 API | Vue Docs |
| Vite 官方文档 | Vue 项目构建 | 推荐搭配 Vue 3 使用 | Vite Docs |
说明:部分开源组件维护活跃度一般,正式项目引入前要看最近更新时间、issue、star、license、安全性。
12.3 React 相关组件 / 资源
| 名称 | 类型 | 说明 | 链接 |
|---|---|---|---|
| @marsidev/react-turnstile | React Turnstile 组件 | 适合 React/Next.js 接入 Cloudflare Turnstile | GitHub - react-turnstile |
| rc-slider-captcha | React 滑块验证码 | React slider captcha component | npm - rc-slider-captcha |
| React 官方文档 | React 基础 | Hooks、状态、事件处理 | React Docs |
| Next.js 官方文档 | React SSR 项目 | 如果登录页在 Next.js 中可参考 | Next.js Docs |
12.4 Java / Spring Boot 后端资源
| 名称 | 类型 | 说明 | 链接 |
|---|---|---|---|
| Spring Boot Docs | 官方文档 | REST API、Web、Redis、配置 | Spring Boot Docs |
| Spring Security | 安全框架 | 登录认证、接口鉴权、安全配置 | Spring Security Docs |
| AJ-Captcha | Java 行为验证码 | 已有 Java 后端实现,可学习滑块/点选 | GitHub - AJ-Captcha |
| Hutool Captcha | Java 工具库 | 可生成图片验证码 | Hutool Captcha |
| Redis Docs | 缓存 | 保存验证码 token、ticket、限流数据 | Redis Docs |
12.5 PHP 后端资源
| 名称 | 类型 | 说明 | 链接 |
|---|---|---|---|
| PHP 官方文档 | 官方文档 | PHP 基础函数、Session、随机数 | PHP Docs |
| PHP random_int | 随机数 | 生成安全随机验证码 | PHP random_int |
| PHP GD | 图片处理 | 生成图片验证码 | PHP GD |
| Laravel | PHP 框架 | 可用于正式后端项目 | Laravel Docs |
| ThinkPHP | PHP 框架 | 国内常见 PHP 框架 | ThinkPHP 文档 |
12.6 前端基础资源
| 名称 | 说明 | 链接 |
|---|---|---|
| MDN Canvas API | 图片验证码绘制 | CanvasRenderingContext2D |
| MDN fillText | Canvas 绘制文字 | fillText() |
| MDN Pointer Events | 鼠标/触摸/触控笔统一事件 | Pointer Events |
| MDN Touch Events | 移动端触摸事件 | Touch Events |
| OWASP | Web 安全体系 | OWASP |
| W3C CAPTCHA 无障碍 | CAPTCHA 可访问性问题 | W3C Inaccessibility of CAPTCHA |
十三、安全加固方案
13.1 验证码不能单独使用
验证码必须配合:
IP 限流
账号登录失败次数限制
短信发送频率限制
设备指纹
User-Agent 分析
黑名单 / 白名单
风控评分
日志监控
13.2 Redis 数据设计
建议 Redis key:
captcha:challenge:{token}
captcha:ticket:{ticket}
captcha:limit:ip:{ip}
captcha:limit:phone:{phone}
captcha:fail:login:{username}
示例:
{
"type": "slider",
"scene": "login",
"targetX": 186,
"expireAt": "2026-07-06T15:30:00",
"used": false,
"clientIp": "127.0.0.1",
"userAgentHash": "xxx"
}
13.3 限流策略
| 场景 | 建议限制 |
|---|---|
| 创建验证码 | 同 IP 每分钟 10 次 |
| 验证失败 | 同 token 最多 3 次 |
| 登录失败 | 同账号 5 次失败后强制验证码 |
| 短信发送 | 同手机号 60 秒一次、每天最多 10 次 |
| 注册接口 | 同 IP 每小时最多 20 次 |
13.4 滑块轨迹校验维度
不要只校验:
Math.abs(userX - targetX) <= 5
还要校验:
拖动总耗时
轨迹点数量
轨迹是否完全匀速
是否瞬间完成
是否有合理停顿
是否有轻微回退
y 轴是否完全不变
客户端时间是否异常
同 IP 是否高频失败
示例判断:
private boolean checkHumanLikeTrack(List<TrackPoint> track) {
if (track == null || track.size() < 5) return false;
long duration = track.get(track.size() - 1).getT() - track.get(0).getT();
if (duration < 500 || duration > 15000) return false;
int yChanged = 0;
int backCount = 0;
int sameSpeed = 0;
Integer lastDiff = null;
for (int i = 1; i < track.size(); i++) {
int diffX = track.get(i).getX() - track.get(i - 1).getX();
int diffY = Math.abs(track.get(i).getY() - track.get(i - 1).getY());
if (diffY > 0) yChanged++;
if (diffX < 0) backCount++;
if (lastDiff != null && diffX == lastDiff) sameSpeed++;
lastDiff = diffX;
}
return sameSpeed < track.size() / 2;
}
13.5 图片验证码加固
可以增加:
字符旋转
字符扭曲
干扰线
噪点
背景纹理
字体随机
字符间距随机
颜色随机
大小随机
但不要过度加干扰,否则真人也看不清。
13.6 防止验证码被重复使用
验证成功后:
record.markUsed();
登录接口使用 ticket 后:
store.removeTicket(ticket);
13.7 生产环境注意事项
| 问题 | 建议 |
|---|---|
| 多台后端服务器 | 使用 Redis,不要用本地 Session |
| 前后端跨域 | 后端配置 CORS,携带 Cookie 时设置 credentials |
| HTTPS | 正式环境必须 HTTPS |
| 反向代理 | 正确获取真实 IP,处理 X-Forwarded-For |
| 日志 | 记录失败原因但不要泄露答案 |
| 错误提示 | 不要提示“答案差 3px”这种信息 |
| 监控 | 关注验证失败率、刷新率、通过率 |
十四、真实项目落地建议
14.1 后台管理系统
推荐:
图片验证码 / 计算验证码 + 登录失败次数限制 + IP 限流
适合:
- 医院官网 CMS 后台
- 企业官网后台
- 内容管理系统
- 内部管理平台
14.2 官网表单
推荐:
计算验证码 / 无感验证码 / 简单图片验证码
适合:
- 在线咨询表单
- 留言表单
- 预约表单
- 联系我们
如果表单提交量大、垃圾数据多,建议:
Cloudflare Turnstile / 阿里云验证码 / 腾讯云验证码
14.3 注册和短信验证码
推荐:
滑块验证码 + 手机号限流 + IP 限流 + 图形验证码兜底
流程:
用户输入手机号
点击获取短信
先弹出滑块验证码
滑块通过后生成 ticket
调用短信接口时携带 ticket
后端校验 ticket 后发送短信
14.4 商城 / 秒杀 / 活动
推荐:
第三方验证码 + 风控评分 + 限流 + 设备指纹
原因:
- 自研验证码容易被针对
- 高并发需要稳定性
- 黑产脚本会模拟行为
- 单纯验证码不够
14.5 移动端 H5 / 小程序
推荐:
滑动验证 / 无感验证 / 第三方行为验证码
注意:
按钮 touch-action: none
防止页面跟着滚动
适配 iOS Safari
适配 Android WebView
错误后要能重新初始化
十五、常见问题与排查
15.1 验证码图片不显示
可能原因:
后端没有返回 base64
接口跨域失败
Content-Type 错误
PHP GD 扩展没安装
Java 图片生成异常
图片路径 404
排查:
打开浏览器 Network
查看 create 接口响应
检查 image 字段是否存在
复制 base64 到新标签页测试
查看后端日志
15.2 输入正确但一直提示错误
可能原因:
大小写处理不一致
前端 token 丢失
刷新验证码后还用旧 token
Session 不一致
多服务器没有共享 Session
验证码过期
scene 不匹配
解决:
统一使用 lowerCase 比较
刷新验证码时更新 token
使用 Redis 保存验证码
检查 Cookie 是否携带
检查接口路径是否一致
15.3 滑块移动端拖动时页面跟着滚动
CSS 加:
.slider-button {
touch-action: none;
}
必要时阻止默认事件:
event.preventDefault();
15.4 React 滑块状态不准
常见原因:
闭包拿到旧 state
mousemove / pointermove 事件重复绑定
没有在 useEffect 中清理事件
建议:
轨迹数据用 useRef
全局事件在 useEffect return 中移除
拖动中关键数据尽量用 ref 保存
15.5 Vue 组件销毁后仍然触发事件
原因:
window.addEventListener 没有 remove
解决:
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
});
15.6 PHP Session 验证码失效
可能原因:
前后端不同域名,Cookie 没带上
session_start 没调用
服务器没有写 session 权限
接口走了不同子域
浏览器 SameSite 限制
解决:
所有接口都 session_start
检查 PHP session.save_path 权限
跨域时配置 credentials
生产环境建议用 Redis
十六、学习资源与官方文档
前端基础
- MDN CanvasRenderingContext2D
- MDN Canvas fillText()
- MDN Pointer Events
- MDN Touch Events
- Vue 官方文档
- React 官方文档
- Vite 官方文档
Java / Spring Boot
PHP
第三方验证码服务
- Cloudflare Turnstile Docs
- Google reCAPTCHA Docs
- Google Cloud reCAPTCHA / Fraud Defense Docs
- hCaptcha Docs
- Friendly Captcha Docs
- GeeTest Docs
- 腾讯云验证码文档
- 阿里云验证码 2.0 快速开始
- 阿里云验证码服务端接入
- 网易易盾官网
Web 安全 / 无障碍
- OWASP 官方网站
- OWASP Credential Stuffing Prevention Cheat Sheet
- OWASP Automated Threats to Web Applications
- W3C Inaccessibility of CAPTCHA
- W3C CAPTCHA Alternatives and Thoughts
最终总结
验证码实现不要只看前端效果,真正安全的验证码一定是:
前端展示挑战
后端保存答案
前端提交用户行为
后端校验结果
后端签发一次性 ticket
业务接口再次校验 ticket
配合 IP 限流、账号限制、短信限制、日志监控
实际项目中可以这样选择:
| 项目类型 | 推荐方案 |
|---|---|
| 学习 Demo | 原生 JS + Spring Boot / PHP 自研图片验证码 |
| 后台管理系统 | 图片验证码 + 登录失败限制 |
| 官网表单 | 计算验证码 / Turnstile / 阿里云验证码 |
| 注册登录 | 滑块验证码 + 行为轨迹 + 限流 |
| 短信发送 | 滑块验证码 + 手机号限流 + IP 限流 |
| 商城/活动/秒杀 | 第三方验证码 + 风控系统 |
| 高安全业务 | 第三方验证码 + 设备指纹 + 风控评分 + WAF |
对前端工程师来说,验证码是一个非常适合练习综合能力的功能点,因为它同时涉及:
DOM 事件
Pointer Events
Canvas 绘图
Vue / React 组件封装
Axios / fetch 请求
前后端接口设计
Redis / Session
登录安全
接口限流
移动端适配
用户体验和无障碍设计
619

被折叠的 条评论
为什么被折叠?



