验证码功能实现笔记
一、整体流程
┌──────────┐ 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 就等价于”已过期”,不需要自己比较时间戳。
五、改进落实记录
- ~~验证码校验通过后应删除 key~~ ✅ 已实现:
AdminController.login比对成功后执行redisTemplate.delete("captcha:" + loginInfoDTO.getUuid()),保证验证码一次性,30/60 秒内不能重复使用。 - ~~30 秒对真人偏短~~ ✅ 已调整:TTL 从 30 秒改为 60 秒(
CaptchaController的set(..., 60, TimeUnit.SECONDS))。 - 前端遗留小问题:
Login.vue里refreshCaptcha()在页面加载时连续调用了两次(多余的一次请求浪费一个验证码),删掉一行即可。