前端验证码验证实战指南:滑块、图片、点选与移动端验证完整实现

前端验证码验证完整实战指南:滑块、图片数字、计算验证码、移动端行为验证、图片点选

适用技术栈:原生 JavaScript、Vue 3、React、Java Spring Boot、PHP
适用场景:登录、注册、短信发送、表单提交、评论、后台管理系统、商城下单、活动报名、防刷接口


目录

  1. 验证码是什么?为什么需要验证码?
  2. 验证码验证的核心安全原则
  3. 验证码类型总览
  4. 完整前后端交互流程
  5. 验证码接口设计规范
  6. 原生 JS 实现
  7. Vue 3 实现
  8. React 实现
  9. Java Spring Boot 后端实现
  10. PHP 后端实现
  11. 第三方验证码服务接入
  12. 组件和资源推荐
  13. 安全加固方案
  14. 真实项目落地建议
  15. 常见问题与排查
  16. 学习资源与官方文档

一、验证码是什么?为什么需要验证码?

验证码,英文通常叫 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 普通图片验证码流程

Redis/Session 后端 前端 用户 Redis/Session 后端 前端 用户 打开登录页 GET /api/captcha/text/create 生成随机验证码 保存 token + answer + expire 返回 token + 图片 base64 展示验证码图片 输入验证码 POST /api/captcha/text/verify 查询 token 对应答案 校验答案、过期时间、是否已使用 返回 captchaTicket 登录接口携带 captchaTicket 校验 captchaTicket 登录成功/失败

4.2 滑块验证码流程

Redis/Session 后端 前端 用户 Redis/Session 后端 前端 用户 GET /api/captcha/slider/create 生成背景图、缺口位置、滑块图 保存 token + targetX + expire 返回 token + 背景图 + 滑块图 拖动滑块 采集 x 坐标、耗时、轨迹点 POST /api/captcha/slider/verify 校验位置误差、轨迹、耗时、token 返回 captchaTicket

4.3 图片点选验证码流程

Redis/Session 后端 前端 Redis/Session 后端 前端 GET /api/captcha/image-choice/create 随机选出 9 张图片 保存正确图片 ids 返回题目 + 图片列表 + token POST /api/captcha/image-choice/verify 对比 selectedIds 和 answerIds 返回 captchaTicket

五、验证码接口设计规范

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 HookReact 复用逻辑表单统一接入
useCaptcha composableVue 复用逻辑多页面复用

12.2 Vue 相关组件 / 资源

名称类型说明链接
AJ-Captcha行为验证码方案包含滑动拼图、文字点选,后端 Java,前端多端示例GitHub - AJ-Captcha
go-captcha-vueVue 验证码组件支持点击、滑块、拖拽、旋转等模式GitHub - go-captcha-vue
vue-slider-captchaVue 滑块验证码简单滑动验证组件jsDelivr - vue-slider-captcha
Vue 官方文档Vue 基础组件、v-model、事件、组合式 APIVue Docs
Vite 官方文档Vue 项目构建推荐搭配 Vue 3 使用Vite Docs

说明:部分开源组件维护活跃度一般,正式项目引入前要看最近更新时间、issue、star、license、安全性。


12.3 React 相关组件 / 资源

名称类型说明链接
@marsidev/react-turnstileReact Turnstile 组件适合 React/Next.js 接入 Cloudflare TurnstileGitHub - react-turnstile
rc-slider-captchaReact 滑块验证码React slider captcha componentnpm - 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-CaptchaJava 行为验证码已有 Java 后端实现,可学习滑块/点选GitHub - AJ-Captcha
Hutool CaptchaJava 工具库可生成图片验证码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
LaravelPHP 框架可用于正式后端项目Laravel Docs
ThinkPHPPHP 框架国内常见 PHP 框架ThinkPHP 文档

12.6 前端基础资源

名称说明链接
MDN Canvas API图片验证码绘制CanvasRenderingContext2D
MDN fillTextCanvas 绘制文字fillText()
MDN Pointer Events鼠标/触摸/触控笔统一事件Pointer Events
MDN Touch Events移动端触摸事件Touch Events
OWASPWeb 安全体系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

十六、学习资源与官方文档

前端基础

Java / Spring Boot

PHP

第三方验证码服务

Web 安全 / 无障碍


最终总结

验证码实现不要只看前端效果,真正安全的验证码一定是:

前端展示挑战
后端保存答案
前端提交用户行为
后端校验结果
后端签发一次性 ticket
业务接口再次校验 ticket
配合 IP 限流、账号限制、短信限制、日志监控

实际项目中可以这样选择:

项目类型推荐方案
学习 Demo原生 JS + Spring Boot / PHP 自研图片验证码
后台管理系统图片验证码 + 登录失败限制
官网表单计算验证码 / Turnstile / 阿里云验证码
注册登录滑块验证码 + 行为轨迹 + 限流
短信发送滑块验证码 + 手机号限流 + IP 限流
商城/活动/秒杀第三方验证码 + 风控系统
高安全业务第三方验证码 + 设备指纹 + 风控评分 + WAF

对前端工程师来说,验证码是一个非常适合练习综合能力的功能点,因为它同时涉及:

DOM 事件
Pointer Events
Canvas 绘图
Vue / React 组件封装
Axios / fetch 请求
前后端接口设计
Redis / Session
登录安全
接口限流
移动端适配
用户体验和无障碍设计
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值