
一、从0到1实现Java图片验证码生成:核心步骤与代码解析
验证码生成是整个功能的基础,你可别觉得“随便画几个字符就行”——真正能用的验证码,得同时满足三个要求:机器难识别、人眼看得清、性能消耗小。我见过有些教程生成的验证码,字符歪歪扭扭还叠在一起,用户瞪着眼看半天都认不出,这种反而是帮倒忙。下面我一步步带你实现,每个环节都会告诉你“为什么要这么做”,而不是只甩代码。
随机字符生成:验证码的“内容骨架”
验证码的核心是那段随机字符,它的质量直接决定了安全性。你可能会问:“直接用Random生成几个字母数字不行吗?” 确实能行,但这样生成的字符可能有规律,比如总是包含连续数字,或者字母都是大写,容易被破解。我 你按这三个原则设计字符生成逻辑:
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
,特意去掉了容易混淆的字符。new Random()
,它的随机种子可能重复,最好用SecureRandom
,这是Java自带的安全随机类,生成的序列更难预测。之前帮朋友排查问题时,发现他就是用了普通Random,结果同一个时间段生成的验证码有重复,被攻击者利用了。直接上代码示例,这段是我优化过的字符生成工具类,你可以直接拿去用:
import java.security.SecureRandom;
public class CaptchaUtils {
// 去掉易混淆字符后的字符集
private static final String CHAR_POOL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
private static final SecureRandom RANDOM = new SecureRandom();
/
生成随机验证码字符
@param length 字符长度, 4-6位
@return 验证码字符串
/
public static String generateRandomCode(int length) {
if (length 6) {
throw new IllegalArgumentException("验证码长度 4-6位");
}
StringBuilder code = new StringBuilder();
for (int i = 0; i < length; i++) {
// 从字符集中随机取一个字符
int index = RANDOM.nextInt(CHAR_POOL.length());
code.append(CHAR_POOL.charAt(index));
}
return code.toString();
}
}
这段代码里有个细节你注意下:我加了长度校验,强制限定在4-6位。别小看这个,之前有个实习生给长度传了2,结果生成的验证码被暴力破解,导致系统被刷了大量垃圾数据。
图片绘制与样式优化:兼顾安全与用户体验
有了字符,下一步就是把它画成图片。这一步最容易踩坑——要么画得太简单被机器识别,要么画得太复杂用户看不清。我 了三个关键优化点,都是实战中试错试出来的:
图片的宽高要根据字符长度调整,比如4位字符,宽120px、高40px比较合适(宽=字符数×30,高=40,这个比例亲测清晰)。背景色别用纯白,稍微带点浅灰或浅蓝,能减少视觉疲劳。字体选择也很重要,别用系统默认的“宋体”,在不同设备上显示可能模糊,我推荐用“微软雅黑”或“黑体”,加粗后字符边缘更清晰。
干扰元素是防止机器识别的关键,但不是越多越好。我之前试过加10条干扰线,结果用户反馈“根本看不清字符”,后来调整成3-5条线+适量噪点,既安全又不影响体验。具体怎么做:
直接上完整绘制代码,我会逐行注释说明作用:
import javax.imageio.ImageIO;
import java.awt.;
import java.awt.image.BufferedImage;
import java.io.OutputStream;
import java.security.SecureRandom;
public class CaptchaImageGenerator {
private static final SecureRandom RANDOM = new SecureRandom();
// 图片宽度(4位字符:120px)
private int width = 120;
// 图片高度
private int height = 40;
// 字符大小
private int fontSize = 24;
// 干扰线数量
private int lineCount = 4;
// 噪点数量
private int noiseCount = 15;
// 生成验证码图片并输出到流
public void generateImage(String code, OutputStream os) throws Exception {
// 创建图片缓冲区
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// 获取绘图对象
Graphics2D g = image.createGraphics();
//
设置背景色(浅灰色)
g.setColor(new Color(245, 245, 245)); // 浅灰
g.fillRect(0, 0, width, height);
//
设置字体(微软雅黑加粗)
Font font = new Font("微软雅黑", Font.BOLD, fontSize);
g.setFont(font);
//
画字符(每个字符随机位置、颜色、旋转角度)
char[] chars = code.toCharArray();
for (int i = 0; i < chars.length; i++) {
// 随机颜色(深色系,避免和背景色太近)
Color color = new Color(
RANDOM.nextInt(80), // R(0-80,深色)
RANDOM.nextInt(80), // G
RANDOM.nextInt(80) // B
);
g.setColor(color);
// 随机旋转角度(-15°到15°)
int degree = RANDOM.nextInt(30)
15; // -15~15
g.rotate(Math.toRadians(degree), 20 + i30, 25); // 字符中心点坐标
// 画字符(x坐标:20 + i30,y坐标:25,间距30px)
g.drawString(String.valueOf(chars[i]), 20 + i30, 25);
// 旋转回来,避免影响下一个字符
g.rotate(-Math.toRadians(degree), 20 + i30, 25);
}
//
画干扰线
for (int i = 0; i < lineCount; i++) {
// 随机颜色(和字符颜色区分开,用中色系)
Color lineColor = new Color(
RANDOM.nextInt(150) + 50, // R(50-200)
RANDOM.nextInt(150) + 50,
RANDOM.nextInt(150) + 50
);
g.setColor(lineColor);
g.setStroke(new BasicStroke(1.5f)); // 线条粗细1.5px
// 随机起点和终点
int x1 = RANDOM.nextInt(width);
int y1 = RANDOM.nextInt(height);
int x2 = RANDOM.nextInt(width);
int y2 = RANDOM.nextInt(height);
g.drawLine(x1, y1, x2, y2);
}
//
画噪点
for (int i = 0; i < noiseCount; i++) {
Color noiseColor = new Color(
RANDOM.nextInt(100) + 100, // 中灰色
RANDOM.nextInt(100) + 100,
RANDOM.nextInt(100) + 100
);
g.setColor(noiseColor);
// 画1x1的小点
g.fillRect(
RANDOM.nextInt(width),
RANDOM.nextInt(height),
1, 1
);
}
//
释放资源,输出图片
g.dispose();
ImageIO.write(image, "png", os); // 用png格式,支持透明(如果需要)
}
}
上面代码里有几个“反坑”细节你一定要注意:旋转字符后记得“旋转回来”,否则下一个字符会叠加旋转角度,最后整个图片都歪了;干扰线颜色别和字符颜色太像,之前我朋友就犯过这错,字符是黑色,干扰线也是深灰,结果用户根本分不清哪个是字符。
二、验证码校验全流程:从前端传递到后端验证的安全实践
生成验证码后,校验环节更关键——就算生成得再安全,校验逻辑有漏洞,等于白搭。我见过最离谱的错误是:后端直接把生成的验证码明文返回给前端,然后前端提交时再把这个明文传回来比对。这种操作等于没设防,攻击者直接拿返回的明文就能通过校验。下面我带你从“存储验证码”到“校验逻辑”一步步实现,每个环节都告诉你安全要点。
验证码存储:选Session还是Redis?
生成验证码后,你得把它存起来,等用户输入后用来比对。存哪里?最常见的两种方案:Session和Redis。我列了个对比表,你根据自己的项目情况选:
存储方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
Session | 简单,无需额外依赖;和用户会话绑定 | 分布式系统不支持(Session存在单机);不易设置过期时间 | 单体应用、用户量小的项目 |
Redis | 支持分布式;可设置过期时间;性能高 | 需要额外部署Redis;增加少量代码复杂度 | 分布式系统、用户量大、需要高可用的项目 |
我的 :如果你的项目是Spring Boot单体应用,用Session够了;如果是分布式部署(比如多台服务器负载均衡),必须用Redis,否则用户请求到不同服务器,Session里拿不到验证码。下面我分别给两种方案的代码示例。
Session存储方案(Spring Boot示例):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@RestController
public class CaptchaController {
@GetMapping("/captcha")
public void getCaptcha(HttpServletRequest request, HttpServletResponse response) throws IOException {
//
生成随机字符(4位)
String code = CaptchaUtils.generateRandomCode(4);
//
存到Session,键名 复杂点,比如"CAPTCHA_CODE_" + 用户标识(如果已登录)
request.getSession().setAttribute("CAPTCHA_CODE", code);
//
设置响应头(禁止缓存,防止验证码重复显示)
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("image/png");
//
生成图片并输出
CaptchaImageGenerator generator = new CaptchaImageGenerator();
generator.generateImage(code, response.getOutputStream());
}
}
Redis存储方案(需要引入Redis依赖):
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@RestController
public class CaptchaController {
private final StringRedisTemplate redisTemplate;
// 构造函数注入RedisTemplate(Spring Boot自动配置)
public CaptchaController(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@GetMapping("/captcha")
public void getCaptcha(HttpServletResponse response) throws IOException {
//
生成随机字符和唯一标识(用于Redis的key)
String code = CaptchaUtils.generateRandomCode(4);
String captchaKey = "CAPTCHA:" + UUID.randomUUID(); // 用UUID作为唯一key
//
存到Redis,设置5分钟过期(根据业务调整,一般3-5分钟)
redisTemplate.opsForValue().set(captchaKey, code, 5, TimeUnit.MINUTES);
//
返回图片时,把captchaKey通过响应头传给前端(前端提交时需要带上这个key)
response.setHeader("Captcha-Key", captchaKey);
//
其他响应头和图片生成和Session方案一样
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("image/png");
CaptchaImageGenerator generator = new CaptchaImageGenerator();
generator.generateImage(code, response.getOutputStream());
}
}
上面Redis方案里,我用了UUID作为key,然后通过响应头传给前端,前端提交时需要同时传“用户输入的验证码”和“这个key”。为什么这么做?如果直接用用户IP或SessionID作为key,攻击者可能通过伪造IP或SessionID来获取他人的验证码。用UUID随机key,每次生成验证码都不一样,更安全。
校验逻辑:5个安全细节必须注意
存好验证码后,就是校验用户输入了。别觉得“比对一下字符串”很简单,这里面有5个细节,少一个都可能出安全问题:
用户输入时可能不小心开了大写键,或者验证码里有小写字母用户输成大写。所以比对时 忽略大小写,比如把用户输入和存储的验证码都转成小写再比较:storedCode.equalsIgnoreCase(userInput)
。
不管用Session还是Redis,都要设置过期时间( 3-5分钟)。过期后即使输入正确也提示“验证码已过期”,防止攻击者长时间尝试破解。Redis方案里我已经设置了过期时间,Session方案的话,可以在Session里存一个“生成时间”,校验时判断是否超过5分钟:
// Session方案增加过期判断
long createTime = (long) request.getSession().getAttribute("CAPTCHA_CREATE_TIME");
if (System.currentTimeMillis()
createTime > 5
60 * 1000) { // 5分钟
return “验证码已过期,请刷新重试”;
}
同一个验证码只能用
前后端分离项目里,验证码key的传递确实是个容易踩坑的地方,我之前帮一个做电商小程序的朋友排查问题,就发现他们直接把用户手机号当key存验证码,结果被人用脚本伪造手机号疯狂刷验证码,差点造成短信接口欠费。你可别觉得“随便找个标识当key就行”,这里面藏着不少安全门道。
为什么不能用IP或者SessionID当key呢?你想啊,现在很多用户是在公共网络环境下访问的,比如公司Wi-Fi,同一个IP可能对应几十上百个用户,用IP当key的话,A用户的验证码可能被同IP的B用户拿到;SessionID在前后端分离项目里更麻烦,前端存Cookie容易被CSRF攻击,存localStorage又得处理跨域问题,而且Session本身是和服务器绑定的,分布式部署时还可能出现拿不到的情况。真正靠谱的做法是用随机UUID当key,这玩意儿就像给每个验证码发了张一次性身份证,一串由字母和数字组成的乱码,每次生成验证码都新造一个,用完就作废,攻击者根本猜不到规律。
具体传递的时候,你可以在后端生成验证码图片时,把这个UUID通过响应头传给前端,比如自定义一个叫X-Captcha-Key的响应头,前端拿到后存在内存或者localStorage里——注意别存Cookie,容易被劫持。等用户输入验证码点击提交时,前端要同时把“用户输入的验证码”和“这个UUID”一起发给后端,后端再用UUID去Redis里查对应的正确验证码进行比对。这里有个小细节,每次生成验证码都必须换新的UUID,就算用户没提交,刷新页面重新生成时也要换,绝对不能重复使用同一个key,我之前见过有人图省事复用key,结果被攻击者抓包拿到旧key反复尝试,把验证码给破解了。
验证码输入正确但提示错误,可能是什么原因?
可能原因包括:验证码已过期(超过3-5分钟有效期)、大小写未统一处理(如存储的验证码含小写字母而用户输入大写)、前后端传递的验证码key不匹配(Redis方案需同时传递随机key和输入值)。 检查验证码生成时的过期设置,以及校验时是否统一转为小写/大写后比对。
如何防止恶意用户频繁刷新验证码进行暴力破解?
可通过“验证码使用一次即失效”机制解决:无论校验成功或失败,均立即删除存储的验证码(如Redis方案中调用 redisTemplate.delete(captchaKey)
),避免重复使用。同时限制单个IP/用户的验证码请求频率(如1分钟内最多生成5次),结合随机key机制进一步降低破解风险。
前后端分离项目中,验证码key如何安全传递给前端?
推荐使用随机UUID作为验证码key,生成后通过响应头(如 X-Captcha-Key
)传递给前端,前端提交时需同时发送“用户输入的验证码”和“该key”。避免使用用户IP、SessionID等固定标识作为key,防止攻击者通过伪造标识获取他人验证码。
验证码生成后加载缓慢,如何优化性能?
可从三方面优化:减少干扰元素数量(干扰线3-4条、噪点10-15个即可)、降低图片尺寸( 宽120-150px、高40-50px)、使用Redis存储时设置合理过期时间(3-5分钟)并启用内存淘汰策略。若项目用户量极大,可考虑异步生成验证码(如通过消息队列处理图片绘制),但需注意同步过期逻辑。
为什么验证码校验时需要区分“已过期”和“输入错误”?
明确区分状态可提升用户体验与安全性:“验证码已过期”提示用户刷新获取新验证码(避免因长时间未操作导致无效尝试),“输入错误”提示重新输入,而“已使用”则防止重复校验。若不区分,攻击者可能通过多次输入正确值破解过期验证码,增加安全风险。