From 474d5d5964602858c497bb3651c41362a01fa750 Mon Sep 17 00:00:00 2001 From: guqing Date: Tue, 18 Jun 2024 17:21:54 +0800 Subject: [PATCH] feat: support arithmetic captcha generation with random operators --- .../comment/widget/DefaultCommentWidget.java | 4 +- .../comment/widget/SettingConfigGetter.java | 38 +++++++++- .../widget/captcha/CaptchaEndpoint.java | 6 +- .../widget/captcha/CaptchaGenerator.java | 72 ++++++++++++++++--- .../widget/captcha/CaptchaManager.java | 2 +- .../widget/captcha/CaptchaManagerImpl.java | 16 +++-- .../comment/widget/captcha/CaptchaType.java | 6 ++ .../widget/captcha/CommentCaptchaFilter.java | 20 +++--- src/main/resources/extensions/settings.yaml | 10 +++ 9 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 src/main/java/run/halo/comment/widget/captcha/CaptchaType.java diff --git a/src/main/java/run/halo/comment/widget/DefaultCommentWidget.java b/src/main/java/run/halo/comment/widget/DefaultCommentWidget.java index c30aa8d..baeb9f5 100644 --- a/src/main/java/run/halo/comment/widget/DefaultCommentWidget.java +++ b/src/main/java/run/halo/comment/widget/DefaultCommentWidget.java @@ -80,8 +80,8 @@ private String commentHtml(IAttribute groupAttribute, IAttribute kindAttribute, properties.setProperty("avatarPolicy", String.valueOf(avatarConfig.getPolicy())); var captcha = settingConfigGetter.getSecurityConfig() - .map(SettingConfigGetter.SecurityConfig::captcha) - .map(SettingConfigGetter.CaptchaConfig::anonymousCommentCaptcha) + .map(SettingConfigGetter.SecurityConfig::getCaptcha) + .map(SettingConfigGetter.CaptchaConfig::isAnonymousCommentCaptcha) .blockOptional() .orElse(false); properties.setProperty("captchaEnabled", String.valueOf(captcha)); diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java index f9c013c..1b20d3f 100644 --- a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java @@ -1,17 +1,49 @@ package run.halo.comment.widget; +import lombok.Data; +import lombok.Getter; +import lombok.experimental.Accessors; +import org.springframework.lang.NonNull; import reactor.core.publisher.Mono; +import run.halo.comment.widget.captcha.CaptchaType; public interface SettingConfigGetter { Mono getSecurityConfig(); - record SecurityConfig(CaptchaConfig captcha) { + @Data + @Accessors(chain = true) + class SecurityConfig { + @Getter(onMethod_ = @NonNull) + private CaptchaConfig captcha; + + public SecurityConfig setCaptcha(CaptchaConfig captcha) { + this.captcha = (captcha == null ? CaptchaConfig.empty() : captcha); + return this; + } + public static SecurityConfig empty() { - return new SecurityConfig(new CaptchaConfig(false)); + return new SecurityConfig() + .setCaptcha(CaptchaConfig.empty()); } } - record CaptchaConfig(boolean anonymousCommentCaptcha) { + @Data + @Accessors(chain = true) + class CaptchaConfig { + + private boolean anonymousCommentCaptcha; + + @Getter(onMethod_ = @NonNull) + private CaptchaType type = CaptchaType.ALPHANUMERIC; + + public CaptchaConfig setType(CaptchaType type) { + this.type = (type == null ? CaptchaType.ALPHANUMERIC : type); + return this; + } + + public static CaptchaConfig empty() { + return new CaptchaConfig(); + } } } diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaEndpoint.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaEndpoint.java index 78cd3e6..18edb95 100644 --- a/src/main/java/run/halo/comment/widget/captcha/CaptchaEndpoint.java +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaEndpoint.java @@ -9,12 +9,14 @@ import reactor.core.publisher.Mono; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; +import run.halo.comment.widget.SettingConfigGetter; @Component @RequiredArgsConstructor public class CaptchaEndpoint implements CustomEndpoint { private final CaptchaManager captchaManager; + private final SettingConfigGetter settingConfigGetter; @Override public RouterFunction endpoint() { @@ -24,7 +26,9 @@ public RouterFunction endpoint() { } private Mono generateCaptcha(ServerRequest request) { - return captchaManager.generate(request.exchange()) + return settingConfigGetter.getSecurityConfig() + .map(SettingConfigGetter.SecurityConfig::getCaptcha) + .flatMap(captchaConfig -> captchaManager.generate(request.exchange(), captchaConfig.getType())) .flatMap(captcha -> ServerResponse.ok().bodyValue(captcha.imageBase64())); } diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java index 340bee9..ea5d651 100644 --- a/src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java @@ -8,9 +8,9 @@ import java.io.InputStream; import java.util.Base64; import java.util.Random; +import java.util.function.Function; import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; -import org.springframework.util.Assert; @Slf4j @UtilityClass @@ -26,8 +26,15 @@ public class CaptchaGenerator { customFont = loadArialFont(); } - public static BufferedImage generateCaptchaImage(String captchaText) { - Assert.hasText(captchaText, "Captcha text must not be blank"); + public static Captcha generateMathCaptcha() { + return generateCaptchaImage(CaptchaGenerator::drawMathCaptchaText); + } + + public static Captcha generateSimpleCaptcha() { + return generateCaptchaImage(CaptchaGenerator::drawSimpleText); + } + + private static Captcha generateCaptchaImage(Function drawCaptchaTextFunc) { BufferedImage bufferedImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); Graphics2D g2d = bufferedImage.createGraphics(); @@ -37,14 +44,10 @@ public static BufferedImage generateCaptchaImage(String captchaText) { g2d.setFont(customFont); - // draw captcha text - Random random = new Random(); - for (int i = 0; i < captchaText.length(); i++) { - g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256))); - g2d.drawString(String.valueOf(captchaText.charAt(i)), 20 + i * 24, 30); - } + var captchaText = drawCaptchaTextFunc.apply(g2d); // add some noise + Random random = new Random(); for (int i = 0; i < 10; i++) { g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256))); int x1 = random.nextInt(WIDTH); @@ -55,7 +58,48 @@ public static BufferedImage generateCaptchaImage(String captchaText) { } g2d.dispose(); - return bufferedImage; + return new Captcha(captchaText, bufferedImage); + } + + private static String drawMathCaptchaText(Graphics2D g2d) { + Random random = new Random(); + int num1 = random.nextInt(90) + 1; + int num2 = random.nextInt(90) + 1; + char operator = getRandomOperator(); + + int result; + String mathText = switch (operator) { + case '+' -> { + result = num1 + num2; + yield num1 + " + " + num2 + " = ?"; + } + case '-' -> { + result = num1 - num2; + yield num1 + " - " + num2 + " = ?"; + } + case '*' -> { + result = num1 * num2; + yield num1 + " * " + num2 + " = ?"; + } + default -> throw new IllegalStateException("Unexpected value: " + operator); + }; + + g2d.setColor(Color.BLACK); + g2d.drawString(mathText, 20, 30); + return String.valueOf(result); + } + + public record Captcha(String code, BufferedImage image) { + } + + private static String drawSimpleText(Graphics2D g2d) { + var captchaText = generateRandomText(); + Random random = new Random(); + for (int i = 0; i < captchaText.length(); i++) { + g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256))); + g2d.drawString(String.valueOf(captchaText.charAt(i)), 20 + i * 24, 30); + } + return captchaText; } private static Font loadArialFont() { @@ -71,7 +115,13 @@ private static Font loadArialFont() { } } - public static String generateRandomText() { + private static char getRandomOperator() { + char[] operators = {'+', '-', '*'}; + Random random = new Random(); + return operators[random.nextInt(operators.length)]; + } + + private static String generateRandomText() { StringBuilder sb = new StringBuilder(CHAR_LENGTH); Random random = new Random(); for (int i = 0; i < CHAR_LENGTH; i++) { diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java index 3926bd0..6a6ab10 100644 --- a/src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java @@ -8,7 +8,7 @@ public interface CaptchaManager { Mono invalidate(String id); - Mono generate(ServerWebExchange exchange); + Mono generate(ServerWebExchange exchange, CaptchaType type); record Captcha(String id, String code, String imageBase64) { } diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaManagerImpl.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaManagerImpl.java index 4c65787..7c20373 100644 --- a/src/main/java/run/halo/comment/widget/captcha/CaptchaManagerImpl.java +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaManagerImpl.java @@ -38,18 +38,20 @@ public Mono invalidate(String id) { } @Override - public Mono generate(ServerWebExchange exchange) { - return doGenerate() + public Mono generate(ServerWebExchange exchange, CaptchaType type) { + return doGenerate(type) .doOnNext(captcha -> captchaCookieResolver.setCookie(exchange, captcha.id())); } - private Mono doGenerate() { - var captchaCode = CaptchaGenerator.generateRandomText(); + private Mono doGenerate(CaptchaType type) { return Mono.fromSupplier(() -> { - var image = CaptchaGenerator.generateCaptchaImage(captchaCode); - var imageBase64 = encodeBufferedImageToDataUri(image); + var captcha = switch (type) { + case ALPHANUMERIC -> CaptchaGenerator.generateSimpleCaptcha(); + case ARITHMETIC -> CaptchaGenerator.generateMathCaptcha(); + }; + var imageBase64 = encodeBufferedImageToDataUri(captcha.image()); var id = UUID.randomUUID().toString(); - return new Captcha(id, captchaCode, imageBase64); + return new Captcha(id, captcha.code(), imageBase64); }) .subscribeOn(Schedulers.boundedElastic()) .doOnNext(captcha -> captchaCache.put(captcha.id(), captcha)); diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaType.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaType.java new file mode 100644 index 0000000..5202383 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaType.java @@ -0,0 +1,6 @@ +package run.halo.comment.widget.captcha; + +public enum CaptchaType { + ALPHANUMERIC, + ARITHMETIC +} diff --git a/src/main/java/run/halo/comment/widget/captcha/CommentCaptchaFilter.java b/src/main/java/run/halo/comment/widget/captcha/CommentCaptchaFilter.java index e4b3098..84e5217 100644 --- a/src/main/java/run/halo/comment/widget/captcha/CommentCaptchaFilter.java +++ b/src/main/java/run/halo/comment/widget/captcha/CommentCaptchaFilter.java @@ -51,21 +51,24 @@ public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilter return pathMatcher.matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) .flatMap(result -> settingConfigGetter.getSecurityConfig()) - .map(SettingConfigGetter.SecurityConfig::captcha) + .map(SettingConfigGetter.SecurityConfig::getCaptcha) .filterWhen(captchaConfig -> isAnonymousCommenter(exchange)) .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) .flatMap(captchaConfig -> { - if (!captchaConfig.anonymousCommentCaptcha()) { + if (!captchaConfig.isAnonymousCommentCaptcha()) { return chain.filter(exchange); } - return validateCaptcha(exchange, chain); + return validateCaptcha(exchange, chain, captchaConfig); }); } - private Mono sendCaptchaRequiredResponse(ServerWebExchange exchange, ResponseStatusException e) { + private Mono sendCaptchaRequiredResponse(ServerWebExchange exchange, + SettingConfigGetter.CaptchaConfig captchaConfig, + ResponseStatusException e) { + var type = captchaConfig.getType(); exchange.getResponse().getHeaders().addIfAbsent(CAPTCHA_REQUIRED_HEADER, "true"); exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); - return captchaManager.generate(exchange) + return captchaManager.generate(exchange, type) .flatMap(captcha -> { var problemDetail = toProblemDetail(e); problemDetail.setProperty("captcha", captcha.imageBase64()); @@ -84,11 +87,12 @@ private byte[] getResponseData(ProblemDetail problemDetail) { } } - private Mono validateCaptcha(ServerWebExchange exchange, WebFilterChain chain) { + private Mono validateCaptcha(ServerWebExchange exchange, WebFilterChain chain, + SettingConfigGetter.CaptchaConfig captchaConfig) { var captchaCodeOpt = getCaptchaCode(exchange); var cookie = captchaCookieResolver.resolveCookie(exchange); if (captchaCodeOpt.isEmpty() || cookie == null) { - return sendCaptchaRequiredResponse(exchange, new CaptchaCodeMissingException()); + return sendCaptchaRequiredResponse(exchange, captchaConfig, new CaptchaCodeMissingException()); } return captchaManager.verify(cookie.getValue(), captchaCodeOpt.get()) .flatMap(valid -> { @@ -96,7 +100,7 @@ private Mono validateCaptcha(ServerWebExchange exchange, WebFilterChain ch captchaCookieResolver.expireCookie(exchange); return chain.filter(exchange); } - return sendCaptchaRequiredResponse(exchange, new InvalidCaptchaCodeException()); + return sendCaptchaRequiredResponse(exchange, captchaConfig, new InvalidCaptchaCodeException()); }); } diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index 1b86364..badf54c 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -44,6 +44,16 @@ spec: id: anonymousCommentCaptcha key: anonymousCommentCaptcha value: false + - $formkit: select + label: 验证码类型 + if: "$get(anonymousCommentCaptcha).value === true" + name: type + value: "ALPHANUMERIC" + options: + - label: 字母数字组合 + value: "ALPHANUMERIC" + - label: 算术验证码 + value: "ARITHMETIC" - group: avatar label: 头像设置 formSchema: