Skip to content

Commit

Permalink
feat: support arithmetic captcha generation with random operators
Browse files Browse the repository at this point in the history
  • Loading branch information
guqing committed Jun 18, 2024
1 parent 2bcf0e2 commit 474d5d5
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
38 changes: 35 additions & 3 deletions src/main/java/run/halo/comment/widget/SettingConfigGetter.java
Original file line number Diff line number Diff line change
@@ -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<SecurityConfig> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerResponse> endpoint() {
Expand All @@ -24,7 +26,9 @@ public RouterFunction<ServerResponse> endpoint() {
}

private Mono<ServerResponse> 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()));
}

Expand Down
72 changes: 61 additions & 11 deletions src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Graphics2D, String> drawCaptchaTextFunc) {
BufferedImage bufferedImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = bufferedImage.createGraphics();

Expand All @@ -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);
Expand All @@ -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() {
Expand All @@ -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++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public interface CaptchaManager {

Mono<Void> invalidate(String id);

Mono<Captcha> generate(ServerWebExchange exchange);
Mono<Captcha> generate(ServerWebExchange exchange, CaptchaType type);

record Captcha(String id, String code, String imageBase64) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,20 @@ public Mono<Void> invalidate(String id) {
}

@Override
public Mono<Captcha> generate(ServerWebExchange exchange) {
return doGenerate()
public Mono<Captcha> generate(ServerWebExchange exchange, CaptchaType type) {
return doGenerate(type)
.doOnNext(captcha -> captchaCookieResolver.setCookie(exchange, captcha.id()));
}

private Mono<Captcha> doGenerate() {
var captchaCode = CaptchaGenerator.generateRandomText();
private Mono<Captcha> 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));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package run.halo.comment.widget.captcha;

public enum CaptchaType {
ALPHANUMERIC,
ARITHMETIC
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,24 @@ public Mono<Void> 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<Void> sendCaptchaRequiredResponse(ServerWebExchange exchange, ResponseStatusException e) {
private Mono<Void> 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());
Expand All @@ -84,19 +87,20 @@ private byte[] getResponseData(ProblemDetail problemDetail) {
}
}

private Mono<Void> validateCaptcha(ServerWebExchange exchange, WebFilterChain chain) {
private Mono<Void> 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 -> {
if (valid) {
captchaCookieResolver.expireCookie(exchange);
return chain.filter(exchange);
}
return sendCaptchaRequiredResponse(exchange, new InvalidCaptchaCodeException());
return sendCaptchaRequiredResponse(exchange, captchaConfig, new InvalidCaptchaCodeException());
});
}

Expand Down
10 changes: 10 additions & 0 deletions src/main/resources/extensions/settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit 474d5d5

Please sign in to comment.