diff --git a/README.md b/README.md index 003a39045..f31c02b89 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,7 @@ > 系统演示: [传送门](https://plus-doc.dromara.org/#/common/demo_system) -> 官方前端项目地址: 基于 Vue + ElementPlus 版本前端项目 [gitee](https://gitee.com/JavaLionLi/plus-ui) - [github](https://github.com/CrazyLionCat/plus-ui) - [gitcode](https://gitcode.com/dromara/plus-ui)
-> 官方前端项目地址: 基于 React + Ant Design 版本前端项目 [plus-ui-react](https://gitee.com/JavaLionLi/plus-ui/tree/6.X-React/) +> 官方前端项目地址: [gitee](https://gitee.com/JavaLionLi/plus-ui) - [github](https://github.com/CrazyLionCat/plus-ui) - [gitcode](https://gitcode.com/dromara/plus-ui)
> 成员前端项目地址: 基于vben5 [ruoyi-plus-vben5](https://github.com/imdap/ruoyi-plus-vben5)
> 成员前端项目地址: 基于soybean [ruoyi-plus-soybean](https://gitee.com/xlsea/ruoyi-plus-soybean)
> 成员项目地址: 删除多租户与工作流 [RuoYi-Vue-Plus-Single](https://gitee.com/ColorDreams/RuoYi-Vue-Plus-Single)
diff --git a/pom.xml b/pom.xml index ab707fcc6..84e1aa474 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,8 @@ 1.3.0 + + 1.5.5 3.3.7 @@ -408,6 +410,13 @@ ${ip2region.version} + + + cloud.tianai.captcha + tianai-captcha-springboot-starter + ${tianai-captcha.version} + + org.dromara diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml index 8f2d51cdf..5489be138 100644 --- a/ruoyi-admin/pom.xml +++ b/ruoyi-admin/pom.xml @@ -117,6 +117,12 @@ test + + + org.dromara + ruoyi-common-captcha + + diff --git a/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java b/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java index d91373890..525101f65 100644 --- a/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java +++ b/ruoyi-admin/src/main/java/org/dromara/web/controller/CaptchaController.java @@ -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 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 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 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 emailCode(@NotBlank(message = "{user.email.not.blank}") String email) { + @GetMapping("/email") + public R 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 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 getImageCaptcha() { + return R.ok(captchaUtils.generateImageCaptcha()); } /** - * 实际生成图片验证码并缓存结果。 + * 获取【行为滑块验证码】生成接口 + *

返回滑块背景图、缺口图、验证码key

* - * @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 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) { + } + + } diff --git a/ruoyi-admin/src/main/java/org/dromara/web/service/SysRegisterService.java b/ruoyi-admin/src/main/java/org/dromara/web/service/SysRegisterService.java index e5037f5e8..00537563f 100644 --- a/ruoyi-admin/src/main/java/org/dromara/web/service/SysRegisterService.java +++ b/ruoyi-admin/src/main/java/org/dromara/web/service/SysRegisterService.java @@ -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; diff --git a/ruoyi-admin/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java b/ruoyi-admin/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java index cdb6022c7..bcfaf881c 100644 --- a/ruoyi-admin/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java +++ b/ruoyi-admin/src/main/java/org/dromara/web/service/impl/PasswordAuthStrategy.java @@ -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; diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index ae9b6ff9e..d27d2d3ab 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -155,7 +155,7 @@ spring.data: # 数据库索引 database: 0 # redis 密码必须配置 - password: ruoyi123 + # password: ruoyi123 # 连接超时时间 timeout: 10s # 是否开启ssl diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 32b8a7429..918943c22 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -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: diff --git a/ruoyi-admin/src/main/resources/bgimages/48.jpg b/ruoyi-admin/src/main/resources/bgimages/48.jpg new file mode 100644 index 000000000..ae731e4ee Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/48.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/a.jpg b/ruoyi-admin/src/main/resources/bgimages/a.jpg new file mode 100644 index 000000000..cef38ec0d Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/a.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/b.jpg b/ruoyi-admin/src/main/resources/bgimages/b.jpg new file mode 100644 index 000000000..72fdb5639 Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/b.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/c.jpg b/ruoyi-admin/src/main/resources/bgimages/c.jpg new file mode 100644 index 000000000..7c8274f02 Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/c.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/d.jpg b/ruoyi-admin/src/main/resources/bgimages/d.jpg new file mode 100644 index 000000000..6057faa35 Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/d.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/e.jpg b/ruoyi-admin/src/main/resources/bgimages/e.jpg new file mode 100644 index 000000000..b5e22e369 Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/e.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/g.jpg b/ruoyi-admin/src/main/resources/bgimages/g.jpg new file mode 100644 index 000000000..ae707ba1b Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/g.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/h.jpg b/ruoyi-admin/src/main/resources/bgimages/h.jpg new file mode 100644 index 000000000..2326c881f Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/h.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/i.jpg b/ruoyi-admin/src/main/resources/bgimages/i.jpg new file mode 100644 index 000000000..f9714d9c9 Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/i.jpg differ diff --git a/ruoyi-admin/src/main/resources/bgimages/j.jpg b/ruoyi-admin/src/main/resources/bgimages/j.jpg new file mode 100644 index 000000000..8f9056f4b Binary files /dev/null and b/ruoyi-admin/src/main/resources/bgimages/j.jpg differ diff --git a/ruoyi-common/pom.xml b/ruoyi-common/pom.xml index 7db451d40..7e1d4519a 100644 --- a/ruoyi-common/pom.xml +++ b/ruoyi-common/pom.xml @@ -18,6 +18,7 @@ ruoyi-common-bom ruoyi-common-social + ruoyi-common-captcha ruoyi-common-core ruoyi-common-doc ruoyi-common-excel diff --git a/ruoyi-common/ruoyi-common-bom/pom.xml b/ruoyi-common/ruoyi-common-bom/pom.xml index 8d8b12dca..6cd871878 100644 --- a/ruoyi-common/ruoyi-common-bom/pom.xml +++ b/ruoyi-common/ruoyi-common-bom/pom.xml @@ -19,6 +19,13 @@ + + + org.dromara + ruoyi-common-captcha + ${revision} + + org.dromara diff --git a/ruoyi-common/ruoyi-common-captcha/pom.xml b/ruoyi-common/ruoyi-common-captcha/pom.xml new file mode 100644 index 000000000..ea2c720ba --- /dev/null +++ b/ruoyi-common/ruoyi-common-captcha/pom.xml @@ -0,0 +1,71 @@ + + + + org.dromara + ruoyi-common + ${revision} + + 4.0.0 + + ruoyi-common-captcha + + + ruoyi-common-code 验证码模块 + + + + + org.springframework.boot + spring-boot-autoconfigure + + + org.projectlombok + lombok + + + cn.hutool + hutool-core + + + org.dromara + ruoyi-common-core + + + org.dromara + ruoyi-common-redis + + + org.dromara + ruoyi-common-web + + + org.dromara + ruoyi-common-mail + + + cloud.tianai.captcha + tianai-captcha-springboot-starter + + + org.dromara.sms4j + sms4j-api + 3.3.5 + compile + + + org.dromara.sms4j + sms4j-core + 3.3.5 + compile + + + javax.annotation + javax.annotation-api + 1.3.2 + compile + + + + diff --git a/ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/config/CaptchaConfig.java b/ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/config/CaptchaConfig.java new file mode 100644 index 000000000..e3efd3800 --- /dev/null +++ b/ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/config/CaptchaConfig.java @@ -0,0 +1,45 @@ +package org.dromara.common.captcha.config; + +import cloud.tianai.captcha.common.constant.CaptchaTypeConstant; +import cloud.tianai.captcha.resource.ResourceStore; +import cloud.tianai.captcha.resource.common.model.dto.Resource; +import cloud.tianai.captcha.resource.impl.LocalMemoryResourceStore; +import org.dromara.common.captcha.config.properties.CaptchaProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 验证码配置 + * + * @author Lion Li + */ +@AutoConfiguration +@EnableConfigurationProperties(CaptchaProperties.class) +@Configuration +public class CaptchaConfig { + + + @Bean + public ResourceStore resourceStore() { + // 使用简单的本地内存存储器,实际项目中可以使用数据库等存储 + LocalMemoryResourceStore resourceStore = new LocalMemoryResourceStore(); + + // 2. 添加自定义背景图片 + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/a.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/b.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/c.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/d.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/e.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/g.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/h.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/i.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.SLIDER, new Resource("classpath", "bgimages/j.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.ROTATE, new Resource("classpath", "bgimages/48.jpg", "default")); + resourceStore.addResource(CaptchaTypeConstant.CONCAT, new Resource("classpath", "bgimages/48.jpg", "default")); +// resourceStore.addResource(CaptchaTypeConstant.WORD_IMAGE_CLICK, new Resource("classpath", "bgimages/c.jpg", "default")); + + return resourceStore; + } +} diff --git a/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/properties/CaptchaProperties.java b/ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/config/properties/CaptchaProperties.java similarity index 82% rename from ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/properties/CaptchaProperties.java rename to ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/config/properties/CaptchaProperties.java index 17f165549..0a86e4d26 100644 --- a/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/properties/CaptchaProperties.java +++ b/ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/config/properties/CaptchaProperties.java @@ -1,4 +1,4 @@ -package org.dromara.common.web.config.properties; +package org.dromara.common.captcha.config.properties; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -9,7 +9,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @author Lion Li */ @Data -@ConfigurationProperties(prefix = "captcha") +@ConfigurationProperties(prefix = "image-captcha") public class CaptchaProperties { /** diff --git a/ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/utils/CaptchaUtils.java b/ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/utils/CaptchaUtils.java new file mode 100644 index 000000000..50f3052c4 --- /dev/null +++ b/ruoyi-common/ruoyi-common-captcha/src/main/java/org/dromara/common/captcha/utils/CaptchaUtils.java @@ -0,0 +1,258 @@ +package org.dromara.common.captcha.utils; + +import cloud.tianai.captcha.application.ImageCaptchaApplication; +import cloud.tianai.captcha.application.vo.ImageCaptchaVO; +import cloud.tianai.captcha.common.constant.CaptchaTypeConstant; +import cloud.tianai.captcha.common.response.ApiResponse; +import cloud.tianai.captcha.generator.common.model.dto.GenerateParam; +import cloud.tianai.captcha.validator.common.model.dto.ImageCaptchaTrack; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.captcha.config.properties.CaptchaProperties; +import org.dromara.common.core.constant.Constants; +import org.dromara.common.core.constant.GlobalConstants; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.core.utils.regex.RegexValidator; +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.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.stereotype.Component; + +import java.awt.*; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 验证码工具类 + * 支持:图形验证码、手机短信验证码、邮箱验证码、行为验证码 + * 规范:一次下发生成验证码、二次校验验证码;内置通用随机码生成、图片生成基础能力 + * + * @author xiaozhi + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CaptchaUtils { + + private final CaptchaProperties captchaProperties; + private final ImageCaptchaApplication imageCaptchaApplication; + + /** + * SpEL解析器,单例复用,避免频繁创建 + */ + private final ExpressionParser expressionParser = new SpelExpressionParser(); + + + // ====================== 【图形验证码】 ====================== + + /** + * 图片验证码响应对象。 + * + * @param captchaEnabled 是否启用验证码 + * @param uuid 验证码标识 + * @param img Base64 图片数据 + */ + public record CaptchaVo(Boolean captchaEnabled, String uuid, String img) { + } + + /** + * 生成图形验证码 + * 限流:同一IP 60秒最多10次请求 + * + * @return CaptchaVo + */ + @RateLimiter(time = 60, count = 10, limitType = LimitType.IP) + public CaptchaVo generateImageCaptcha() { + boolean captchaEnabled = captchaProperties.getEnable(); + if (!captchaEnabled) { + return new CaptchaVo(false, null, null); + } + // 保存验证码信息 + 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(); + String code = captcha.getCode(); + + // 数学验证码,计算表达式结果存储 + if ("math".equals(captchaType)) { + Expression exp = expressionParser.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()); + } + + /** + * 校验图形验证码 + *

【一次性消费】校验通过自动删除缓存,防止重复使用、重放攻击 + * + * @param uuid 前端传递验证码唯一标识 + * @param inputCode 用户输入验证码 + * @return true=验证通过 false=验证失败 + */ + public boolean verifyImageCaptcha(String uuid, String inputCode) { + + return true; + } + + // ====================== 【手机短信验证码】 ====================== + + /** + * 生成手机短信验证码并发送 + * + * @param phoneNumber 手机号 + */ + public void generateSmsCaptcha(String phoneNumber) { + if (!RegexValidator.isMobile(phoneNumber)) { + return; + } + String key = GlobalConstants.CAPTCHA_CODE_KEY + phoneNumber; + String code = RandomUtil.randomNumbers(6); + // 验证码模板id 自行处理 (查数据库或写死均可) + String templateId = ""; + LinkedHashMap map = new LinkedHashMap<>(1); + map.put("code", code); + SmsBlend smsBlend = SmsFactory.getSmsBlend("config1"); + + SmsResponse smsResponse; + try { + smsResponse = smsBlend.sendMessage(phoneNumber, templateId, map); + } catch (Exception e) { + log.error("验证码短信发送异常,手机号:{}", phoneNumber, e); + return; + } + if (!smsResponse.isSuccess()) { + log.error("验证码短信发送失败 => {}", smsResponse); + return; + } + // 发送成功才写入缓存 + RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); + } + + /** + * 校验短信验证码 + *

【一次性消费】校验成功自动删除缓存 + * + * @param phoneNumber 手机号 + * @param inputCode 用户输入验证码 + * @return 是否通过 + */ + public boolean verifySmsCaptcha(String phoneNumber, String inputCode) { + return true; + } + // ====================== 【邮箱验证码】 ====================== + + /** + * 生成邮箱验证码并发送 + * 限流:同一个邮箱60秒最多1次 + * + * @param email 邮箱地址 + */ + @RateLimiter(key = "#email", time = 60, count = 1) + public void generateEmailCaptcha(String email) { + if (!RegexValidator.isEmail(email)) { + return; + } + String key = GlobalConstants.CAPTCHA_CODE_KEY + email; + String code = RandomUtil.randomNumbers(6); + 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()); + } + } + + /** + * 校验邮箱验证码 + * + * @param inputCode 用户输入 + * @param cacheCode 缓存验证码 + * @return 是否通过 + */ + public boolean verifyEmailCaptcha(String inputCode, String cacheCode) { + return true; + } + + // ====================== 【行为验证码(滑块/点选)】 ====================== + + /** + * 生成行为验证码初始票据 ticket + * 用于前端拉取滑块背景图、滑块图 + * + * @return ticket字符串 + */ + public ApiResponse generateBehaviorCaptchaTicket() { + String type; + int i = ThreadLocalRandom.current().nextInt(0, 4); + if (i == 0) { + type = CaptchaTypeConstant.SLIDER; + } else if (i == 1) { + type = CaptchaTypeConstant.CONCAT; + } else if (i == 2) { + type = CaptchaTypeConstant.ROTATE; + } else { + type = CaptchaTypeConstant.WORD_IMAGE_CLICK; + } + GenerateParam generateParam = new GenerateParam(); + // 要生成的验证码类型 + generateParam.setType(type); + return imageCaptchaApplication.generateCaptcha(generateParam); + } + + + /** + * 行为验证码一次校验(滑块坐标、轨迹校验由第三方SDK/自研逻辑实现) + * 本方法仅做凭证匹配占位,实际行为判定需要额外业务逻辑 + * + * @param ticket 请求票据 + * @param cacheTicket 缓存票据 + * @return 票据是否匹配 + */ + public ApiResponse verifyBehaviorCaptchaFirstStage(String id, ImageCaptchaTrack data) { + ApiResponse response = imageCaptchaApplication.matching(id, data); + if (response.isSuccess()) { + return ApiResponse.ofSuccess(Collections.singletonMap("id", id)); + } + return response; + } + + /** + * 行为验证码二次验证(业务接口调用,例如登录接口) + * + * @param verifyTicket 前端传入二次凭证 + * @param cacheVerifyTicket 服务端缓存凭证 + * @return 验证结果 + */ + public boolean verifyBehaviorCaptchaSecondStage(String verifyTicket, String cacheVerifyTicket) { + return true; + } +} diff --git a/ruoyi-common/ruoyi-common-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..ee7d08602 --- /dev/null +++ b/ruoyi-common/ruoyi-common-captcha/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.dromara.common.captcha.config.CaptchaConfig diff --git a/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/CaptchaConfig.java b/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/CaptchaConfig.java deleted file mode 100644 index 7140db947..000000000 --- a/ruoyi-common/ruoyi-common-web/src/main/java/org/dromara/common/web/config/CaptchaConfig.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.dromara.common.web.config; - -import org.dromara.common.web.config.properties.CaptchaProperties; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.context.properties.EnableConfigurationProperties; - -/** - * 验证码配置 - * - * @author Lion Li - */ -@AutoConfiguration -@EnableConfigurationProperties(CaptchaProperties.class) -public class CaptchaConfig { - -} diff --git a/ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 7299472a0..1f706e721 100644 --- a/ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/ruoyi-common/ruoyi-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,3 @@ -org.dromara.common.web.config.CaptchaConfig org.dromara.common.web.config.FilterConfig org.dromara.common.web.config.I18nConfig org.dromara.common.web.config.ResourcesConfig