news 2026/10/14 23:47:22

前后端分离验证码设计:Redis原子消费与防刷最佳实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前后端分离验证码设计:Redis原子消费与防刷最佳实践

前后端分离做了三四年,验证码这块我一直觉得是“细节多、坑不少”的活儿。最近在给一个内部管理系统做登录改造时,又把这套东西从头到尾捋了一遍,包括图形验证码的生成、存储、校验,以及前端组件的封装,过程中踩了不少坑,也把一些想当然的设计推倒重来。趁着记忆还热乎,把整个实现思路和实操记录整理出来,希望对正在做同样事情的你有点参考价值。

这篇文章以 Java + Spring Boot + Redis + Vue 这套国内最常用的前后端分离组合为例,核心讲清楚验证码在无状态接口下该怎么设计、请求链路怎么走、防刷怎么做、前端组件怎么封装,以及有哪些平时文档里不会写的坑。适合正在做登录注册、表单提交、短信防刷等场景的开发者,小白也能按步骤完整落地。

1. 先想清楚:前后端分离下验证码的痛点到底在哪

1.1 传统 Session 方案为什么在前后端分离下不适用

很多做后端的老开发最早接触验证码,用的都是 Session 方案:后端生成验证码图片,把答案塞进 Session,前端提交时从 Session 里取出来比对。这个方案在服务端渲染时代没有任何问题,因为浏览器和服务器天然共享同一个会话上下文。

但到了前后端分离架构下,情况完全变了。前端可能部署在某个静态域名下,后端接口在另一个域名甚至另一组集群上,两者之间通过 JSON 交互,没有页面跳转的概念。这时候 Session 方案至少会遇到三座大山:第一,跨域请求下 Cookie 的携带是个麻烦事,需要额外配置 CORS 允许携带凭据,很多团队在这块配置出错导致验证码永远校验不过;第二,如果前端不做 Cookie 会话而是改用 Token 鉴权,那 Session 里存的验证码答案根本没办法取到;第三,后端一旦做了多实例部署,Session 默认存在单机内存里,请求被负载均衡到另一台机器上就找不到验证码了,除非引入 Spring Session 这类组件做会话固化。

所以说,不是 Session 不能用,而是它和前后端分离的“无状态、跨域、可扩展”诉求天然不匹配。真正合理的思路是:把验证码的状态从“会话上下文”里挪出来,放到一个独立的、跨实例共享的存储里去,这也就是 Redis 方案出现的根本原因。

1.2 验证码方案的核心诉求只有三个

抛开各种花里胡哨的验证码类型不谈,一个合格的验证码实现本质上要满足三个诉求:唯一标识、过期时效、单次消费。

唯一标识的意思是,每次生成的验证码都必须有一个全局唯一的 ID,用户输入验证码时要把这个 ID 一起提交上来,后端才能知道该拿哪一份答案去比对。这个 ID 决定了验证码“是谁的”。过期时效很好理解,验证码不能永久有效,一般 120 秒到 5 分钟之间,过期必须作废,防止验证码被囤积后长期使用。单次消费是最容易被忽略的一点:同一个验证码一旦校验成功一次,必须立刻失效,哪怕它还没到过期时间,也不能再被第二次使用,这是防止“验证码重放攻击”的关键。

这三个诉求,正好对应了 Redis 的三个方面:用 UUID 生成唯一 key,用 EXPIRE 设置过期时间,用 DEL 实现消费即删除。理解了这个对应关系,后面所有的设计细节都是顺理成章的。

1.3 Redis 方案之外的其他选项,为什么我最终没有选

可能有人会问,用本地缓存 ConcurrentHashMap 行不行?行,但仅限于单机部署。一旦后端起了多个实例,用户第一次请求落到实例 A,验证码存在 A 的内存里;提交校验时负载均衡把请求转发到了实例 B,B 的内存里根本没有这个验证码,用户就会莫名其妙地提示“验证码已过期”,这在多实例部署里几乎是必现的问题。本地缓存只能用于 demo 或者单机内部工具,生产环境一律不建议。

也有人会问,用 JWT 之类的 Token 存验证码行不行?这在理论上是可行的,但实际体验很糟糕。JWT 签发后在后端无法主动销毁,你没办法实现“单次消费”,验证码可以反复提交直到 JWT 过期,这是严重的安全隐患。另外验证码本身是极短生命周期的临时数据,用 JWT 这种“带签名、可携带身份信息”的重量级凭证去承载,属于杀鸡用牛刀,白白增加代码复杂度。所以最终方案很清晰:Redis 的字符串类型就够了,一个 key 存一份答案,过期自动清理,校验通过立即删除。

2. 整体设计:一张接口链路图讲清验证码的数据流

2.1 接口设计:生成接口和校验逻辑怎么拆分

先明确一下职责边界。整个验证码模块只需要一个暴露给前端的接口:获取验证码接口。至于校验,我认为不应该单独做成一个公开接口,而是作为业务接口内部的一个前置校验步骤来使用。

什么意思?比如登录接口,正确的做法是:前端先调用获取验证码接口,拿到 captchaId 和图片;用户输入用户名、密码、验证码后一起提交到登录接口;登录接口内部先调用验证码校验服务,校验通过才继续走账号密码认证逻辑。这样做的原因是,如果单独做一个校验验证码接口,那就意味着客户端需要发起两次业务请求,第一次校验证码,第二次提交业务,中间的时序无法保证,而且给恶意脚本多了一个探测校验结果的机会。把验证码校验嵌入业务接口内部,一次提交、一次校验,链路最短也最安全。

获取验证码接口可以设计成下面这样:

项目内容
接口路径GET /api/captcha/gen
请求参数无,但会读取请求 IP 和前端传来的设备指纹用于防刷
响应字段captchaId(UUID)、captchaImage(Base64 图片)、expireSeconds(过期秒数)

接口不做鉴权,因为它是登录页用的,登录之前本来就没有 Token。这里要特别注意:不要在这个接口上做任何基于用户身份的拦截,只做频率限制。

2.2 Redis 数据结构设计:key 的粒度为什么必须是一次性的

存储结构上目前用的方案非常简洁,每个验证码一个独立的 Redis 字符串 key,格式如下:

captcha:code:9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c -> "K3mP"

解释一下几个设计点。第一,key 后半段直接使用 UUID 去掉横杠后的字符串,这个值就是返回给前端的 captchaId,后端校验时用前缀拼接 captchaId 就能定位到具体的答案。第二,value 存的是验证码明文,因为验证码本身就是短期口令,生命周期只有两分钟,Redis 中的明文风险可控,不需要额外加密,但要注意日志脱敏,稍后我会专门说。第三,过期时间我设置为 120 秒,这个数值不是拍脑袋定的:太短用户来不及输入,太长验证码容易被脚本囤积慢慢爆破,120 秒是大多数登录场景下比较舒服的区间。

这里有个关键的逻辑:每次获取验证码都会生成一个新的 UUID,也就是说同一个用户连续点击“换一张”时,Redis 里会同时存在多个有效验证码,旧的不会立即删除。这是一个好的设计吗?我认为是。因为如果使用固定 userId 作为 key,用户每刷新一次就会覆盖上一次的验证码,这会导致一种非常影响体验的异常:用户拿着第一次的验证码提交,但前端在提交瞬间因为某些原因刷新了一次,后端一比对就发现“验证码已过期”,用户一脸懵。用 UUID 作为 key,天然允许多个验证码同时存活,它们之间的过期互不影响,用户提交哪个就用哪个答案去比对,这个体验问题就不存在了。

2.3 校验的原子性问题:先删还是先比对

这是验证码模块最容易被写错的地方,我要单独拿出来强调。很多初版实现是这样写的:

String expected = redisTemplate.opsForValue().get(key); if (expected == null) { throw new CaptchaExpiredException("验证码已过期"); } if (!expected.equalsIgnoreCase(inputCode)) { throw new CaptchaErrorException("验证码错误"); } redisTemplate.delete(key); // 校验通过后再删除

表面上看逻辑没问题:先查答案、比对、通过后删除。但这里面有一个并发漏洞:如果攻击者在极短的时间内用同一个 captchaId 和验证码同时发起多个请求,在“get 到答案”和“delete 删除”之间的这个时间窗口里,多个请求都能读到同一个正确验证码,都能校验通过。这就是经典的“竞态条件”,验证码在并发场景下被重复消费。

正确的姿势应该是“先消费再比对”:拿到 redisTemplate 后,先执行删除,再拿删除的结果值去做比对。Redis 的单线程模型保证 get-and-del 这个操作要么拿到值并删除,要么拿不到值,不存在中间状态。这样即使攻击者同时发 10 个请求,只有第一个请求能拿到真实的验证码答案,其余请求拿到的是 null,直接判定过期。这一个细节的调整,直接把验证码模块从“可重放”变成了“单次消费”,安全等级完全是两个档次。

如果你的 Redis 版本在 6.2 以上,还可以用 GETDEL 命令一步完成取值和删除,语义更明确。但用 StringRedisTemplate 的 get 加 delete 组合也完全够用,实测没有性能问题。

2.4 为什么不建议把验证码答案放进 JWT

之前见过一个方案,把验证码答案加密后放进 JWT 返回给前端,前端提交时再把 JWT 原样传回来,后端解密比对。这个方案的动机是“不想引入 Redis 依赖”,但问题很致命:JWT 一旦发出去,在过期之前没有任何办法在后端让它失效,也就是说验证码无法做到“校验一次立即作废”。攻击者拿到一个验证码之后,可以在这个验证码的整个生命周期内无数次地重放请求,而每次重放都会通过验证码校验,这给密码爆破或批量注册开了个后门。

另外,JWT 的体积比一条 Redis 字符串大得多,每次都要塞进响应体重返前端,纯属浪费带宽。我的态度很明确:验证码这种短期、一次性、无需跨服务传递的数据,就用 Redis 存,不要创造性地把技术方案搞复杂。

3. 后端核心实现:从零手写一个验证码生成器

3.1 为什么建议自研,而不是直接套用现成验证码库

图形验证码这块其实有很多现成的第三方库,生成图片、写入文字、加干扰线都封装好了。我在早期项目里也用过,但后来发现两个问题:一个是库的体积和依赖比较多,为了一个验证码功能引入一大堆类库,有点划不来;另一个是很多库的生成样式千篇一律,生成的验证码图案被批量识别工具针对性训练过,安全性反而没有自己做的杂牌验证码高。

所以我现在的选择是自研一个轻量级的生成器,核心代码加起来不到一百行,控制力更强,想怎么扭曲、想加什么干扰都能自定义。当然,如果你的业务需要滑块验证码、拖动拼图这类强交互验证,那还是建议直接用专业的行为验证服务,自研成本和对抗识别难度会高很多,这类产品已经做得很成熟了。但简单的图形验证码,自研完全够用,而且可控。

3.2 字符集选择:为什么必须去掉易混淆字符

这一步看起来简单其实很关键。我之前见过一个验证码,里面出现了字母 l 和数字 1,用户盯着图片看了半天也分不清到底是哪个,最后输错三次被锁定,找客服投诉。所以字符集的第一原则就是把容易混淆的字符排除掉。

我目前使用的字符集如下:

private static final char[] CHARS = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '2', '3', '4', '5', '6', '7', '8', '9' };

注意我排掉了哪些:数字 0 和 1 排掉,因为和字母 O、l 太像;字母 I 排掉,和数字 1 像;字母 O 排掉,和数字 0 像;字母 L 排掉,和数字 1 在字体里也容易混淆。剩下的字符集合虽然只有 32 个,但每个字符之间的视觉差异足够大。对于 4 位验证码来说,32 的 4 次方是 104 万种组合,足够防住随机猜测。更保险的实践是区分大小写,但我个人建议不要刻意引入大小写混合,因为用户需要先判断大小写再输入,体验会变差,而且大小写在语义上很容易被忽略,校验时我统一用 equalsIgnoreCase 做比对。

3.3 图片生成细节:干扰线、噪点、旋转、字体的取舍

图片渲染的逻辑,直接决定用户能不能一眼认出验证码,以及机器识别的成本高不高。我的生成参数如下:图片尺寸 120×40,验证码字符数 4,每个字符独立绘制并做随机小角度旋转,背景使用浅色渐变,字符颜色用深色系,在此基础上叠加干扰线和噪点。

先讲文字绘制。我使用 Graphics2D 绘制,开启抗锯齿让字体边缘平滑,每个字符随机选择一个 20 到 28 之间的字号,随机旋转 -30 到 30 度,每个字符的 y 坐标也做一点上下浮动,避免四个字排成一条水平直线像印刷体一样整齐。字体统一用系统自带的 SansSerif 字体,不用花哨的艺术字体,因为太花哨的字体反而让用户难以辨认。

干扰线的逻辑是随机画 6 条线段,颜色比文字浅一些,宽度 1 像素,线段随机起点和终点。噪点则是随机撒 80 个像素点,颜色随机浅灰或淡蓝。这两个参数的经验值是:干扰线太少起不到干扰作用,太多会盖住字符影响用户体验;噪点密度同理,我测试下来 120×40 的图片撒 80 个噪点是比较均衡的。核心原则是“人能看清,机器难认”。

生成图片并输出 Base64 的完整代码如下:

public static String generateImage(String code) { int width = 120; int height = 40; BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g2d = image.createGraphics(); // 抗锯齿,让文字边缘更平滑 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); // 背景:浅色渐变,一定程度上增加机器识别的难度 GradientPaint gradient = new GradientPaint(0, 0, new Color(240, 244, 248), width, height, new Color(225, 232, 238)); g2d.setPaint(gradient); g2d.fillRect(0, 0, width, height); // 干扰线:随机画 6 条浅色线段 Random random = new Random(); for (int i = 0; i < 6; i++) { g2d.setColor(new Color(180 + random.nextInt(60), 190 + random.nextInt(50), 200 + random.nextInt(40))); int x1 = random.nextInt(width); int y1 = random.nextInt(height); int x2 = random.nextInt(width); int y2 = random.nextInt(height); g2d.setStroke(new BasicStroke(1f)); g2d.drawLine(x1, y1, x2, y2); } // 噪点:随机撒 80 个像素点 for (int i = 0; i < 80; i++) { g2d.setColor(new Color(150 + random.nextInt(100), 150 + random.nextInt(100), 150 + random.nextInt(100))); g2d.fillRect(random.nextInt(width), random.nextInt(height), 1, 1); } // 字符绘制:每个字符随机旋转、随机大小、随机颜色 int x = 8; for (int i = 0; i < code.length(); i++) { String ch = String.valueOf(code.charAt(i)); int fontSize = 22 + random.nextInt(8); g2d.setFont(new Font("SansSerif", Font.BOLD, fontSize)); g2d.setColor(new Color(30 + random.nextInt(80), 40 + random.nextInt(80), 60 + random.nextInt(100))); double angle = (random.nextDouble() - 0.5) * 0.9; int y = 26 + random.nextInt(5); g2d.rotate(angle, x, y); g2d.drawString(ch, x, y); g2d.rotate(-angle, x, y); x += 24 + random.nextInt(6); } g2d.dispose(); ByteArrayOutputStream out = new ByteArrayOutputStream(); try { ImageIO.write(image, "png", out); } catch (IOException e) { throw new RuntimeException("验证码图片生成失败", e); } return Base64.getEncoder().encodeToString(out.toByteArray()); }

注意两点:一个是每个字符绘制完必须马上 rotate 回去,否则后续字符会一直带着旋转偏移,位置越来越乱;另一个是 x 累加值要与图片宽度匹配,4 个字符每个间隔约 28 像素,加上左右边距,120 宽度刚好放得下,不会挤成一团。

3.4 生成接口与校验服务的完整实现

把上面两部分组装起来。生成接口的完整代码如下:

@RestController @RequestMapping("/api/captcha") public class CaptchaController { private static final String REDIS_KEY_PREFIX = "captcha:code:"; private static final long EXPIRE_SECONDS = 120; @Resource private StringRedisTemplate stringRedisTemplate; @GetMapping("/gen") public Result<CaptchaVO> gen() { String captchaId = UUID.randomUUID().toString().replace("-", ""); String code = CaptchaGenerator.generateText(4); String imageBase64 = CaptchaGenerator.generateImage(code); stringRedisTemplate.opsForValue().set( REDIS_KEY_PREFIX + captchaId, code, Duration.ofSeconds(EXPIRE_SECONDS) ); CaptchaVO vo = new CaptchaVO(); vo.setCaptchaId(captchaId); vo.setCaptchaImage("data:image/png;base64," + imageBase64); vo.setExpireSeconds(EXPIRE_SECONDS); return Result.ok(vo); } }

然后是校验服务。这个服务会被登录接口、注册接口等业务方复用,所以单独抽取成一个 Service 而不是写在 Controller 里。代码实现如下:

@Service public class CaptchaService { private static final String REDIS_KEY_PREFIX = "captcha:code:"; @Resource private StringRedisTemplate stringRedisTemplate; public void verify(String captchaId, String captchaCode) { if (StringUtils.isBlank(captchaId) || StringUtils.isBlank(captchaCode)) { throw new BizException("验证码不能为空"); } String key = REDIS_KEY_PREFIX + captchaId; // 先删除后比对,一次性消费,避免并发重放 String expected = stringRedisTemplate.opsForValue().getAndDelete(key); if (expected == null) { throw new BizException("验证码已过期,请重新获取"); } if (!expected.equalsIgnoreCase(captchaCode.trim())) { throw new BizException("验证码错误"); } } }

这里用的是 getAndDelete 方法,对应 Redis 6.2 的 GETDEL 命令。如果你们用的 Spring Data Redis 版本比较老,没有这个方法也没关系,用 get 加 delete 的组合也可以,但一定要“先删后比”,顺序不能反。

业务接口里调用就简单了,以登录为例:

@PostMapping("/login") public Result<LoginVO> login(@RequestBody LoginRequest req) { // 第一步:校验验证码,不通过直接抛异常 captchaService.verify(req.getCaptchaId(), req.getCaptchaCode()); // 第二步:执行实际的登录逻辑 return accountService.login(req.getUsername(), req.getPassword()); }

这样的调用顺序保证了验证码校验永远在业务逻辑之前执行,验证码不通过,业务代码根本不会走到下一步。

3.5 防刷策略:生成和校验两个维度的频率控制

验证码接口不设防就是裸奔。攻击者写个脚本不停调用生成接口,Redis 里就能堆积大量无用 key;或者在登录接口上暴力穷举 4 位验证码,反正只有 104 万种组合,配合高并发很快就能撞出来。所以防刷必须做,而且要做双层。

第一层是生成接口的频率限制。最简单有效的方式是对 IP 做维度限制:同一个 IP 每分钟最多调用 10 次生成接口。我用 Redis 的 INCR 加 EXPIRE 实现:

String countKey = "captcha:freq:" + ip; Long count = stringRedisTemplate.opsForValue().increment(countKey); if (count != null && count == 1) { stringRedisTemplate.expire(countKey, Duration.ofMinutes(1)); } if (count != null && count > 10) { return Result.fail("操作过于频繁,请稍后再试"); }

这里有个小细节:INCR 之后如果 count 等于 1,说明这个 key 是第一次创建,需要手动设置过期时间;如果已经存在,设置过期时间会覆盖原有的 TTL,导致窗口被无限拉长,所以只在首次创建时设置。这个写法比直接 SETNX 更简洁。

第二层是校验失败的计数限制。同一个 captchaId 只允许校验失败 2 次,超过就强制删除 Redis 中的验证码,用户必须重新获取。这个逻辑在 CaptchaService 的 verify 方法里面加几行即可:

String failKey = REDIS_KEY_PREFIX + "fail:" + captchaId; Long failCount = stringRedisTemplate.opsForValue().increment(failKey); if (failCount != null && failCount == 1) { stringRedisTemplate.expire(failKey, Duration.ofMinutes(1)); } if (failCount != null && failCount > 2) { stringRedisTemplate.delete(key); throw new BizException("验证码错误次数过多,已失效,请重新获取"); }

对用户来说,同一张验证码输错 3 次就自动刷新,体验上是能被接受的。对攻击者来说,每次穷举也需要消耗新的验证码,成本大幅提升。

4. 前端接入:组件化封装的正确打开方式

4.1 Base64 图片的两种渲染方式,推荐第一种

后端返回的验证码图片是 Base64 字符串,前端拿到之后有两种渲染方式。第一种是直接拼成 data URI 塞进 img 标签的 src 属性:

<img :src="captchaImg" alt="验证码" @click="refreshCaptcha" />

第二种是后端单独提供一个图片流接口,前端用的方式加载。第二种方式在前后端分离下多了一次网络请求,而且浏览器对图片 URL 有缓存机制,验证码刷新后如果 URL 没变,浏览器可能直接复用缓存的旧图,必须额外给 URL 拼时间戳参数,完全是给自己找麻烦。所以我强烈推荐第一种:Base64 内嵌到 JSON 响应里,前端拿到的就是一张完整的图片数据,不存在缓存问题,也没有额外的请求开销。

使用 data URI 唯一的代价是图片体积稍微大一点,一张 120×40 的 PNG 转 Base64 也就 1KB 到 2KB 左右,几乎可以忽略。

4.2 Vue 组件封装:获取、刷新、防重复提交

前端这边我习惯把验证码区域封装成一个独立组件,这样登录、注册、找回密码等所有需要验证码的页面都能复用。组件内部维护三个状态:captchaId、captchaImg、captchaCode。组件挂载时自动获取验证码,点击图片或“换一张”按钮时重新获取。完整代码如下:

<template> <div class="captcha-row"> <input v-model="captchaCode" class="captcha-input" maxlength="4" placeholder="请输入验证码" @input="handleInput" /> <img :src="captchaImg" alt="验证码" class="captcha-img" title="点击刷新" @click="refreshCaptcha" /> </div> </template> <script setup> import { ref, onMounted } from 'vue' import request from '@/utils/request' const captchaId = ref('') const captchaImg = ref('') const captchaCode = ref('') const refreshing = ref(false) // 刷新验证码:加一个简单的防重复请求锁 async function refreshCaptcha() { if (refreshing.value) return refreshing.value = true try { const res = await request.get('/api/captcha/gen') captchaId.value = res.data.captchaId captchaImg.value = res.data.captchaImage captchaCode.value = '' } finally { refreshing.value = false } } // 暴露给父组件的方法:获取当前填写的验证码和 ID function getCaptchaData() { return { captchaId: captchaId.value, captchaCode: captchaCode.value } } onMounted(refreshCaptcha) defineExpose({ getCaptchaData, refreshCaptcha }) </script>

这里有两个容易被忽略的细节:一是刷新按钮要加一个 refreshing 锁,因为用户快速点击时,会触发多个并发请求,如果上一个请求还没返回,下一个请求又发出去,最终返回的 captchaId 和页面显示的验证码可能对不上。加了锁之后,上一次请求完成前无法发起新请求,逻辑就闭环了。二是在刷新成功之后,要把输入框里的旧验证码清空,否则用户前面输入了一个旧验证码,刷新后图片变了但输入框内容还在,提交时用旧验证码比对新 captchaId 对应的答案,必然报“验证码错误”,体验很不好。

4.3 提交校验的时序问题:验证码过期了怎么处理

前端提交表单时,把 captchaId 和 captchaCode 一起放到请求体里:

const payload = { username: form.username, password: form.password, captchaId: captchaId.value, captchaCode: captchaCode.value } const res = await request.post('/api/login', payload)

如果后端返回“验证码已过期”或“验证码错误”的异常,前端要弹出提示,并在页面组件里自动刷新一次验证码。这里有条经验:错误提示要区分“已过期”和“错误”两种情况。过期说明用户停留时间太长,直接刷新验证码即可;错误说明用户输错了,可以只清空输入框,不用刷新图片。如果后端把所有失败原因都统一返回“验证码错误”,用户就不知道自己是输错了还是过期了,而且每错一次就要重新看一张新图,体验很差。所以我在后端的异常设计里特意区分了这两种情况,前端也做了对应处理。

还有一个容易踩的坑:在登录按钮的 loading 状态里,很多团队会把验证码刷新也一起绑到按钮 loading 上,导致登录请求还在处理中,用户点击验证码图片却没反应。正确的做法是刷新验证码的 refreshing 状态和登录按钮的 loading 状态彼此独立,验证码刷新不应被登录请求阻塞。

4.4 常见前端边界情况:输入框限制、自动填充、键盘事件

验证码输入框有几个容易被忽略的边界情况。一是输入长度要限制成后端实际的字符长度,4 位验证码就把 maxlength 设为 4,并且过滤非字母数字字符。二是如果页面是登录页,浏览器很可能会自动填充用户名密码,但绝不会自动填充验证码,所以不要给验证码输入框设置 autocomplete 属性,否则浏览器可能把它当成表单字段自动填一些奇怪的内容。三是键盘事件,用户输完 4 位验证码后如果焦点还停留在输入框,回车提交会触发表单原生的 submit 行为,这里要记得用 @keyup.enter 捕获后调用登录方法,并 preventDefault 阻止表单默认提交,不然会出现页面刷新、请求丢失这种诡异问题。

5. 常见问题排查与经验总结

5.1 一张速查表解决八成验证码问题

现象可能原因排查方向与解法
验证码图片显示不出来后端返回的 Base64 拼接异常检查 captchaImage 字段是否包含 data:image/png;base64, 前缀,前端 img src 是否直接绑定了该字段
每次刷新后提交都提示验证码错误前端刷新请求和显示图片的 captchaId 不一致检查网络面板中生成接口的响应,确认页面展示的图片和当前组件里的 captchaId 是否同一批
并发请求下同一个验证码能多次通过校验时先 get 后 delete,存在竞态条件改成先删除后比对,或者用 GETDEL 一步完成
多实例部署后验证码偶尔失效验证码存在单机内存或本地缓存统一迁移到 Redis,确认所有实例连接同一个 Redis 实例或集群
同一用户连续刷新后旧验证码还能用UUID 作为 key,多个验证码共存这是设计预期行为,无需处理,旧验证码会在 TTL 到期后自动清理
验证码图片被浏览器缓存导致显示旧图使用了图片 URL 而非 Base64全部改用 Base64 data URI,不要用独立的图片流接口
输错三次后验证码还能继续提交未做失败次数计数在 verify 方法中增加失败计数,超过次数后删除 key

5.2 一次线上小事故复盘:验证码重放引发的思考

之前在某公司做一个注册接口时,因为赶进度,我把验证码校验逻辑写成了“先比对后删除”。当时想着“比对成功才删除,比对失败说明验证码还有机会重试”,听起来很合理。上线第二天安全扫描报告就提示注册接口存在验证码重放风险:攻击脚本用同一个验证码并发 20 个请求,结果 15 个都通过了验证码校验,直接注册了 15 个账号。

复盘下来问题出在两点:一个是对“并发安全”的敏感度不够,总觉得验证码这种东西又不是支付口令,谁会针对它做并发绕过,结果恰恰是验证码重放成了垃圾注册的典型攻击路径;另一个是 Redis 操作没有利用原子性,把 get 和 delete 拆开,中间的并发窗口被利用。修复方式就是我前面写的那一版,先删后比,一行代码改动就堵住了漏洞。这个教训之后,我给自己定了一条规矩:涉及“一次性消费”的数据,一律通过原子操作完成状态流转,绝不用“先读后写”的朴素思维。

5.3 调试验证码模块的几个实用技巧

排查验证码问题时,不要光盯着前端界面看。我一般按下面三步走:

第一步,看 Redis。登录 Redis 执行 keys captcha:code:*,确认 key 是否存在、过期时间是多少。TTL 查一下:

redis-cli keys captcha:code:* ttl captcha:code:9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c get captcha:code:9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c

如果 key 不存在,说明要么没调生成接口,要么已经被消费掉了。如果 TTL 是 -1,说明设置过期时间那步漏了,排查代码中 set 是否少了 Duration 参数。

第二步,看响应体。打开浏览器 Network 面板,找到生成接口的请求,直接看响应 JSON 里的 captchaImage 字段是否完整复制出了 Base64 字符串,可以随便找一个在线 Base64 转图片工具还原看看图片是否正常。如果后端在存储时不小心把答案打到了日志里,那就要检查 logback 配置,对验证码相关字段做脱敏处理,我通常会在生成接口的日志里只打印 captchaId 不打印 code 值。

第三步,看请求时序。用前端组件跑一次完整链路:获取验证码、输入答案、提交登录。在 Network 面板里核对登录请求的 payload 中 captchaId 是否与刚才生成接口返回的一致。这个步骤能快速定位是前端组件状态错乱还是后端存储错乱。

5.4 日志脱敏与安全意识

验证码虽然生命周期短,但日志脱敏还是必须做的。很多团队记录请求日志时,习惯把整个请求体打出来,这时如果请求体里包含了 captchaCode,验证码答案就直接写进日志文件了。运维人员能看见没问题,但日志有可能被同步到日志分析平台、被采集工具读取,攻击者一旦拿到日志权限就能直接分析出答案。我的做法是:全局日志过滤器里对字段名 captchaCode、password、confirmPassword 等做脱敏替换,统一打成 ******,不管哪个接口都不允许把验证码答案写进日志。另外,Redis 里验证码 key 的命名也要规律统一,便于排查时一眼从 keys 命令里定位,同时也方便做批量清理。

从整个方案来看,验证码模块本身不大,但涉及的点很碎:存储选型、原子消费、防刷、日志脱敏、前端状态管理,每个点都能写出一个独立的教训。合理的设计应该是“简单、可解释、无竞态”,而不是堆砌复杂的技术。如果你正在做类似的项目,我建议先把“唯一标识、过期时效、单次消费”这三个诉求想透,再结合 Redis 的原子操作去设计,这个模块就算做扎实了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 23:46:43

ERP进销存系统源码实战:MySQL表设计、库存流转与部署避坑

简介&#xff1a;这是一套适用于中小企业业务流程信息化建设的开源ERP进销存系统源码包&#xff0c;整合采购、销售、库存与财务模块&#xff0c;并基于MySQL提供数据支撑。系统采用Web前后端交互架构&#xff0c;源码可自由二次开发&#xff0c;适合Java、JSP方向学习者深入理…

作者头像 李华
网站建设 2026/10/14 23:45:36

昇腾AI训推一体化:打通大模型从训练到上线的最后一公里

大模型落地的过程里&#xff0c;很多团队都卡在同一个地方&#xff1a;训练环境和推理环境完全割裂。模型在训练集群上跑得好好的&#xff0c;到了推理阶段又要重新适配硬件、重做算子优化&#xff0c;甚至因为显存和算力分配不合理&#xff0c;导致训练完的模型迟迟上不了线。…

作者头像 李华
网站建设 2026/10/14 23:45:25

Axure RP CRX 0.6.3 Chrome插件:让本地原型交互不失效

简介&#xff1a;这是一份面向原型设计师、产品经理及前端开发者的 Axure RP Chrome 插件资源包&#xff0c;版本为 0.6.3。Axure RP 常用于快速构建线框图和交互原型&#xff0c;而通过这款 CRX 插件&#xff0c;可将原型预览直接嵌入谷歌浏览器&#xff0c;省去在编辑器和浏览…

作者头像 李华
网站建设 2026/10/14 23:45:21

Windows下Apache Superset安装汉化全攻略:避坑指南与实践

简介&#xff1a;基于Windows环境的SuperSet安装与汉化说明文档&#xff0c;面向需要快速搭建BI可视化平台的数据分析师、开发工程师及运维人员。文档以步骤化方式&#xff0c;覆盖Python/pip依赖准备、pip install superset、管理员账号创建、数据库升级初始化、示例数据加载、…

作者头像 李华
网站建设 2026/10/14 23:44:24

开源Claude Code Skill盘点:如何让AI自动剪辑视频、挖掘冷门宝藏

第一次让 Claude Code 帮我剪出一条完整的口播视频&#xff0c;我盯着成品愣了半天&#xff1a;转场、字幕、背景音乐居然全都齐了&#xff0c;中间没有任何人手写的剪辑逻辑&#xff0c;我只是往对话里压了一条指令。秘诀不在模型变聪明了&#xff0c;而是我提前给它装了一套开…

作者头像 李华
网站建设 2026/10/14 23:42:20

系统提示词成本优化实战:从1.4万字符压到200,Token账单降85%

先给大家看一组触动过我的数字&#xff1a;某个周一的上午&#xff0c;我打开后台的Token消费看板&#xff0c;发现单日费用高得离谱&#xff0c;而最扎眼的是“输入Token”这一栏——它占了总账单的四成。顺着调用日志排查&#xff0c;罪魁祸首很快浮出水面&#xff1a;系统提…

作者头像 李华