简介:本资源面向Java后端与全栈开发者,提供一套可直接用于生产环境的用户行为验证码方案,涵盖点击中文文字图片验证码与拖动/滑动图片验证码两种形态,适合需要提升登录、注册等场景防刷能力的中高级开发者参考落地。压缩包共178个文件,约8.92MB,以68个java源码为核心,辅以xml配置、png/jpg/gif图片素材、css与js前端资源及yml配置文件,结构完整、开箱即用。资源基于JDK1.8、Spring Boot 2.1.17与Redis构建,图片生成依赖AWT的BufferedImage与Graphics2D,坐标传输采用AES或DES加密,前端结合点击坐标、图片位置与滚动偏移计算相对位置。目前已有3079人学习下载,配套demo、单元测试与实现思路说明,可帮助读者快速理解随机中文文字生成、随机抠图拼图及拖动校验的完整链路,并对照博文排查部署与运行问题。
1. 从一次登录被刷说起:这套 Java 行为验证码源码能顶什么用
上个月帮朋友看一个后台,登录接口一晚上被撞了四万多次,账号密码字典跑得飞起。他之前用的是四位字符图形码,OCR 识别率早就被喂到九成以上,等于没设防。我给他换了一套行为验证码,跑了两周,撞库请求直接掉到个位数。这套东西就是今天要拆的:Java 实现点击中文文字验证码与拖动/滑动图片验证码,带源码、demo、单元测试和实现思路。
它不是那种调第三方接口的封装壳子,而是把图片生成、坐标校验、Redis 存取、前后端坐标加密整条链路都摊开给你看的工程。适合两类人:一类是正在做登录注册风控、想自己掌控验证码逻辑的后端;另一类是准备 Java 面试、想搞明白「验证码到底怎么防机器」的开发者。整套跑起来只要 JDK1.8、Maven、一个 Redis,导入 IDEA 选 Maven 项目就能开工。
2. 环境落地:从导入到两个 Demo 跑起来
2.1 依赖版本与工程结构先对齐
这套源码对版本卡得比较死,不是它矫情,是 Spring Boot 2.1.x 和 JDK1.8 的组合在 AWT 图片处理上最稳。你要是拿 JDK17 去跑,BufferedImage相关的字体渲染在某些 Linux 发行版上会直接抛HeadlessException,这个坑后面避坑章节会细说。
先把环境清单摆出来,照着核对一遍再动手:
| 组件 | 版本要求 | 说明 |
|---|---|---|
| JDK | 1.8 | 不要用 11/17,AWT 字体渲染差异大 |
| Maven | 3.3+ | 打包用,低版本对 Spring Boot 2.1 插件支持差 |
| Spring Boot | 2.1.17.RELEASE | 源码锁定版本,别乱升 |
| Redis | 任意稳定版 | 存验证码坐标和过期时间,必须能连上 |
工程是标准的 Maven 多模块结构,demo是主启动模块,里面有两个启动类:ClickCaptchaApplication对应点击文字验证码,DraggedCaptchaApplication对应滑动图片验证码。两个 Demo 共用一套图片生成和 Redis 工具,区别只在前端交互和坐标校验逻辑。
导入的时候有个细节:IDEA 里选File -> New -> Project from Existing Sources,然后务必选 Maven,不要选 Gradle 或者 Eclipse。选错了依赖树拉不起来,layui.css、jquery-ui.css这些前端资源虽然不影响编译,但页面会裸奔。
2.2 改 Redis 地址并打包
源码里 Redis 配置在demo子模块的resources/application.yml,默认写的是本地地址。你要连自己的服务器,改这几行:
spring: redis: host: 你的Redis地址 port: 6379 password: 你的密码 database: 0 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0timeout建议别低于 3000ms,验证码校验是同步阻塞的,网络抖一下超时太短会直接判失败,用户体感就是「明明点对了却说错」。database用 0 就行,别和业务库混用,验证码 key 过期频繁,单独库好排查。
改完执行打包命令:
mvn package -Dmaven.test.skip=true-Dmaven.test.skip=true是跳过测试编译和运行,第一次跑建议先加上,等环境通了再单独跑单元测试。打包成功后demo/target下会有可执行 jar,但源码设计是直接跑启动类,所以你也可以在 IDEA 里右键ClickCaptchaApplication选 Run。
2.3 两个 Demo 的启动与验证
先起点击文字验证码:
# 方式一:IDEA 直接运行 ClickCaptchaApplication # 方式二:命令行 java -jar demo/target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=click启动后访问http://localhost:8080,页面上会出现一张带四个中文文字的图片,按顺序点击正确文字即可通过。再起滑动验证码,运行DraggedCaptchaApplication,页面变成一张带缺口的主图加一个滑块,拖动滑块对齐缺口。
两个 Demo 的验证码数据都写进 Redis,key 格式类似captcha:click:{uuid},过期时间默认 120 秒。你可以用redis-cli查一下:
redis-cli > keys captcha:* 1) "captcha:click:8f3a2b1c-..." > ttl captcha:click:8f3a2b1c-... (integer) 118看到 key 和 TTL 说明链路通了。如果 key 不存在,八成是 Redis 没连上或者序列化配置有问题,先看启动日志有没有Unable to connect to Redis。
3. 点击文字验证码:BufferedImage 生成与坐标校验
3.1 随机中文文字与随机位置怎么生成
点击文字验证码的核心是「让机器不知道点哪几个字、按什么顺序点」。源码里用BufferedImage和Graphics2D画底图,再用Font渲染随机中文。文字库一般放几十到上百个常用汉字,每次随机抽 4 个作为答案,再混入若干干扰字。
关键代码逻辑大概是这样:
// 生成底图 BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); // 抗锯齿,不然文字边缘锯齿严重,OCR 反而好识别 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 填充背景 g.setColor(new Color(240, 240, 240)); g.fillRect(0, 0, width, height); // 随机抽 4 个答案字 + 若干干扰字 List<String> answerChars = randomPick(chinesePool, 4); List<String> allChars = new ArrayList<>(answerChars); allChars.addAll(randomPick(chinesePool, 6)); Collections.shuffle(allChars); // 逐个画字,记录每个字的坐标和宽高 List<CharPos> positions = new ArrayList<>(); for (String ch : allChars) { int x = random.nextInt(width - 40); int y = random.nextInt(height - 40) + 30; g.setFont(new Font("宋体", Font.BOLD, 28)); g.setColor(new Color(random.nextInt(150), random.nextInt(150), random.nextInt(150))); g.drawString(ch, x, y); positions.add(new CharPos(ch, x, y, 28, 28)); } g.dispose();这里有几个参数值得说。字体大小 28 是平衡识别难度和用户体验的结果,太小用户看不清,太大干扰字容易重叠。颜色随机范围控制在 0-150,避免太浅导致用户找不到。坐标记录的是文字左上角,前端点击传回来的是相对图片的坐标,后端要做一次命中判断。
答案顺序也要存进 Redis,不能只存文字集合。因为点击验证码要求「按顺序点」,顺序错了就算点对字也不通过。Redis 里存的结构一般是answer: ["张","王","李","赵"]加positions: [{x,y,w,h},...],校验时先比对顺序再比对坐标。
3.2 前端坐标换算与 AES 加密传输
前端这块最容易翻车。用户点击的是页面上的图片,但图片可能被 CSS 缩放、可能页面滚动了、可能浏览器有缩放。源码里前端算相对坐标的公式是:
// 获取图片元素 const img = document.getElementById('captchaImg'); const rect = img.getBoundingClientRect(); // 点击事件坐标 const clickX = event.clientX - rect.left; const clickY = event.clientY - rect.top; // 换算成图片原始像素坐标(图片可能被 CSS 缩放) const scaleX = img.naturalWidth / rect.width; const scaleY = img.naturalHeight / rect.height; const realX = Math.round(clickX * scaleX); const realY = Math.round(clickY * scaleY);getBoundingClientRect()拿到的是图片在视口中的位置,clientX - rect.left就是相对图片左上角的坐标。naturalWidth / rect.width是缩放比,这一步不做,图片被 CSS 压过之后坐标全错,用户点对了也判失败。
坐标传回后端前用 AES 加密,源码里用的是 AES/CBC/PKCS5Padding,密钥前后端约定。加密不是为了防用户,是为了防脚本直接构造明文请求。常见做法是把坐标数组序列化成 JSON 再加密:
const coords = [{x: realX, y: realY}]; const plaintext = JSON.stringify(coords); const encrypted = CryptoJS.AES.encrypt(plaintext, secretKey).toString(); // 发给后端 fetch('/captcha/verify', { method: 'POST', body: JSON.stringify({uuid: uuid, data: encrypted}) });后端收到后先解密,再拿坐标去和 Redis 里存的positions做命中判断。命中逻辑是判断点击点是否落在某个字的矩形范围内,允许几像素误差。顺序校验则是把命中的字按点击顺序拼起来,和答案数组比对。
3.3 单元测试怎么跑、测什么
源码带了单元测试,主要覆盖三块:图片生成不抛异常、坐标命中判断正确、Redis 存取正常。跑测试前先把 Redis 起起来,测试类里一般用@SpringBootTest加载上下文。
@Test public void testGenerateClickCaptcha() { ClickCaptcha captcha = captchaService.generate(); assertNotNull(captcha.getImageBase64()); assertEquals(4, captcha.getAnswer().size()); // 验证 Redis 里确实写入了 String key = "captcha:click:" + captcha.getUuid(); assertTrue(redisTemplate.hasKey(key)); } @Test public void testVerifyCorrectOrder() { // 模拟生成后按正确顺序点击 boolean result = captchaService.verify(uuid, correctCoords); assertTrue(result); }测试里最容易挂的是 Redis 连接和图片字体。如果报Font not found,说明服务器没装中文字体,后面避坑章节会讲怎么处理。单元测试跑通不代表生产没问题,但至少能证明核心链路是活的。
4. 滑动图片验证码:抠图、拼图与拖动轨迹校验
4.1 随机抠图与拼图用 Graphics2D 怎么实现
滑动验证码的图片处理比点击复杂一点,但核心还是BufferedImage和Graphics2D。流程是:拿一张底图,随机选一个位置,抠出一块拼图形状,把抠出来的块放到滑块图里,底图上留一个缺口。
// 底图 BufferedImage bg = ImageIO.read(new File("bg.jpg")); int width = bg.getWidth(); int height = bg.getHeight(); // 随机缺口位置,x 不能太靠边,否则滑块拖不到 int gapX = 100 + random.nextInt(width - 200); int gapY = 50 + random.nextInt(height - 150); // 抠图:用 Path2D 画拼图形状,然后 clip BufferedImage gapImage = new BufferedImage(60, 60, BufferedImage.TYPE_INT_ARGB); Graphics2D gGap = gapImage.createGraphics(); gGap.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); Path2D path = createPuzzlePath(60, 60); // 拼图形状 gGap.setClip(path); gGap.drawImage(bg, 0, 0, 60, 60, gapX, gapY, gapX + 60, gapY + 60, null); gGap.dispose(); // 底图上挖缺口:用半透明灰色填充 Graphics2D gBg = bg.createGraphics(); gBg.setColor(new Color(128, 128, 128, 180)); gBg.fill(path); // 注意这里要平移到 gapX, gapY gBg.dispose();createPuzzlePath是画拼图形状的方法,一般用Path2D的moveTo、lineTo、curveTo组合出一个带凸起和凹槽的块。形状不用太复杂,能区分机器和人的拖动轨迹就行。
抠图位置gapX的范围要控制好,太靠左滑块初始位置就够得着,太靠右用户拖不到。gapY也别太靠上或靠下,否则缺口被页面元素挡住。这些边界值在源码里都有注释,改的时候别拍脑袋。
4.2 拖动轨迹的采集与校验逻辑
滑动验证码防的不是「能不能拖到」,而是「拖得像不像人」。机器可以瞬间把滑块从 0 拖到目标位置,但人的拖动有加速、减速、微小抖动。源码里前端采集的是拖动过程中的坐标序列:
let track = []; let startTime = 0; slider.onmousedown = function(e) { startTime = Date.now(); track = []; document.onmousemove = function(e) { track.push({ x: e.clientX, y: e.clientY, t: Date.now() - startTime }); }; }; document.onmouseup = function() { document.onmousemove = null; // 加密后发给后端 const encrypted = encrypt(JSON.stringify(track)); verify(encrypted); };后端校验分两层。第一层是位置校验:最终拖动距离是否落在缺口允许误差内,一般 ±5 像素。第二层是轨迹校验:看track数组的时间间隔和位移是否符合人类特征。比如总耗时不能低于 300ms,不能是匀速直线,要有加减速。
public boolean verifyTrack(List<TrackPoint> track, int targetX) { if (track.size() < 5) return false; // 点太少,明显是脚本 long totalTime = track.get(track.size() - 1).getT() - track.get(0).getT(); if (totalTime < 300) return false; // 太快 // 检查是否有加速减速:相邻位移差不能全相等 boolean hasVariation = false; for (int i = 2; i < track.size(); i++) { int d1 = track.get(i-1).getX() - track.get(i-2).getX(); int d2 = track.get(i).getX() - track.get(i-1).getX(); if (Math.abs(d2 - d1) > 2) hasVariation = true; } return hasVariation; }这套逻辑不是万能的,高级脚本可以模拟人类轨迹,但能把大部分低级脚本挡在门外。源码里轨迹校验的参数都可以调,太严会误伤手速快的用户,太松等于没校验。
4.3 坐标加密与 Redis 过期策略
滑动验证码的坐标传输同样走 AES 加密,和点击验证码共用一套加解密工具类。Redis 里存的是缺口位置gapX和校验状态,key 格式captcha:drag:{uuid},过期时间 120 秒。
有个细节:滑动验证码校验通过后要立刻删除 Redis key,防止同一个 uuid 被重复提交。点击验证码也一样,校验成功即删。源码里用redisTemplate.delete(key)实现,别漏了这步,否则脚本可以拿一个通过的 uuid 反复刷接口。
过期时间设 120 秒是平衡用户体验和安全的结果。太短用户还没拖完就过期,太长给了脚本更多尝试窗口。如果业务场景对安全要求更高,可以缩到 60 秒,但要在前端加倒计时提示。
5. 避坑与排查:那些让你怀疑人生的报错
5.1 现象:启动报 HeadlessException
原因:服务器没有图形环境,AWT 默认要连 X11 display,Linux 上没装 X 或者没设java.awt.headless=true就会抛这个。
解决:启动参数加-Djava.awt.headless=true,或者在application.yml里配spring.main.headless: true。Spring Boot 2.1 默认会设,但如果你自己 new 了JFrame之类的东西,还是会触发。验证码场景只用BufferedImage,设了 headless 就够。
5.2 现象:图片上中文全是方框
原因:服务器没装中文字体,Font("宋体", ...)找不到对应字体,回退到默认字体又不支持中文。
解决:Linux 上装fontconfig和wqy-zenhei之类的字体包,或者把字体文件打进资源目录用Font.createFont加载。源码里用的是系统字体,生产环境建议改成加载自带字体文件,避免环境差异。
// 加载自带字体 InputStream is = getClass().getResourceAsStream("/fonts/simhei.ttf"); Font font = Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(28f); g.setFont(font);5.3 现象:前端点击坐标总是偏
原因:图片被 CSS 缩放、页面滚动、浏览器缩放,三者任一没算进去都会偏。
解决:用getBoundingClientRect()拿实时位置,用naturalWidth / rect.width算缩放比,滚动位置window.scrollY也要考虑。调试时可以在前端把算出来的坐标画个红点,肉眼比对是否落在文字上。
5.4 现象:Redis 里 key 存在但校验一直失败
原因:序列化方式不一致。存的时候用 JDK 序列化,取的时候用 JSON 反序列化,或者反过来。
解决:统一RedisTemplate的序列化器,key 用StringRedisSerializer,value 用Jackson2JsonRedisSerializer或GenericJackson2JsonRedisSerializer。源码里配好了,但如果你自己改了 Redis 配置类,容易漏。
5.5 现象:单元测试跑得过,生产环境偶尔失败
原因:生产环境 Redis 网络延迟、字体渲染差异、并发下 uuid 冲突。
解决:Redis 操作加超时和重试,字体用自带文件,uuid 用UUID.randomUUID()基本不会冲突但也要防重复提交。生产环境建议加日志,把每次校验的 uuid、坐标、结果打出来,出问题能回溯。
6. 进阶:把验证码接进真实登录流程的几个技巧
源码跑通只是第一步,真要用到项目里,还得处理几个工程问题。第一个是接口防刷:验证码生成接口本身也要限流,不然脚本疯狂调生成接口,Redis 和图片渲染都扛不住。常见做法是按 IP 或设备指纹做滑动窗口限流,比如每分钟最多生成 10 次。
第二个是降级策略:Redis 挂了怎么办。验证码强依赖 Redis,挂了整个登录就进不去。我一般会加一层本地缓存兜底,Redis 不可用时切到ConcurrentHashMap存验证码,虽然多机不一致,但至少单机能用。等 Redis 恢复再切回来。
第三个是验证码难度动态调整。同一套参数用久了,脚本会针对性地训练识别模型。可以根据 IP 的历史失败次数动态调整:失败多的 IP 给更难的干扰字、更小的缺口误差。源码里参数都是写死的,你可以抽成配置项,按风控等级下发。
第四个是单元测试的边界覆盖。源码自带的测试覆盖了正常流程,但边界情况比如「坐标刚好在文字边缘」「拖动轨迹只有两个点」「Redis key 刚好过期」这些没覆盖。我一般会补几个边界测试:
@Test public void testVerifyExpiredCaptcha() { // 生成后手动删除 Redis key,模拟过期 captchaService.generate(); redisTemplate.delete(key); assertFalse(captchaService.verify(uuid, coords)); } @Test public void testVerifyEdgeCoordinate() { // 坐标刚好在文字矩形边界上 boolean result = captchaService.verify(uuid, edgeCoords); // 根据业务决定边界算命中还是不算,源码里算命中 assertTrue(result); }最后说个验证方法:接进登录流程后,用 JMeter 或 ab 压一下验证码接口,看 QPS 和 Redis 连接数。图片生成是 CPU 密集型,单机 QPS 不会太高,一般几百到一千,不够就加机器或者把图片生成改成异步预生成。
从那以后我每次接验证码,都强制先跑一遍单元测试再压测,确认 Redis 连接池和图片渲染在并发下不崩,才敢往生产推。希望帮到你。
本文还有配套的精品资源,点击获取