Skip to content

Commit e165d76

Browse files
committed
feat: Add CAPTCHA verification for new anonymous comments to enhance security
1 parent 40de277 commit e165d76

11 files changed

+406
-1
lines changed

build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ repositories {
1616
}
1717

1818
dependencies {
19-
implementation platform('run.halo.tools.platform:plugin:2.9.0-SNAPSHOT')
19+
implementation platform('run.halo.tools.platform:plugin:2.13.0-SNAPSHOT')
2020
compileOnly 'run.halo.app:api'
2121

2222
testImplementation 'run.halo.app:api'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package run.halo.comment.widget;
2+
3+
import reactor.core.publisher.Mono;
4+
5+
public interface SettingConfigGetter {
6+
7+
Mono<SecurityConfig> getSecurityConfig();
8+
9+
record SecurityConfig(boolean anonymousCommentCaptcha) {
10+
public static SecurityConfig empty() {
11+
return new SecurityConfig(false);
12+
}
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package run.halo.comment.widget;
2+
3+
import static run.halo.app.extension.index.query.QueryFactory.equal;
4+
5+
import java.util.function.Function;
6+
import com.google.common.cache.Cache;
7+
import com.google.common.cache.CacheBuilder;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.stereotype.Component;
10+
import reactor.core.publisher.Mono;
11+
import run.halo.app.extension.ConfigMap;
12+
import run.halo.app.extension.DefaultExtensionMatcher;
13+
import run.halo.app.extension.ExtensionClient;
14+
import run.halo.app.extension.controller.Controller;
15+
import run.halo.app.extension.controller.ControllerBuilder;
16+
import run.halo.app.extension.controller.Reconciler;
17+
import run.halo.app.extension.router.selector.FieldSelector;
18+
import run.halo.app.plugin.ReactiveSettingFetcher;
19+
20+
@Component
21+
@RequiredArgsConstructor
22+
public class SettingConfigGetterImpl implements SettingConfigGetter {
23+
private final ReactiveSettingFetcher settingFetcher;
24+
private final SettingConfigCache settingConfigCache;
25+
26+
@Override
27+
public Mono<SecurityConfig> getSecurityConfig() {
28+
return settingConfigCache.get("security",
29+
key -> settingFetcher.fetch("security", SecurityConfig.class)
30+
.defaultIfEmpty(SecurityConfig.empty())
31+
);
32+
}
33+
34+
interface SettingConfigCache {
35+
<T> Mono<T> get(String key, Function<String, Mono<T>> loader);
36+
}
37+
38+
@Component
39+
@RequiredArgsConstructor
40+
static class SettingConfigCacheImpl implements Reconciler<Reconciler.Request>, SettingConfigCache {
41+
private static final String CONFIG_NAME = "plugin-comment-widget-configmap";
42+
43+
private final Cache<String, Object> cache = CacheBuilder.newBuilder()
44+
.maximumSize(10)
45+
.build();
46+
47+
private final ExtensionClient client;
48+
49+
@SuppressWarnings("unchecked")
50+
public <T> Mono<T> get(String key, Function<String, Mono<T>> loader) {
51+
return Mono.justOrEmpty(cache.getIfPresent(key))
52+
.switchIfEmpty(loader.apply(key).doOnNext(value -> cache.put(key, value)))
53+
.map(object -> (T) object);
54+
}
55+
56+
@Override
57+
public Result reconcile(Request request) {
58+
cache.invalidateAll();
59+
return Result.doNotRetry();
60+
}
61+
62+
@Override
63+
public Controller setupWith(ControllerBuilder builder) {
64+
var extension = new ConfigMap();
65+
var extensionMatcher = DefaultExtensionMatcher.builder(client, extension.groupVersionKind())
66+
.fieldSelector(FieldSelector.of(equal("metadata.name", CONFIG_NAME)))
67+
.build();
68+
return builder
69+
.extension(extension)
70+
.syncAllOnStart(false)
71+
.onAddMatcher(extensionMatcher)
72+
.onUpdateMatcher(extensionMatcher)
73+
.build();
74+
}
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package run.halo.comment.widget.captcha;
2+
3+
import java.time.Duration;
4+
import org.springframework.http.HttpCookie;
5+
import org.springframework.lang.Nullable;
6+
import org.springframework.web.server.ServerWebExchange;
7+
8+
public interface CaptchaCookieResolver {
9+
@Nullable
10+
HttpCookie resolveCookie(ServerWebExchange exchange);
11+
12+
void setCookie(ServerWebExchange exchange, String value);
13+
14+
void expireCookie(ServerWebExchange exchange);
15+
16+
String getCookieName();
17+
18+
Duration getCookieMaxAge();
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package run.halo.comment.widget.captcha;
2+
3+
import java.time.Duration;
4+
import lombok.Getter;
5+
import org.springframework.http.HttpCookie;
6+
import org.springframework.http.ResponseCookie;
7+
import org.springframework.lang.Nullable;
8+
import org.springframework.util.Assert;
9+
import org.springframework.web.server.ServerWebExchange;
10+
11+
@Getter
12+
public class CaptchaCookieResolverImpl implements CaptchaCookieResolver {
13+
public static final String CAPTCHA_COOKIE_KEY = "comment-captcha";
14+
15+
private final String cookieName = CAPTCHA_COOKIE_KEY;
16+
17+
private final Duration cookieMaxAge = Duration.ofDays(1);
18+
19+
@Override
20+
@Nullable
21+
public HttpCookie resolveCookie(ServerWebExchange exchange) {
22+
return exchange.getRequest().getCookies().getFirst(getCookieName());
23+
}
24+
25+
@Override
26+
public void setCookie(ServerWebExchange exchange, String value) {
27+
Assert.notNull(value, "'value' is required");
28+
exchange.getResponse().getCookies()
29+
.set(getCookieName(), initCookie(exchange, value).build());
30+
}
31+
32+
@Override
33+
public void expireCookie(ServerWebExchange exchange) {
34+
ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build();
35+
exchange.getResponse().getCookies().set(this.cookieName, cookie);
36+
}
37+
38+
private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange,
39+
String value) {
40+
return ResponseCookie.from(this.cookieName, value)
41+
.path(exchange.getRequest().getPath().contextPath().value() + "/")
42+
.maxAge(getCookieMaxAge())
43+
.httpOnly(true)
44+
.secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme()))
45+
.sameSite("Lax");
46+
}
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package run.halo.comment.widget.captcha;
2+
3+
import javax.imageio.ImageIO;
4+
import java.awt.*;
5+
import java.awt.image.BufferedImage;
6+
import java.io.ByteArrayOutputStream;
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.util.Base64;
10+
import java.util.Random;
11+
import lombok.experimental.UtilityClass;
12+
import org.springframework.util.Assert;
13+
14+
@UtilityClass
15+
public class CaptchaGenerator {
16+
private static final String CHAR_STRING = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
17+
private static final int WIDTH = 160;
18+
private static final int HEIGHT = 40;
19+
private static final int CHAR_LENGTH = 6;
20+
21+
private static final Font customFont;
22+
23+
static {
24+
customFont = loadArialFont();
25+
}
26+
27+
public static BufferedImage generateCaptchaImage(String captchaText) {
28+
Assert.hasText(captchaText, "Captcha text must not be blank");
29+
BufferedImage bufferedImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
30+
Graphics2D g2d = bufferedImage.createGraphics();
31+
32+
// paint white background
33+
g2d.setColor(Color.WHITE);
34+
g2d.fillRect(0, 0, WIDTH, HEIGHT);
35+
36+
g2d.setFont(customFont);
37+
38+
// draw captcha text
39+
Random random = new Random();
40+
for (int i = 0; i < captchaText.length(); i++) {
41+
g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
42+
g2d.drawString(String.valueOf(captchaText.charAt(i)), 20 + i * 24, 30);
43+
}
44+
45+
// add some noise
46+
for (int i = 0; i < 10; i++) {
47+
g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
48+
int x1 = random.nextInt(WIDTH);
49+
int y1 = random.nextInt(HEIGHT);
50+
int x2 = random.nextInt(WIDTH);
51+
int y2 = random.nextInt(HEIGHT);
52+
g2d.drawLine(x1, y1, x2, y2);
53+
}
54+
55+
g2d.dispose();
56+
return bufferedImage;
57+
}
58+
59+
private static Font loadArialFont() {
60+
var fontPath = "/fonts/Arial_Bold.ttf";
61+
try (InputStream is = CaptchaGenerator.class.getResourceAsStream(fontPath)) {
62+
if (is == null) {
63+
throw new RuntimeException("Cannot load font file for " + fontPath + ", please check if it exists.");
64+
}
65+
return Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(Font.BOLD, 24);
66+
} catch (FontFormatException | IOException e) {
67+
return new Font("Serif", Font.BOLD, 24);
68+
}
69+
}
70+
71+
public static String generateRandomText() {
72+
StringBuilder sb = new StringBuilder(CHAR_LENGTH);
73+
Random random = new Random();
74+
for (int i = 0; i < CHAR_LENGTH; i++) {
75+
sb.append(CHAR_STRING.charAt(random.nextInt(CHAR_STRING.length())));
76+
}
77+
return sb.toString();
78+
}
79+
80+
public static String encodeToBase64(BufferedImage image) {
81+
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
82+
ImageIO.write(image, "png", outputStream);
83+
byte[] imageBytes = outputStream.toByteArray();
84+
return Base64.getEncoder().encodeToString(imageBytes);
85+
} catch (IOException e) {
86+
throw new RuntimeException(e);
87+
}
88+
}
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package run.halo.comment.widget.captcha;
2+
3+
import reactor.core.publisher.Mono;
4+
5+
public interface CaptchaManager {
6+
Mono<Boolean> verify(String id, String captchaCode);
7+
8+
Mono<Void> invalidate(String id);
9+
10+
Mono<Captcha> generate();
11+
12+
record Captcha(String id, String code, String imageBase64) {
13+
}
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package run.halo.comment.widget.captcha;
2+
3+
import java.util.UUID;
4+
import java.util.concurrent.TimeUnit;
5+
import com.google.common.cache.Cache;
6+
import com.google.common.cache.CacheBuilder;
7+
import org.springframework.stereotype.Component;
8+
import reactor.core.publisher.Mono;
9+
import reactor.core.scheduler.Schedulers;
10+
11+
@Component
12+
public class CaptchaManagerImpl implements CaptchaManager {
13+
public static final long CODE_EXPIRATION_MINUTES = 5;
14+
15+
private final Cache<String, Captcha> captchaCache =
16+
CacheBuilder.newBuilder()
17+
.expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES)
18+
.maximumSize(100)
19+
.build();
20+
21+
@Override
22+
public Mono<Boolean> verify(String key, String captchaCode) {
23+
return Mono.justOrEmpty(captchaCache.getIfPresent(key))
24+
.filter(captcha -> captcha.code().equals(captchaCode))
25+
.hasElement();
26+
}
27+
28+
@Override
29+
public Mono<Void> invalidate(String id) {
30+
captchaCache.invalidate(id);
31+
return Mono.empty();
32+
}
33+
34+
@Override
35+
public Mono<Captcha> generate() {
36+
var captchaCode = CaptchaGenerator.generateRandomText();
37+
return Mono.fromSupplier(() -> {
38+
var image = CaptchaGenerator.generateCaptchaImage(captchaCode);
39+
var imageBase64 = CaptchaGenerator.encodeToBase64(image);
40+
var id = UUID.randomUUID().toString();
41+
return new Captcha(id, captchaCode, imageBase64);
42+
})
43+
.subscribeOn(Schedulers.boundedElastic())
44+
.doOnNext(captcha -> captchaCache.put(captcha.id(), captcha));
45+
}
46+
}

0 commit comments

Comments
 (0)