知识点编号:268
难度系数:⭐⭐⭐
实用指数:💯💯💯💯💯
📖 开篇:一场"机器人"入侵事件
某天凌晨3点,你的手机疯狂震动 📱:
告警:注册量异常!
- 3分钟内注册 10000 个账号
- 用户名规律:user001, user002, user003...
- 邮箱规律:xxx001@qq.com, xxx002@qq.com...
产品经理冲过来:
"小王!我们被机器人刷了!😱 快加验证码!"
你:
"验证码?那不是输入几个数字吗?"
产品经理:
"现在的验证码可不简单,有图形验证码、滑块验证码、行为验证码... 你不会以为还是20年前的技术吧?🙄"
今天,我们就来学习如何实现各种验证码! 🎯
🎯 验证码的演进史
┌──────────────────────────────────────────────────────────┐
│ 验证码进化史 (1997-2024) │
└──────────────────────────────────────────────────────────┘
1997年 🐌 静态图片验证码
"请输入图中的数字:5678"
↓ 被OCR识别率达99%
2005年 🔀 扭曲字符验证码
"请输入图中的字符(倾斜+干扰线)"
↓ 用户体验差,识别率仅60%
2010年 🧩 拼图验证码
"请将拼图拖动到正确位置"
↓ 体验提升,但仍可被破解
2015年 📱 短信验证码
"请输入手机收到的验证码"
↓ 成本高,可被短信轰炸攻击
2018年 🎯 行为验证码(reCAPTCHA v3)
"无需点击,后台分析用户行为"
↓ 体验最佳,但成本高
2020年 🧠 AI对抗验证码
"滑块+轨迹分析+行为分析"
↓ 目前主流方案
🎨 验证码类型全家桶
1️⃣ 图形验证码(古老但实用)
┌─────────────────┐
│ 8 7 K 3 │ ← 扭曲、干扰线、噪点
│ / \ /\ \ │
│ ─────────── │
└─────────────────┘
特点:
- ✅ 实现简单
- ✅ 成本为0
- ❌ 用户体验差
- ❌ 容易被OCR破解
2️⃣ 滑块验证码(最流行)
┌────────────────────────────┐
│ [→] 向右滑动完成拼图 │
│ │
│ ┌──┐ │
│ │ │ ╔══╗ │
│ └──┘ ║ ║ │
│ ╚══╝ │
└────────────────────────────┘
特点:
- ✅ 用户体验好
- ✅ 安全性高
- ✅ 可定制
- ⚠️ 实现复杂
3️⃣ 点选验证码
┌─────────────────────────────┐
│ 请依次点击【汽车】 │
│ │
│ 🚗 🏠 ✈️ 🚗 │
│ │
│ 🌳 🚗 🌊 🏠 │
└─────────────────────────────┘
4️⃣ 短信验证码
【某某网】您的验证码是:123456
有效期5分钟,请勿告知他人。
5️⃣ 行为验证码(无感验证)
用户行为分析:
- 鼠标移动轨迹 ✅
- 键盘输入节奏 ✅
- 停留时间 ✅
- 浏览器指纹 ✅
→ 综合评分:95分(人类)
🖼️ 实战1:图形验证码(Java实现)
依赖引入
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
配置类
@Configuration
public class KaptchaConfig {
@Bean
public Producer kaptchaProducer() {
Properties properties = new Properties();
// 图片宽度
properties.setProperty("kaptcha.image.width", "150");
// 图片高度
properties.setProperty("kaptcha.image.height", "50");
// 字符集
properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
// 字符长度
properties.setProperty("kaptcha.textproducer.char.length", "4");
// 字体大小
properties.setProperty("kaptcha.textproducer.font.size", "40");
// 字体颜色
properties.setProperty("kaptcha.textproducer.font.color", "black");
// 干扰线颜色
properties.setProperty("kaptcha.noise.color", "blue");
// 背景渐变色
properties.setProperty("kaptcha.background.clear.from", "lightGray");
properties.setProperty("kaptcha.background.clear.to", "white");
DefaultKaptcha kaptcha = new DefaultKaptcha();
Config config = new Config(properties);
kaptcha.setConfig(config);
return kaptcha;
}
}
生成验证码
@RestController
@RequestMapping("/captcha")
public class CaptchaController {
@Autowired
private Producer kaptchaProducer;
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 生成图形验证码
*/
@GetMapping("/image")
public void generateImage(HttpServletRequest request,
HttpServletResponse response) throws IOException {
// 1. 生成验证码文本
String code = kaptchaProducer.createText();
// 2. 生成图片
BufferedImage image = kaptchaProducer.createImage(code);
// 3. 生成唯一标识(存到Cookie或返回给前端)
String uuid = UUID.randomUUID().toString();
// 4. 存入Redis(5分钟过期)
String key = "captcha:" + uuid;
redisTemplate.opsForValue().set(key, code, Duration.ofMinutes(5));
// 5. 返回UUID
Cookie cookie = new Cookie("captcha_uuid", uuid);
cookie.setMaxAge(300); // 5分钟
cookie.setPath("/");
response.addCookie(cookie);
// 6. 输出图片
response.setContentType("image/jpeg");
response.setHeader("Cache-Control", "no-cache");
ServletOutputStream out = response.getOutputStream();
ImageIO.write(image, "jpg", out);
out.flush();
out.close();
log.info("✅ 生成验证码:{}", code);
}
/**
* 校验验证码
*/
@PostMapping("/verify")
public Result verify(@RequestParam String code,
@CookieValue(name = "captcha_uuid") String uuid) {
// 1. 从Redis获取
String key = "captcha:" + uuid;
String cachedCode = redisTemplate.opsForValue().get(key);
// 2. 校验
if (cachedCode == null) {
return Result.fail("验证码已过期");
}
if (!cachedCode.equalsIgnoreCase(code)) {
return Result.fail("验证码错误");
}
// 3. 验证通过后删除(一次性)
redisTemplate.delete(key);
return Result.success("验证通过");
}
}
前端代码
<!DOCTYPE html>
<html>
<head>
<title>图形验证码</title>
</head>
<body>
<h2>请输入验证码</h2>
<!-- 验证码图片 -->
<img id="captchaImg" src="/captcha/image"
alt="验证码"
onclick="refreshCaptcha()"
style="cursor: pointer;">
<p style="color: gray;">看不清?点击图片刷新</p>
<!-- 输入框 -->
<input type="text" id="code" placeholder="请输入验证码">
<button onclick="verifyCaptcha()">提交</button>
<script>
// 刷新验证码
function refreshCaptcha() {
document.getElementById('captchaImg').src =
'/captcha/image?t=' + new Date().getTime();
}
// 校验验证码
function verifyCaptcha() {
const code = document.getElementById('code').value;
fetch('/captcha/verify', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'code=' + code
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert('验证通过!');
} else {
alert(data.message);
refreshCaptcha();
}
});
}
</script>
</body>
</html>
🧩 实战2:滑块验证码(完整实现)
后端实现
1. 验证码实体
@Data
public class SlideCaptcha {
private String uuid; // 唯一标识
private String backgroundImage; // 背景图(Base64)
private String sliderImage; // 滑块图(Base64)
private Integer x; // 缺口X坐标(后端保存,不返回)
private Integer y; // 缺口Y坐标
private Long timestamp; // 生成时间
}
2. 生成滑块验证码
@Service
public class SlideCaptchaService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 滑块宽度
private static final int SLIDER_WIDTH = 50;
// 滑块高度
private static final int SLIDER_HEIGHT = 50;
/**
* 生成滑块验证码
*/
public SlideCaptcha generate() throws IOException {
// 1. 加载背景图(300x200)
BufferedImage bgImage = loadRandomImage();
int width = bgImage.getWidth();
int height = bgImage.getHeight();
// 2. 随机生成缺口位置
int x = new Random().nextInt(width - SLIDER_WIDTH - 50) + 50; // 避免太靠边
int y = new Random().nextInt(height - SLIDER_HEIGHT - 20) + 10;
// 3. 生成滑块图(包含缺口部分)
BufferedImage sliderImage = new BufferedImage(
SLIDER_WIDTH, SLIDER_HEIGHT, BufferedImage.TYPE_INT_ARGB
);
// 4. 切割滑块(圆角矩形+凹槽)
cutSlider(bgImage, sliderImage, x, y);
// 5. 在背景图上绘制缺口阴影
drawShadow(bgImage, x, y);
// 6. 转Base64
String bgBase64 = imageToBase64(bgImage);
String sliderBase64 = imageToBase64(sliderImage);
// 7. 生成UUID
String uuid = UUID.randomUUID().toString();
// 8. 保存到Redis(坐标信息)
String key = "slide_captcha:" + uuid;
Map<String, Object> data = new HashMap<>();
data.put("x", x);
data.put("y", y);
data.put("timestamp", System.currentTimeMillis());
redisTemplate.opsForHash().putAll(key, data);
redisTemplate.expire(key, Duration.ofMinutes(5));
// 9. 返回结果(不包含x坐标)
SlideCaptcha captcha = new SlideCaptcha();
captcha.setUuid(uuid);
captcha.setBackgroundImage(bgBase64);
captcha.setSliderImage(sliderBase64);
captcha.setY(y);
return captcha;
}
/**
* 切割滑块
*/
private void cutSlider(BufferedImage bg, BufferedImage slider, int x, int y) {
Graphics2D g2 = slider.createGraphics();
// 创建圆角矩形路径
Shape shape = createSliderShape();
// 裁剪
g2.setClip(shape);
g2.drawImage(bg, -x, -y, null);
// 在背景图上抠掉这部分(变成灰色)
Graphics2D bgG2 = bg.createGraphics();
bgG2.setColor(new Color(0, 0, 0, 100)); // 半透明黑色
bgG2.translate(x, y);
bgG2.fill(shape);
g2.dispose();
bgG2.dispose();
}
/**
* 创建滑块形状(圆角矩形+凹凸)
*/
private Shape createSliderShape() {
// 简化版:圆角矩形
// 实际应该更复杂,增加凹凸
return new RoundRectangle2D.Float(0, 0, SLIDER_WIDTH, SLIDER_HEIGHT, 10, 10);
}
/**
* 绘制缺口阴影
*/
private void drawShadow(BufferedImage bg, int x, int y) {
Graphics2D g2 = bg.createGraphics();
g2.setColor(new Color(0, 0, 0, 50));
g2.drawRect(x, y, SLIDER_WIDTH, SLIDER_HEIGHT);
g2.dispose();
}
/**
* 加载随机背景图
*/
private BufferedImage loadRandomImage() throws IOException {
// 从资源目录随机加载
int index = new Random().nextInt(10) + 1;
String path = "static/captcha/bg" + index + ".jpg";
ClassPathResource resource = new ClassPathResource(path);
return ImageIO.read(resource.getInputStream());
}
/**
* 图片转Base64
*/
private String imageToBase64(BufferedImage image) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "png", baos);
byte[] bytes = baos.toByteArray();
return "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes);
}
/**
* 校验滑块位置
*/
public boolean verify(String uuid, int moveX, List<Long> track) {
String key = "slide_captcha:" + uuid;
// 1. 获取真实坐标
Integer realX = (Integer) redisTemplate.opsForHash().get(key, "x");
Long timestamp = (Long) redisTemplate.opsForHash().get(key, "timestamp");
if (realX == null) {
log.warn("验证码已过期或不存在:{}", uuid);
return false;
}
// 2. 校验位置(允许5像素误差)
if (Math.abs(realX - moveX) > 5) {
log.warn("位置错误:real={}, move={}", realX, moveX);
return false;
}
// 3. 校验时间(不能太快,防止机器)
long costTime = System.currentTimeMillis() - timestamp;
if (costTime < 500) {
log.warn("完成太快(机器):{}ms", costTime);
return false;
}
// 4. 校验轨迹(防止直接拖到终点)
if (!isValidTrack(track)) {
log.warn("轨迹异常");
return false;
}
// 5. 删除验证码(一次性)
redisTemplate.delete(key);
return true;
}
/**
* 校验轨迹是否合法
*/
private boolean isValidTrack(List<Long> track) {
if (track == null || track.size() < 10) {
return false; // 轨迹点太少
}
// 检查是否有加速-减速过程(人类特征)
boolean hasAcceleration = false;
for (int i = 1; i < track.size() - 1; i++) {
long speed1 = track.get(i) - track.get(i - 1);
long speed2 = track.get(i + 1) - track.get(i);
if (speed2 < speed1) {
hasAcceleration = true;
break;
}
}
return hasAcceleration;
}
}
3. 控制器
@RestController
@RequestMapping("/captcha/slide")
public class SlideCaptchaController {
@Autowired
private SlideCaptchaService captchaService;
/**
* 生成滑块验证码
*/
@GetMapping("/generate")
public Result<SlideCaptcha> generate() {
try {
SlideCaptcha captcha = captchaService.generate();
return Result.success(captcha);
} catch (Exception e) {
log.error("生成验证码失败", e);
return Result.fail("生成失败");
}
}
/**
* 校验滑块
*/
@PostMapping("/verify")
public Result verify(@RequestBody SlideVerifyDTO dto) {
boolean success = captchaService.verify(
dto.getUuid(),
dto.getMoveX(),
dto.getTrack()
);
if (success) {
return Result.success("验证通过");
} else {
return Result.fail("验证失败");
}
}
}
@Data
public class SlideVerifyDTO {
private String uuid; // 验证码ID
private Integer moveX; // 滑动距离
private List<Long> track; // 移动轨迹(时间戳)
}
前端实现
<!DOCTYPE html>
<html>
<head>
<title>滑块验证码</title>
<style>
.captcha-container {
width: 300px;
position: relative;
}
#bgImage {
width: 100%;
height: 200px;
}
#sliderImage {
position: absolute;
top: 0;
left: 0;
width: 50px;
height: 50px;
}
.slider-bar {
width: 300px;
height: 40px;
background: #e8e8e8;
position: relative;
margin-top: 10px;
}
.slider-btn {
width: 40px;
height: 40px;
background: #3498db;
position: absolute;
cursor: pointer;
text-align: center;
line-height: 40px;
color: white;
}
</style>
</head>
<body>
<div class="captcha-container">
<!-- 背景图 -->
<img id="bgImage" src="" alt="背景">
<!-- 滑块 -->
<img id="sliderImage" src="" alt="滑块">
<!-- 滑动条 -->
<div class="slider-bar">
<div class="slider-btn" id="sliderBtn">→</div>
</div>
</div>
<button onclick="loadCaptcha()">刷新</button>
<script>
let captchaData = null;
let isDragging = false;
let startX = 0;
let track = []; // 记录轨迹
// 加载验证码
async function loadCaptcha() {
const res = await fetch('/captcha/slide/generate');
const data = await res.json();
if (data.success) {
captchaData = data.data;
// 显示图片
document.getElementById('bgImage').src = captchaData.backgroundImage;
document.getElementById('sliderImage').src = captchaData.sliderImage;
document.getElementById('sliderImage').style.top = captchaData.y + 'px';
// 重置滑块
document.getElementById('sliderBtn').style.left = '0px';
track = [];
}
}
// 鼠标按下
document.getElementById('sliderBtn').addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX;
track = [Date.now()]; // 记录开始时间
});
// 鼠标移动
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
let moveX = e.clientX - startX;
if (moveX < 0) moveX = 0;
if (moveX > 260) moveX = 260; // 最大移动距离
// 移动滑块和拼图
document.getElementById('sliderBtn').style.left = moveX + 'px';
document.getElementById('sliderImage').style.left = moveX + 'px';
// 记录轨迹
track.push(Date.now());
});
// 鼠标松开
document.addEventListener('mouseup', async (e) => {
if (!isDragging) return;
isDragging = false;
let moveX = e.clientX - startX;
// 发送验证请求
const res = await fetch('/captcha/slide/verify', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
uuid: captchaData.uuid,
moveX: moveX,
track: track
})
});
const data = await res.json();
if (data.success) {
alert('验证通过!✅');
} else {
alert('验证失败!请重试');
loadCaptcha();
}
});
// 页面加载时自动加载验证码
window.onload = loadCaptcha;
</script>
</body>
</html>
📱 实战3:短信验证码
后端实现
@Service
public class SmsCodeService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private SmsService smsService; // 短信发送服务
/**
* 发送短信验证码
*/
public Result sendCode(String phone) {
// 1. 校验手机号
if (!PhoneUtil.isValid(phone)) {
return Result.fail("手机号格式错误");
}
// 2. 检查发送频率(60秒内只能发一次)
String lockKey = "sms:lock:" + phone;
Boolean hasLock = redisTemplate.hasKey(lockKey);
if (Boolean.TRUE.equals(hasLock)) {
return Result.fail("发送太频繁,请稍后再试");
}
// 3. 检查每日发送次数(防止恶意刷)
String countKey = "sms:count:" + phone + ":" + LocalDate.now();
Long count = redisTemplate.opsForValue().increment(countKey);
if (count > 10) {
return Result.fail("今日发送次数已达上限");
}
redisTemplate.expire(countKey, Duration.ofDays(1));
// 4. 生成6位随机验证码
String code = generateCode(6);
// 5. 发送短信
boolean success = smsService.send(phone,
String.format("您的验证码是:%s,5分钟内有效。", code));
if (!success) {
return Result.fail("短信发送失败");
}
// 6. 存入Redis(5分钟有效)
String codeKey = "sms:code:" + phone;
redisTemplate.opsForValue().set(codeKey, code, Duration.ofMinutes(5));
// 7. 设置发送锁(60秒)
redisTemplate.opsForValue().set(lockKey, "1", Duration.ofSeconds(60));
log.info("✅ 发送验证码:phone={}, code={}", phone, code);
return Result.success("发送成功");
}
/**
* 校验验证码
*/
public boolean verify(String phone, String code) {
String key = "sms:code:" + phone;
String cachedCode = redisTemplate.opsForValue().get(key);
if (cachedCode == null) {
return false; // 不存在或已过期
}
if (!cachedCode.equals(code)) {
return false; // 不匹配
}
// 验证通过后删除
redisTemplate.delete(key);
return true;
}
/**
* 生成随机验证码
*/
private String generateCode(int length) {
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(random.nextInt(10));
}
return sb.toString();
}
}
短信发送服务(以阿里云为例)
@Service
public class AliyunSmsService {
@Value("${aliyun.sms.accessKeyId}")
private String accessKeyId;
@Value("${aliyun.sms.accessKeySecret}")
private String accessKeySecret;
@Value("${aliyun.sms.signName}")
private String signName;
@Value("${aliyun.sms.templateCode}")
private String templateCode;
public boolean send(String phone, String code) {
try {
DefaultProfile profile = DefaultProfile.getProfile(
"cn-hangzhou",
accessKeyId,
accessKeySecret
);
IAcsClient client = new DefaultAcsClient(profile);
SendSmsRequest request = new SendSmsRequest();
request.setPhoneNumbers(phone);
request.setSignName(signName);
request.setTemplateCode(templateCode);
request.setTemplateParam("{"code":"" + code + ""}");
SendSmsResponse response = client.getAcsResponse(request);
return "OK".equals(response.getCode());
} catch (Exception e) {
log.error("短信发送失败", e);
return false;
}
}
}
🛡️ 验证码防护策略
1️⃣ 防止暴力破解
@Component
public class CaptchaRateLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 检查错误次数
*/
public boolean checkErrorCount(String key) {
String errorKey = "captcha:error:" + key;
Long count = redisTemplate.opsForValue().increment(errorKey);
if (count == 1) {
// 首次错误,设置过期时间
redisTemplate.expire(errorKey, Duration.ofMinutes(10));
}
// 超过5次错误,锁定10分钟
if (count > 5) {
log.warn("验证码错误次数过多:{}", key);
return false;
}
return true;
}
}
2️⃣ 防止重放攻击
/**
* 验证码只能使用一次
*/
public boolean verifyOnce(String uuid, String code) {
String key = "captcha:" + uuid;
// 使用Redis的GETDEL命令(原子操作)
String cachedCode = redisTemplate.opsForValue().getAndDelete(key);
if (cachedCode == null) {
return false; // 已使用或不存在
}
return cachedCode.equals(code);
}
3️⃣ 防止接口被刷
@Component
@Aspect
public class CaptchaRateLimitAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around("@annotation(rateLimited)")
public Object around(ProceedingJoinPoint pjp, RateLimited rateLimited) {
// 获取IP
String ip = IpUtil.getClientIp();
String key = "rate_limit:" + ip;
// 检查调用次数(每分钟最多10次)
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, Duration.ofMinutes(1));
}
if (count > 10) {
throw new BizException("请求太频繁");
}
return pjp.proceed();
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
}
🎯 最佳实践
1️⃣ 选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 注册登录 | 图形验证码 + 短信 | 双重验证 |
| 支付确认 | 短信验证码 | 安全性高 |
| 评论发布 | 滑块验证码 | 用户体验好 |
| 批量操作 | 行为验证码 | 无感知 |
| 高风险操作 | 短信 + 人脸识别 | 最高安全级别 |
2️⃣ 用户体验优化
/**
* 智能验证码(根据风险等级动态选择)
*/
public CaptchaType chooseCaptchaType(UserContext ctx) {
int riskScore = calculateRiskScore(ctx);
if (riskScore < 30) {
return CaptchaType.NONE; // 低风险,无需验证
} else if (riskScore < 60) {
return CaptchaType.SLIDER; // 中风险,滑块验证
} else {
return CaptchaType.SMS; // 高风险,短信验证
}
}
private int calculateRiskScore(UserContext ctx) {
int score = 0;
// 新用户
if (ctx.isNewUser()) score += 20;
// 异地登录
if (ctx.isAbnormalLocation()) score += 30;
// 频繁操作
if (ctx.isFrequentOperation()) score += 25;
// 设备指纹异常
if (ctx.isAbnormalDevice()) score += 25;
return score;
}
📝 总结
验证码技术栈选择
┌──────────────────────────────────────┐
│ 验证码方案对比 │
├──────────────────────────────────────┤
│ 图形验证码 │
│ 优点:成本低、实现简单 │
│ 缺点:体验差、容易被破解 │
│ 适用:低并发、内部系统 │
├──────────────────────────────────────┤
│ 滑块验证码 │
│ 优点:体验好、安全性高 │
│ 缺点:实现复杂 │
│ 适用:大部分场景 ⭐⭐⭐⭐⭐ │
├──────────────────────────────────────┤
│ 短信验证码 │
│ 优点:安全性最高 │
│ 缺点:成本高(0.03-0.05元/条) │
│ 适用:支付、敏感操作 │
├──────────────────────────────────────┤
│ 行为验证码 │
│ 优点:无感验证、体验最佳 │
│ 缺点:成本高、技术门槛高 │
│ 适用:大厂、高并发场景 │
└──────────────────────────────────────┘
关键要点 🎯
-
安全三要素
- 一次性(验证后删除)
- 时效性(5分钟过期)
- 频率限制(防刷)
-
防护策略
- IP限流
- 错误次数限制
- 行为分析
-
用户体验
- 智能判断(低风险免验证)
- 多种方案组合
- 验证码刷新
让你的系统既安全又好用! 🎉🎉🎉