分离验证码模块,增加tianai验证码,实现基础的获取验证码,需要优化验证码图片逻辑

This commit is contained in:
admin
2026-08-09 18:14:25 +08:00
parent 430478c040
commit 182a16efca
27 changed files with 480 additions and 135 deletions
@@ -1,42 +1,25 @@
package org.dromara.web.controller;
import cloud.tianai.captcha.application.vo.ImageCaptchaVO;
import cloud.tianai.captcha.common.response.ApiResponse;
import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.captcha.generator.RandomGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import org.dromara.common.captcha.utils.CaptchaUtils;
import org.dromara.common.captcha.utils.CaptchaUtils.CaptchaVo;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.regex.RegexValidator;
import org.dromara.common.mail.config.properties.MailProperties;
import org.dromara.common.mail.core.MailBuilder;
import org.dromara.common.redis.annotation.RateLimiter;
import org.dromara.common.redis.enums.LimitType;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.common.web.core.WaveAndCircleCaptcha;
import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.awt.*;
import java.time.Duration;
import java.util.LinkedHashMap;
/**
* 验证码操作处理
*
@@ -47,9 +30,10 @@ import java.util.LinkedHashMap;
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/resource/captcha")
public class CaptchaController {
private final CaptchaProperties captchaProperties;
private final CaptchaUtils captchaUtils;
private final MailProperties mailProperties;
/**
@@ -59,25 +43,9 @@ public class CaptchaController {
* @return 操作结果
*/
@RateLimiter(key = "#phoneNumber", time = 60, count = 1)
@GetMapping("/resource/sms/code")
public R<Void> smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phoneNumber) {
if (!RegexValidator.isMobile(phoneNumber)) {
return R.fail("请输入正确的手机号!");
}
String key = GlobalConstants.CAPTCHA_CODE_KEY + phoneNumber;
String code = RandomUtil.randomNumbers(4);
// 验证码模板id 自行处理 (查数据库或写死均可)
String templateId = "";
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
SmsResponse smsResponse = smsBlend.sendMessage(phoneNumber, templateId, map);
if (!smsResponse.isSuccess()) {
log.error("验证码短信发送异常 => {}", smsResponse);
Object data = smsResponse.getData();
return R.fail(data == null ? "验证码短信发送失败" : data.toString());
}
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
@GetMapping("/sms")
public R<Void> sendSmsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phoneNumber) {
captchaUtils.generateSmsCaptcha(phoneNumber);
return R.ok();
}
@@ -87,96 +55,60 @@ public class CaptchaController {
* @param email 邮箱
* @return 操作结果
*/
@GetMapping("/resource/email/code")
public R<Void> emailCode(@NotBlank(message = "{user.email.not.blank}") String email) {
@GetMapping("/email")
public R<Void> sendEmailCode(@NotBlank(message = "{user.email.not.blank}") String email) {
if (!mailProperties.getEnabled()) {
return R.fail("当前系统没有开启邮箱功能!");
}
if (!RegexValidator.isEmail(email)) {
return R.fail("请输入正确的邮箱地址!");
}
SpringUtils.getAopProxy(this).emailCodeImpl(email);
SpringUtils.getAopProxy(this).captchaUtils.generateEmailCaptcha(email);
return R.ok();
}
/**
* 发送邮箱验证码的实际执行方法,拆分出来避免开关关闭时仍触发限流。
*
* @param email 邮箱
*/
@RateLimiter(key = "#email", time = 60, count = 1)
public void emailCodeImpl(String email) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + email;
String code = RandomUtil.randomNumbers(4);
try {
MailBuilder.of()
.to(email)
.subject("登录验证码")
.text("您本次验证码为:" + code + ",有效性为" + Constants.CAPTCHA_EXPIRATION + "分钟,请尽快填写。")
.send();
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
} catch (Exception e) {
log.error("验证码短信发送异常 => {}", e.getMessage());
throw new ServiceException(e.getMessage());
}
}
/**
* 获取图片验证码。
*
* @return 验证码信息
*/
@GetMapping("/auth/code")
public R<CaptchaVo> getCode() {
boolean captchaEnabled = captchaProperties.getEnable();
if (!captchaEnabled) {
return R.ok(new CaptchaVo(false, null, null));
}
return R.ok(SpringUtils.getAopProxy(this).getCodeImpl());
@GetMapping("/image")
public R<CaptchaVo> getImageCaptcha() {
return R.ok(captchaUtils.generateImageCaptcha());
}
/**
* 实际生成图片验证码并缓存结果。
* 获取【行为滑块验证码】生成接口
* <p>返回滑块背景图、缺口图、验证码key</p>
*
* @return 验证码信息
* @return 行为验证码vo
*/
@RateLimiter(time = 60, count = 10, limitType = LimitType.IP)
public CaptchaVo getCodeImpl() {
// 保存验证码信息
String uuid = IdUtil.simpleUUID();
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + uuid;
// 生成验证码
String captchaType = captchaProperties.getType();
CodeGenerator codeGenerator;
if ("math".equals(captchaType)) {
codeGenerator = new MathGenerator(captchaProperties.getNumberLength(), false);
} else {
codeGenerator = new RandomGenerator(captchaProperties.getCharLength());
}
WaveAndCircleCaptcha captcha = new WaveAndCircleCaptcha(160, 60);
// captcha.setBackground(Color.WHITE); // 不设置就是透明底
captcha.setFont(new Font("Arial", Font.BOLD, 45));
captcha.setGenerator(codeGenerator);
captcha.createCode();
// 如果是数学验证码,使用SpEL表达式处理验证码结果
String code = captcha.getCode();
if ("math".equals(captchaType)) {
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(StringUtils.remove(code, "="));
code = exp.getValue(String.class);
}
RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
return new CaptchaVo(true, uuid, captcha.getImageBase64());
@RequestMapping("/behavior/generate")
public ApiResponse<ImageCaptchaVO> generateBehaviorCaptcha() {
return captchaUtils.generateBehaviorCaptchaTicket();
}
/**
* 图片验证码响应对象。
* 校验【行为滑块验证码】
* POST语义更合适,提交校验参数
*
* @param captchaEnabled 是否启用验证码
* @param uuid 验证码标识
* @param img Base64 图片数据
* @param captchaKey 验证码缓存key
* @param slideX 用户滑动偏移x坐标
* @return 校验结果
*/
public record CaptchaVo(Boolean captchaEnabled, String uuid, String img) {
@PostMapping("/behavior/verify")
public ApiResponse<?> verifyBehaviorCaptcha(behaviorData data) {
return captchaUtils.verifyBehaviorCaptchaFirstStage(data.id(), data.data());
}
/**
* 行为验证码验证接口传回BO
*
* @param id 验证码唯一ID,请求登录接口时携带该标识,后端根据ID读取缓存验证码做校验
* @param data 验证码业务数据,包含Base64图片、滑块滑动轨迹信息等,详见 {@link ImageCaptchaTrack}
*/
public record behaviorData(String id, ImageCaptchaTrack data) {
}
}
@@ -16,7 +16,7 @@ import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.log.event.LoginInfoEvent;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.common.captcha.config.properties.CaptchaProperties;
import org.dromara.system.api.model.RegisterBody;
import org.dromara.system.domain.SysUser;
import org.dromara.system.domain.bo.SysUserBo;
@@ -19,7 +19,7 @@ import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.common.captcha.config.properties.CaptchaProperties;
import org.dromara.system.api.model.LoginUser;
import org.dromara.system.api.model.PasswordLoginBody;
import org.dromara.system.domain.SysUser;
@@ -155,7 +155,7 @@ spring.data:
# 数据库索引
database: 0
# redis 密码必须配置
password: ruoyi123
# password: ruoyi123
# 连接超时时间
timeout: 10s
# 是否开启ssl
+34 -1
View File
@@ -15,7 +15,7 @@ server:
# 最大线程数
max: 256
captcha:
image-captcha:
# 是否启用验证码校验
enable: true
# 验证码类型 math 数组计算 char 字符验证
@@ -25,6 +25,39 @@ captcha:
# 字符验证码长度
charLength: 4
# 行为验证码配置, 详细请看 cloud.tianai.captcha.autoconfiguration.ImageCaptchaProperties 类
captcha:
# 如果项目中使用到了redis,滑块验证码会自动把验证码数据存到redis中, 这里配置redis的key的前缀,默认是captcha:slider
prefix: captcha
# 验证码过期时间,默认是2分钟,单位毫秒, 可以根据自身业务进行调整
expire:
# 默认缓存时间 2分钟
default: 10000
# 针对 点选验证码 过期时间设置为 2分钟, 因为点选验证码验证比较慢,把过期时间调整大一些
WORD_IMAGE_CLICK: 20000
# 使用加载系统自带的资源, 默认是 false(这里系统的默认资源包含 滑动验证码模板/旋转验证码模板,如果想使用系统的模板,这里设置为true)
init-default-resource: true
# 缓存控制, 默认为false不开启
local-cache-enabled: false
# 缓存开启后,验证码会提前缓存一些生成好的验证数据, 默认是20
local-cache-size: 20
# 缓存开启后,缓存拉取失败后等待时间 默认是 5秒钟
local-cache-wait-time: 5000
# 缓存开启后,缓存检查间隔 默认是2秒钟
local-cache-period: 2000
# 缓存开启后,忽略的字段,默认是 ""(不忽略任何字段)
local-cache-ignored-cache-fields: ""
# 配置字体包,供文字点选验证码使用,可以配置多个,不配置使用默认的字体
font-path:
- classpath:font/SimHei.ttf
secondary:
# 二次验证, 默认false 不开启
enabled: true
# 二次验证过期时间, 默认 2分钟
expire: 240000
# 二次验证缓存key前缀,默认是 captcha:secondary
keyPrefix: "captcha:secondary"
# 日志配置
logging:
level:
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB