ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot3整合行为验证码,10分钟搞定登录防刷与暴力破解

SpringBoot3整合行为验证码,10分钟搞定登录防刷与暴力破解 做后端这行谁没被“撞库”和“暴力破解”恶心过我手上一个SpringBoot3项目登录接口上线不到一周安全日志里就全是某个IP段发起的密码尝试请求数据库里莫名多了一堆无意义的“待激活”账号。老办法上了字符验证码结果用户嫌难认OCR脚本分分钟绕过白搭。后来把方案换成了行为验证码配合SpringBoot3把登录入口重新武装了一遍效果立竿见影。这篇文章就把这套“10分钟落地”的接入过程、防刷组合策略和几个真正会卡住你的坑全部摊开讲适合正在用SpringBoot3做Web项目、又不想被脚本刷爆登录接口的Java后端同学参考。1. 为什么业务系统需要单独接一道“行为验证码”1.1 从一道被OCR干翻的老式验证码说起传统图形验证码的逻辑很简单服务端生成一张扭曲的字符图片用户输入图片里的内容对了就放行。放在五年前还能拦住一批脚本现在基本形同虚设。主流的OCR开源引擎配合深度学习模型识别准确率已经高得吓人尤其是那种没有干扰线、背景干净的纯字符图识别速度比人眼还快。至于算术题、文字点选这类的老方案也早就有对应的打码平台和打码工场在批量处理成本低到可以忽略。更要命的是体验问题。我见过不少用户卡在“看半天认不出验证码”这一步输错三次直接关页面走了。移动端小屏幕上扭曲字符的误触率更是高得离谱。也就是说图形验证码既没挡住攻击者又把正常用户挡在了门外两头不讨好。行为验证码解决的正是这个矛盾。它不再让用户“认图”而是让用户“做动作”——拖动滑块拼图、按顺序点选文字甚至某些无感模式下用户根本感知不到验证的存在。机器脚本模拟人类的轨迹和点击行为很难做到完全一致这个差异就是行为验证码的立身之本。从用户侧看滑动一下鼠标就能通过比输四位扭曲字符体感舒服太多从服务端看拦截脚本刷接口的效果比静态图片码高一个量级。1.2 行为验证码到底防的是什么先说清楚一个概念行为验证码不是万能的它防的是“自动化脚本攻击”不是“人工恶意操作”。具体到业务场景主要有几类撞库与暴力破解攻击者拿泄露的账号密码批量尝试登录或者对某个账号做高频密码猜测。行为验证码要求每次登录前先完成滑动/点选脚本没法低成本地批量处理这一步攻击成本立刻翻倍。短信与邮件轰炸注册、找回密码这类会触发短信验证码的接口经常被脚本用来批量触发导致短信费用爆炸。在这些接口前加一道行为验证能挡住绝大多数批量调用。薅羊毛与批量注册活动抽奖、新用户立减金这类福利接口脚本会注册大量小号来套取权益。行为验证能显著提高批量注册的“人力成本”让自动化方案变得不划算。在SpringBoot3项目的整体防刷架构里行为验证码的位置非常靠前。它不是最终防线而是第一道闸门负责把自动化流量和真人流量区分开。闸门后面还有限流、账号锁定、风控规则等一系列措施。很多团队只接了一个验证码就以为万事大吉其实后续的配套策略更重要这个我在第6部分详细展开。2. 主流选型自研、第三方SaaS还是开源自部署2.1 三类方案的横向对比接入之前先聊选型这一步很多人忽视直接决定后期投入的精力。自研行为验证码是最不建议的路线。原因很简单行为验证的本质是“让机器难以伪造人类行为”这需要海量样本数据来训练和优化识别模型还需要对抗黑产持续升级的攻击手段。一个业务团队做出来的轨迹识别模型面对专业打码平台基本是裸奔状态。更别提前端还需要适配不同端的轨迹采集、指纹采集工作量极大效果还未必达标。第三方SaaS是大部分企业的选择。极验、腾讯防水墙、阿里云验证码这些厂商都提供了完整的前后端SDK接入速度快识别模型有厂商持续维护效果有保障。缺点是商业化产品按调用量计费量大的时候成本不低同时验证服务部署在厂商侧数据和流量会经过第三方对数据合规要求严格的团队需要多评估一层。开源自部署是我个人比较偏好的中间路线。以AJ-Captcha为代表它提供了完整的服务端和前端实现可以部署在自己服务器上数据不出内网没有按量计费的问题还可以按业务需求改源码。效果上虽然识别模型不如商业SaaS那么“聪明”但对绝大多数中小团队的防刷需求完全够用。我做了一张选型对比表你可以根据自己的实际约束来判断方案初次接入成本长期费用识别能力数据合规维护压力自研极高极低弱完全自主重第三方SaaS低按量计费强数据过第三方轻开源自部署中零中完全自主中2.2 为什么我选AJ-Captcha我对AJ-Captcha的评估结论是性价比和可控性的平衡点最好。它在Gitee/GitHub上开源社区活跃支持两种验证模式——滑动拼图blockPuzzle和文字点选clickWord并且内置了缓存接口抽象默认走本地内存缓存也可以接Redis。这对已经使用Redis做会话管理的SpringBoot3项目来说非常顺滑。接口设计也很简洁核心就两个获取验证码的/captcha/get以及校验结果的/captcha/check。前端有官方提供的vue组件同时支持H5移动端适配不费劲。更关键的一点是AJ-Captcha的授权方式对商用友好公司内部使用不用太担心授权风险。综合下来如果你需要一套能自己掌控的验证码服务它是最值得先花一小时试跑的方案。3. SpringBoot3环境里最容易被忽略的三个前置问题3.1 JDK17与javax/jakarta命名空间切换SpringBoot3相比SpringBoot2最大的隐性变化是底层从javax.*迁移到了jakarta.*命名空间同时强制要求JDK17及以上。这两个变化对使用老版本第三方库的项目来说非常致命因为很多在SpringBoot2时代正常工作的starter组件在Boot3环境下会直接抛出ClassNotFoundException或者因为包名不一致出现各种诡异的运行时报错。行为验证码的接入同样面临这个问题。网上能搜到的大部分AJ-Captcha接入教程都是基于SpringBoot2写的它们引入的captcha-spring-boot-starter依赖是为Boot2构建的直接搬进Boot3项目大概率翻车。我当时的处理方式很直接不用starter直接把源码里核心的CaptchaService和两个Controller接口逻辑拿出来维护这样命名空间完全由自己控制反而更干净。这里给个实操建议不管是AJ-Captcha还是其他扩展组件在SpringBoot3项目里引入前先确认它的pom.xml依赖和编译目标。如果发现它的依赖树里还是javax.servlet基本可以断定没有适配Boot3要么手动迁移要么换方案。3.2 依赖冲突log4j2、logback与验证码框架的日志摩擦SpringBoot3项目里日志框架的选择本身就有讲究。默认SpringBoot使用的是Logback但有些团队为了性能会切换成Log4j2。行为验证码框架如果内部用SLF4J写日志理论上和你选的日志实现是解耦的问题出在版本冲突上。我踩过的具体情况是项目里为Log4j2配置了log4j2-spring.xml但引入验证码依赖时传递依赖里带了一个老版本的logback-classic导致SLF4J绑定冲突运行时报出“SLF4J: Class path contains multiple SLF4J bindings”之类的提示某个日志配置直接失效。排查方法也简单用mvn dependency:tree看传递依赖把多余的logback依赖排除掉或者统一用spring-boot-starter-log4j2替换默认日志starter。至于另一种场景——如果你用的是Logback则要处理logback-spring.xml里验证码请求的日志噪音问题。验证码接口QPS很高默认DEBUG级别的日志会把大量验证请求刷进日志文件一晚上能涨几个GB。我后面专门在logback-spring.xml里加了过滤器把/captcha/get和/captcha/check这个路径的访问日志单独降级或直接剔除详细配置见第7部分。3.3 Redis序列化器对验证码缓存的影响行为验证码的数据必然要解决服务端存储问题。AJ-Captcha支持通过CaptchaCacheService接口自定义缓存实现。如果你用Redis存储验证码信息序列化器的选择会直接影响校验逻辑。默认的RedisTemplateObject, Object使用的是JDK序列化Redis里存的key是\xAC\xED\x00\x05t\x00开头的一串乱码问题不大但key中包含的captcha:前缀会被序列化掉导致排查Redis数据时看着很别扭。更推荐的做法是单独配置一个StringRedisTemplate实例来存取验证码信息key和value都用String类型后续调试、设置TTL、查看过期情况都一目了然。实际设置TTL时要注意验证码的过期时间不能太短我见过有人设成30秒用户刚拖动完滑块后端校验时就提示“验证码已过期”。比较合适的区间是60秒到120秒既能防止验证码被长时间复用又不至于误伤正常用户。这个值在AJ-Captcha里可以通过配置属性调整我用的配置是120秒。4. 后端接入核心链路初始化、二次校验、结果透传4.1 初始化后端把验证码参数发出去整个行为验证码的流程从用户的视角看是“打开页面拖动滑块进入登录”但从代码链路看实际上是后端先发包前端再交互最后后端再校验收尾。第一步是后端提供一个获取验证码的接口。AJ-Captcha的/captcha/get接口入参通常是captchaType指定blockPuzzle还是clickWord、clientUid用户标识、ts时间戳。后端处理时会先生成一张背景图和一块拼图记录拼图的正确位置坐标生成一个唯一标识token然后把图片和token返回给前端。我在项目里没有直接用AJ-Captcha自带的Controller而是自己封装了一层原因有两个一是自带的Controller返回结构比较固定我想统一包一层业务响应体二是需要在返回前把token做一次签名处理防止被篡改。这里贴一段核心封装逻辑RestController RequestMapping(/captcha) public class CaptchaController { Resource private CaptchaService captchaService; GetMapping(/get) public ResultCaptchaVO getCaptcha(RequestParam String captchaType) { CaptchaVO captchaVO new CaptchaVO(); captchaVO.setCaptchaType(captchaType); // 这两个参数用于后续校验时需要回传客户端会原样带回 captchaVO.setClientUid(IdUtil.fastSimpleUUID()); captchaVO.setTs(System.currentTimeMillis()); // 底层会根据 captchaType 生成对应的验证码数据 CaptchaVO result captchaService.get(captchaVO); // 业务层在此对 result.getToken() 做额外处理例如绑定客户端IP return Result.ok(result); } }这里有个容易被忽略的细节clientUid和ts不是随便带的它们会在后续校验接口里被回传服务端可以用ts校验请求时效用clientUid做用户级数据隔离。如果你在网关层按IP做了限流还可以在获取验证码时就把IP和token绑定到Redis里后续校验时比对IP是否一致能进一步降低验证码被盗用的风险。4.2 二次校验滑动结束后的那个请求用户把滑块拖到目标位置后前端会把本次操作产生的行为轨迹数据包括拖动坐标序列、耗时、偏移量和token一起发回后端的/captcha/check接口这就是第二次校验。AJ-Captcha在这里做的核心事情有两件一是检查token是否存在且未过期二是判断用户提交的拼图位置是否在允许的误差范围内。/captcha/check接口的入参是captchaType、pointJson、token。pointJson里包含用户最终停留的坐标点服务端会和生成验证码时记录的原始坐标做对比横纵坐标的绝对偏差同时小于某个阈值才算通过。校验通过后服务端会返回一个新的validate凭证这个凭证才是后续业务接口要真正使用的“通行证”。我自己封装校验接口时在AJ-Captcha之上还加了一个步骤校验通过后把validate凭证写入Redis设置一个较短的有效期比如90秒同时绑定token保证一次一用。业务接口拿这个validate过来时我先查Redis是否存在存在才放行并立即删除该凭证。这个“一次性票据”的设计在后面第6部分讲防刷策略时还会提到它能解决验证码结果被重放的问题。4.3 业务接口中如何拿到“验证通过”的凭证做到这一步很多人的疑问是/captcha/check校验通过之后登录接口还要不要再校验一次答案是必须。因为验证码和登录是两步操作攻击者可以手动完成一次验证码拿到合法的校验结果然后把这个结果抓到脚本里重放绕过验证码直接打登录接口。所以正确的时序是请求先到验证码校验通过后拿到一次性validate凭证请求再带着用户名、密码、validate一起打到登录接口登录接口第一件事就是检查validate的合法性。这里有两个实现方案我对比过之后推荐方案B方案A登录接口内部调用captchaService.check再做一次验证码校验。坏处是校验逻辑重复执行验证码服务耦合在登录逻辑里职责混乱。方案B验证码校验独立成前置接口通过后发放一次性validate票据登录接口只验票据。这样职责清晰验证码服务和登录业务完全解耦也方便后续和网关限流做组合。我在项目里用的方案B登录接口的入口处做了一个注解拦截凡是标注了CaptchaRequired的接口都会先校验请求头里的validate字段Component public class CaptchaValidateInterceptor implements HandlerInterceptor { Resource private StringRedisTemplate stringRedisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 前后端约定验证码校验通过后validate凭证放在 header 里 String validate request.getHeader(X-Captcha-Validate); if (StrUtil.isBlank(validate)) { throw new BizException(缺少验证码凭证); } // 从 Redis 查凭证存在则立即删除保证一次性 Boolean removed stringRedisTemplate.delete(captcha:validate: validate); if (!Boolean.TRUE.equals(removed)) { throw new BizException(验证码凭证无效或已过期); } return true; } }这套拦截器的好处是登录、注册、找回密码、发送短信验证码这几个高风险接口全部一键加上验证码校验不用每个接口都写重复逻辑。5. 前端触发与校验流程滑动拼图、点选文字、二次时序5.1 前端两种模式的实际体验差异AJ-Captcha的前端组件支持两种模式对应后端的captchaType参数。实际使用中这两种模式的体验和适用场景差别不小。blockPuzzle就是大家最常见的滑动拼图一张背景图被裁剪出一块拼图形状用户按住滑块拖到正确缺口位置即可。它最大的优势是用户认知成本为零——主流App和网页都在用这个形式几乎不用引导。缺点是背景图能否有效区分“真人拖动”和“脚本拖动”完全取决于滑块与缺口的贴合度以及轨迹特征采样质量。clickWord则是给出几个汉字用户按顺序点击句子中对应的字。这种模式在PC端上体验还行但在移动端小屏上文字偏小、点击精准度不高误触率明显上升。它更适合作风控升级后的二次验证或者对安全性要求更高的场景比如找回密码、修改手机号这类敏感操作。我的建议是不要只配一种模式。登录页面用blockPuzzle保持低摩擦触发敏感操作比如更换绑定手机号、提现时切换到clickWord增加一步验证成本。AJ-Captcha的前端组件支持动态切换captchaType前后端约定好即可。5.2 前端引入与参数回传前端接入在官方文档里写得比较清楚我在这里补充几个容易忽略的实操点。使用官方Vue组件时通常是这样初始化的import { Captcha } from aj-captcha const captcha new Captcha({ element: #captcha-box, captchaType: blockPuzzle, mode: pop, // float: 浮动, pop: 弹出, fixed: 固定 // 获取验证码接口由后端二次封装后的路径 captchaId: , verifyBarColor: #3370ff, apiCodeRequest: (data) { // 向后端 /captcha/get 发起请求 return request.post(/captcha/get, data) }, apiCodeCheck: (data) { // 向后端 /captcha/check 发起请求 return request.post(/captcha/check, data) }, success: (res) { // 校验成功后拿到 validate 凭证 const { validate } res // 把 validate 存入 sessionStorage后续登录请求时带上 }, fail: (err) { // 校验失败组件会自动刷新验证码 console.error(验证失败, err) } })有几个前端细节值得注意。一是mode选择弹窗模式pop不会打断用户的页面操作体验优于固定模式但要注意移动端弹窗的样式适配二是验证码组件内部的get/check请求走的是自定义接口意味着组件本身不关心后端实现只要保证两个接口的出入参格式和AJ-Captcha约定一致就行三是success回调里拿到的validate一定要临时存起来而不是每次现取因为第二次校验返回的validate只在很短时间窗口内有效。5.3 校验时序三个请求谁先谁后把前后端串起来看完整的请求链路是三个请求依次发生前端加载验证码组件时向后端发GET /captcha/get拿到背景图、滑块图、token。用户完成拖动/点选后前端向后端发POST /captcha/check携带pointJson和token校验通过后拿到validate。用户点击登录前端把用户名、密码、validate一起发到业务登录接口登录接口先校验validate再走账号密码认证。这三步合在一起才是行为验证码完整的一套闭环。很多团队只做了前两步第三步漏掉了结果验证码形同虚设。攻击者只需要人工过一步拿到validate后写进脚本里循环重放验证码这道闸就直接失效了。这也是我在4.3里反复强调一次性票据的原因——validate必须绑定一次性使用用完即焚不留重放窗口。6. 登录接口的防刷防暴力破解组合策略验证码只是第一道闸6.1 先想清楚验证码失效之后你的接口还靠什么行为验证码把九成以上的脚本流量挡在了门外但剩下的一成依然需要处理。比如攻击者用真人众包的方式过验证码或者用真实浏览器内核的框架模拟行为这些高级攻击手段不是验证码本身能解决的。换句话说验证码是防刷的开始而不是结束。登录接口的防护必须是一个分层的组合策略。我的经验是按照“来源可信度”从高到低排列防护强度正常可信用户走最轻的校验路径可疑来源逐步加码从IP限流到账号锁定再到强制二次验证确认恶意来源直接拉黑并告警。每一层策略之间要能够独立开关方便灰度调整。6.2 RedisLua实现登录接口限流限流是除验证码之外最重要的一道防线。SpringBoot3项目里最直接的做法就是基于Redis做应用层限流用Lua脚本保证计数的原子性。我用的方案是结合固定窗口和滑动窗口两种模式固定窗口适合对单个IP的单日总请求数做限制滑动窗口适合对某个账号在一分钟内的高频尝试做精细控制。这里是一个基于SpringBoot3 Redis的滑动窗口限流Lua脚本-- key: 限流key例如 rate:ip:1.2.3.4 -- argv[1]: 窗口大小毫秒 -- argv[2]: 窗口内最大请求数 local key KEYS[1] local window tonumber(ARGV[1]) local limit tonumber(ARGV[2]) local current redis.call(TIME)[1] * 1000 redis.call(TIME)[2] / 1000 -- 用 ZSET 存储请求时间戳移除窗口外的记录 redis.call(ZREMRANGEBYSCORE, key, 0, current - window) local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, current, current) -- 为滑动的 key 设置过期时间避免冷数据堆积 redis.call(PEXPIRE, key, window) return 1 else return 0 end在Java侧封装一个RateLimit注解通过AOP统一处理。在实际项目中我把限流分成了两个维度IP维度和账号维度。IP维度用于拦截同一个出口IP的高频调用账号维度用于拦截对某个特定账号的持续猜测。两个维度独立计数后者需要账号字段在请求中可解析通常在登录Service的入参里获取。6.3 账号维度锁定与异常告警限流解决的是“短时间内大批量打”的问题账号锁定解决的是“低频长时间试探”的问题。暴力破解经常是慢速的每分钟试三五个密码IP限流很难命中这时候账号维度的策略就要跟上。我在项目里的实现是每个账号维护一个Redis计数器记录连续登录失败的次数失败一次加一同时设置过期时间。当失败次数达到5次时锁定该账号15分钟期间输入正确密码也拒绝登录。密码正确时清除失败计数。这个策略很朴素但效果非常稳定。另外还有一个小技巧值得分享不要对所有账号一视同仁地触发验证码。正常用户的浏览器和设备指纹是稳定的如果同一个用户短时间内多次触发验证码说明可能有人在自动化模拟。我在验证码触发策略上做了分级风险等级触发条件校验强度低首次登录、固定设备默认不弹验证码中切换IP、异常时间登录滑动拼图高连续失败超过3次、触发频率高文字点选 账号临时锁定实际落地后正常用户的登录路径基本不会碰到验证码体验几乎没有影响而脚本和暴力破解在第一个高风险账号登录尝试时就会被卡住攻击成本显著上升。7. 实战踩坑记录从logback-spring.xml到校验失败率飙升7.1 坑一反向代理下面限流拿到了同一个IP上线第一天就发现一个诡异现象所有限流规则全都没生效仔细一查所有请求拿到的IP都是Nginx的内网地址。原因很简单我用的SpringBoot3服务在Nginx后面直接读request.getRemoteAddr()拿到的当然是代理服务器IP而不是真实客户端IP。解决方案是加一个过滤器从X-Forwarded-For或X-Real-IP头中提取真实IP并把它放进ThreadLocal或者请求上下文里供后续的限流AOP和日志模块复用。要提醒的是X-Forwarded-For头本身可以被客户端伪造所以必须保证只有可信反代层才能设置这个头服务端取的时候要取最右侧追加的IP而不是最左侧。7.2 坑二验证码过期时间设置太短有段时间运营反馈用户登录时“滑动完提示验证失败”排查发现验证码缓存TTL设成了30秒。用户打开登录页、停留几秒、再滑一下时间就超过了30秒后端校验时缓存已过期直接判失败。这个坑其实很好避免但容易被忽视。验证码的过期时间要考虑的是“用户从看到验证码到完成操作”的最长合理时间而不是你希望验证码多久刷新一次。我后来统一把captcha.get返回的验证码TTL调整为120秒再把校验通过后的validate票据独立设置90秒有效期。前者给用户留足操作时间后者严格控制重放窗口两者各司其职。7.3 坑三滑块轨迹校验误伤正常用户接入初期发现一个反常数据滑块校验失败率达到了15%而且集中出现在移动端。查看后端校验日志发现大量失败原因是“轨迹坐标偏移超限”。进一步排查移动端部分低端手机上触摸采样率不足手指滑动再快上报的轨迹点也比较稀疏偏移量一放大就超过了阈值。AJ-Captcha的轨迹校验逻辑是通过前端上报的坐标序列和耗时计算相似度的对轨迹点数量有一定要求。解决思路有两个方向一是调低相似度阈值给移动端留出更多容错空间二是关闭某些过于严格的干扰选项比如滑块干扰轨迹。我最终采用的是动态阈值策略PC端保留严格阈值移动端按User-Agent识别后自动放宽。调整之后移动端校验失败率降到了3%左右基本和PC端持平。7.4 坑四日志爆炸logback-spring.xml里的一行配置验证码接口接入后日志量以肉眼可见的速度暴涨。原因不复杂/captcha/get和/captcha/check是高QPS接口每个人打开登录页都会触发至少两次请求即使不打DEBUG日志INFO级别的出入参记录在高峰期也足以把磁盘撑爆。排查后我在logback-spring.xml里针对这两个路径单独降级了日志级别。需要注意Logback的默认配置是按全局级别来的想要单独控制某个路径可以用Logger标签配合filter实现。配置方式大致如下springProfile nameprod logger namecom.captcha levelWARN additivityfalse appender-ref refFILE_ERROR/ /logger /springProfile更进一步的做法是给验证码接口的访问日志单独建一个文件和业务日志分离后续排查验证码问题的时候直接看这个文件不会被其他日志干扰。这套小改造做完之后日志磁盘增长问题彻底解决排查验证码相关问题也更快了。最后再分享一个经验行为验证码接入本身不难难的是把它嵌进整体的防刷体系里。验证码、限流、账号锁定、会话票据每一环单独拿出来都不复杂但组合在一起才能形成真正的防御纵深。如果你正在SpringBoot3项目里做登录安全改造建议按这个顺序逐步落地边上线边观察误杀率不要一次性把闸门全部开到最严。
返回列表