验证码功能实现笔记

验证码功能实现笔记

一、整体流程

┌──────────┐  1. GET /api/service/captcha    ┌──────────┐  2. 路由转发(白名单免鉴权)  ┌──────────────┐
│ Login.vue │ ──────────────────────────────► │  网关    │ ─────────────────────────► │ mall-service  │
│ (前端)    │ ◄────────────────────────────── │  :9000   │ ◄───────────────────────── │ CaptchaCtrl   │
└──────────┘  4. 返回base64图片+uuid          └──────────┘  3. 生成验证码           └──────┬───────┘
     │                                                                                   │
     │ 5. 用户看到图片,输入验证码,带着 uuid 一起登录                                      │  3a. 文本存入Redis
     ▼                                                                                   ▼  key=captcha:{uuid}
┌──────────┐  6. POST /api/admin/login     ┌──────────┐                       ┌──────────┐
│  网关     │ ─────────────────────────────► │ mall-    │ ─ 7. 从Redis取出比对 ─► │  Redis   │
│  白名单   │                                │ admin    │                       │ 60秒TTL  │
└──────────┘                                └──────────┘                       └──────────┘

核心思路:验证码文本不存 Session,而是以 captcha:{uuid} 为 key 存入 Redis 并设置 60 秒 TTL;前端只拿到图片(base64)和 uuid,登录时把”用户输入 + uuid”一起提交,后端用 uuid 从 Redis 取出答案比对,验证通过后立即删除 key 保证一次性。这样天然支持分布式/多实例,过期由 Redis 自动处理。

二、后端:生成验证码

1. 引入 kaptcha 依赖(根 pom.xml)

<!--验证码-->
<dependency>
    <groupId>com.github.penggle</groupId>
    <artifactId>kaptcha</artifactId>
    <version>2.3.2</version>
</dependency>

kaptcha 是 Google 的验证码生成库,负责随机文本 + 干扰线图片的绘制。

2. 配置验证码样式(mall-service/.../config/KaptchaConfig.java)

@Configuration
public class KaptchaConfig {

    @Bean
    public DefaultKaptcha kaptchaProducer() {
        Properties properties = new Properties();
        // 图片尺寸
        properties.setProperty("kaptcha.image.width", "120");
        properties.setProperty("kaptcha.image.height", "40");
        // 验证码字符集(去掉易混淆的 0/O、1/I 等)和长度
        properties.setProperty("kaptcha.textproducer.char.string", "23456789ABCDEFGHJKLMNPQRSTUVWXYZ");
        properties.setProperty("kaptcha.textproducer.char.length", "4");
        properties.setProperty("kaptcha.textproducer.font.size", "32");
        properties.setProperty("kaptcha.textproducer.font.color", "black");
        // 干扰线和背景
        properties.setProperty("kaptcha.noise.color", "gray");
        properties.setProperty("kaptcha.background.clear.from", "white");
        properties.setProperty("kaptcha.background.clear.to", "white");
        // 无边框
        properties.setProperty("kaptcha.border", "no");

        DefaultKaptcha kaptcha = new DefaultKaptcha();
        kaptcha.setConfig(new Config(properties));
        return kaptcha;
    }
}

说明:注册一个 DefaultKaptcha Bean,用 Properties 定制外观——4 位字符、字符集里刻意剔除了 0/O、1/I 这类肉眼易混淆的字符、加灰色干扰线、白底无边框。之后任何地方注入 DefaultKaptcha 即可调用 createText()(生成随机文本)和 createImage(text)(绘制成图片)。

3. 生成接口(mall-service/.../controller/CaptchaController.java)

@RestController
@RequestMapping("/service")
public class CaptchaController {
    @Autowired
    private DefaultKaptcha defaultKaptcha;
    @Autowired
    private RedisTemplate redisTemplate;

    //图片以json形式返回
    @GetMapping("/captcha")
    public Result captcha(HttpServletResponse response) throws IOException {
        String captcha = defaultKaptcha.createText();          // ① 生成4位随机文本
        //redis存储
        String uuid = UUID.randomUUID().toString().replace("-", "");   // ② 生成唯一标识
        redisTemplate.opsForValue().set("captcha:" + uuid, captcha, 60, TimeUnit.SECONDS);  // ③ 存入Redis,60秒过期

        BufferedImage image = defaultKaptcha.createImage(captcha);     // ④ 用文本绘制图片
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        ImageIO.write(image, "jpg", out);
        String base64Code = Base64.encodeBase64String(out.toByteArray());  // ⑤ 转base64

        Map<String, Object> map = new HashMap<>();
        map.put("captcha", "data:image/png;base64," + base64Code);     // ⑥ 拼成 data URI
        map.put("uuid", uuid);
        return Result.ok("", map);
    }
}

说明:一次请求做了三件事——

  • 生成并暂存答案:createText() 得到 4 位验证码文本,以 captcha:{uuid} 为 key 存入 Redis,第三个参数 60 + TimeUnit.SECONDS 就是过期时间的设置处,Redis 会在 60 秒后自动删除该 key,无需任何清理代码。
  • uuid 是关键设计:前后端分离下没有 Session 关联,所以随机生成一个 uuid 返回给前端,前端登录时原样带回,后端靠它找回对应的验证码答案。每个 uuid 只对应一次验证码,互不干扰。
  • 图片以 base64 返回:不提供图片 URL,而是把图片字节转成 data:image/png;base64,... 直接塞进 JSON。好处是前端 <img :src> 直接用,无需二次请求、无浏览器缓存问题;代价是响应体略大。

⚠️ 注意:本项目是 Spring Boot 3,servlet 相关类必须 import jakarta.servlet.。之前这里用了 javax.servlet.,Spring 无法注入该参数,导致每个请求都抛异常返回”系统繁忙”。

4. 网关放行(mall-gateway/.../filter/AuthGlobalFilter.java)

String path = exchange.getRequest().getURI().getPath();
//不需要进行验证的路径
if (path.startsWith("/service/captcha")
        || path.startsWith("/admin/login")
        || path.startsWith("/user/login")) {
    return chain.filter(exchange);
}

说明:网关的全局鉴权过滤器对 /service/captcha 放行——用户还没登录、没有 token,取验证码必须是匿名可访问的,否则陷入”要登录才能拿到验证码、要验证码才能登录”的死循环。同理放行的还有登录接口本身。

三、前端:展示与刷新

1. API 封装(ui/ui-admin/src/api/service/captcha.js)

import request from '@/utils/request.js'

const captchaApi = {
    captcha() {
        return request.get('/service/captcha')
    }
}

export default captchaApi

2. 页面逻辑(ui/ui-admin/src/views/Login.vue)

//验证码图片地址,加时间戳参数防止浏览器缓存,点击图片刷新
const captchaSrc = ref('')
const refreshCaptcha = () => {
  captchaApi.captcha().then(result => {
    captchaSrc.value = result.data.captcha    // base64 data URI,直接绑给 <img>
    loginInfoDTO.value.uuid = result.data.uuid // 记住uuid,登录时一起提交
  })
}
refreshCaptcha()
<el-form-item prop="captcha">
  <div class="captcha-row">
    <el-input placeholder="请输入验证码" v-model="loginInfoDTO.captcha"></el-input>
    <img class="captcha-img" :src="captchaSrc" alt="验证码" title="点击刷新" @click="refreshCaptcha">
  </div>
</el-form-item>

登录时提交的 DTO:

const loginInfoDTO = ref({
  name: '',
  password: '',
  captcha: '',   // 用户输入的验证码
  uuid: ''       // 取验证码时后端返回的标识
})

说明:页面加载时调一次 refreshCaptcha() 把 base64 图片显示出来;因为每张图片的答案在 Redis 里只活 30 秒,点击图片就调 refreshCaptcha() 换一张新的(旧 uuid 的 key 会自动过期,不产生垃圾数据)。登录时 captcha 和 uuid 一起提交。

四、后端:登录时校验(mall-admin/.../controller/AdminController.java)

@PostMapping("/login")
public Result<String> login(@RequestBody LoginInfoDTO loginInfoDTO) {
    // 首先判断验证码是否正确
    String captcha = (String) redisTemplate.opsForValue().get("captcha:" + loginInfoDTO.getUuid());
    if (!StringUtils.hasText(captcha)) {
        return Result.error("验证码已过期");        // key不存在 = 已过期或uuid非法
    }
    if (!captcha.equalsIgnoreCase(loginInfoDTO.getCaptcha())) {
        return Result.error("验证码错误");          // 忽略大小写比对
    }
    // ……验证码通过后,再校验用户名、密码、禁用状态,签发JWT token
}

说明:校验顺序是先验证码、后账号密码——验证码是抵御暴力破解的第一道门,先挡住脚本请求,再消耗数据库查询。过期的判断是隐式的:Redis TTL 到期自动删 key,get() 拿到 null 就等价于”已过期”,不需要自己比较时间戳。

五、改进落实记录

  1. ~~验证码校验通过后应删除 key~~ ✅ 已实现:AdminController.login 比对成功后执行 redisTemplate.delete("captcha:" + loginInfoDTO.getUuid()),保证验证码一次性,30/60 秒内不能重复使用。
  2. ~~30 秒对真人偏短~~ ✅ 已调整:TTL 从 30 秒改为 60 秒(CaptchaController 的 set(..., 60, TimeUnit.SECONDS))。
  3. 前端遗留小问题:Login.vue 里 refreshCaptcha() 在页面加载时连续调用了两次(多余的一次请求浪费一个验证码),删掉一行即可。
上一篇