Skip to content

Commit be2dea9

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

14 files changed

+546
-1
lines changed

build.gradle

+2-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'
@@ -42,4 +42,5 @@ build {
4242

4343
halo {
4444
version = "2.15.0-rc.1"
45+
debug = true
4546
}

packages/comment-widget/src/base-form.ts

+22
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,21 @@ export class BaseForm extends LitElement {
4141
@state()
4242
name = '';
4343

44+
@property({ type: Boolean })
45+
captchaRequired = false;
46+
47+
@property({ type: String })
48+
captchaImage = '';
49+
4450
@property({ type: Boolean })
4551
submitting = false;
4652

53+
@property({ type: String })
54+
captchaCode = '';
55+
56+
@property({ type: String })
57+
captchaCodeMsg = '';
58+
4759
textareaRef: Ref<HTMLTextAreaElement> = createRef<HTMLTextAreaElement>();
4860

4961
get customAccount() {
@@ -167,6 +179,16 @@ export class BaseForm extends LitElement {
167179
placeholder="网站"
168180
/>
169181
<a href=${this.loginUrl} rel="nofollow"> (已有该站点的账号) </a>
182+
<div ?hidden=${!this.captchaRequired}>
183+
<input
184+
name="captchaCode"
185+
value=${this.captchaCode}
186+
type="text"
187+
placeholder="验证码"
188+
/>
189+
<span>${this.captchaCodeMsg}</span>
190+
<img src="${this.captchaImage}" alt="captcha" width="100%" />
191+
</div>
170192
</div>`
171193
: ''}
172194

packages/comment-widget/src/comment-form.ts

+25
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from './context';
1414
import { Comment, CommentRequest, User } from '@halo-dev/api-client';
1515
import { createRef, Ref, ref } from 'lit/directives/ref.js';
16+
import { isRequireCaptcha, getCaptchaCodeHeader } from './utils/captcha';
1617
import { BaseForm } from './base-form';
1718
import './base-form';
1819
import { ToastManager } from './lit-toast';
@@ -53,11 +54,23 @@ export class CommentForm extends LitElement {
5354
@state()
5455
submitting = false;
5556

57+
@state()
58+
captchaRequired = false;
59+
60+
@state()
61+
captchaImageBase64 = '';
62+
63+
@state()
64+
captchaCodeMsg = '';
65+
5666
baseFormRef: Ref<BaseForm> = createRef<BaseForm>();
5767

5868
override render() {
5969
return html` <base-form
6070
.submitting=${this.submitting}
71+
.captchaRequired=${this.captchaRequired}
72+
.captchaImage=${this.captchaImageBase64}
73+
.captchaCodeMsg=${this.captchaCodeMsg}
6174
${ref(this.baseFormRef)}
6275
@submit="${this.onSubmit}"
6376
></base-form>`;
@@ -110,10 +123,22 @@ export class CommentForm extends LitElement {
110123
method: 'POST',
111124
headers: {
112125
'Content-Type': 'application/json',
126+
...getCaptchaCodeHeader(data.captchaCode),
113127
},
114128
body: JSON.stringify(commentRequest),
115129
});
116130

131+
console.log(response);
132+
if (isRequireCaptcha(response)) {
133+
this.captchaRequired = true;
134+
const { captcha, detail } = await response.json();
135+
this.captchaImageBase64 = captcha;
136+
this.captchaCodeMsg = detail;
137+
return;
138+
}
139+
this.captchaCodeMsg = ''
140+
this.captchaRequired = false;
141+
117142
if (!response.ok) {
118143
throw new Error('评论失败,请稍后重试');
119144
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export const getCaptchaCodeHeader = (code: string): Record<string, string> => {
2+
console.log('code input:', code)
3+
if (!code || code.trim().length === 0) {
4+
return {};
5+
}
6+
return {
7+
'X-Captcha-Code': code,
8+
};
9+
};
10+
11+
export const isRequireCaptcha = (response: Response) => {
12+
return response.status === 403 && response.headers.get('X-Require-Captcha');
13+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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(CaptchaConfig captcha) {
10+
public static SecurityConfig empty() {
11+
return new SecurityConfig(new CaptchaConfig(false));
12+
}
13+
}
14+
15+
record CaptchaConfig(boolean anonymousCommentCaptcha) {
16+
}
17+
}
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,49 @@
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.stereotype.Component;
9+
import org.springframework.util.Assert;
10+
import org.springframework.web.server.ServerWebExchange;
11+
12+
@Getter
13+
@Component
14+
public class CaptchaCookieResolverImpl implements CaptchaCookieResolver {
15+
public static final String CAPTCHA_COOKIE_KEY = "comment-widget-captcha";
16+
17+
private final String cookieName = CAPTCHA_COOKIE_KEY;
18+
19+
private final Duration cookieMaxAge = Duration.ofHours(1);
20+
21+
@Override
22+
@Nullable
23+
public HttpCookie resolveCookie(ServerWebExchange exchange) {
24+
return exchange.getRequest().getCookies().getFirst(getCookieName());
25+
}
26+
27+
@Override
28+
public void setCookie(ServerWebExchange exchange, String value) {
29+
Assert.notNull(value, "'value' is required");
30+
exchange.getResponse().getCookies()
31+
.set(getCookieName(), initCookie(exchange, value).build());
32+
}
33+
34+
@Override
35+
public void expireCookie(ServerWebExchange exchange) {
36+
ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build();
37+
exchange.getResponse().getCookies().set(this.cookieName, cookie);
38+
}
39+
40+
private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange,
41+
String value) {
42+
return ResponseCookie.from(this.cookieName, value)
43+
.path(exchange.getRequest().getPath().contextPath().value() + "/")
44+
.maxAge(getCookieMaxAge())
45+
.httpOnly(true)
46+
.secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme()))
47+
.sameSite("Lax");
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 lombok.extern.slf4j.Slf4j;
13+
import org.springframework.util.Assert;
14+
15+
@Slf4j
16+
@UtilityClass
17+
public class CaptchaGenerator {
18+
private static final String CHAR_STRING = "ABCDEFGHJKMNPRSTUVWXYZabcdefghjkmnpqrstuvwxyz0123456789";
19+
private static final int WIDTH = 160;
20+
private static final int HEIGHT = 40;
21+
private static final int CHAR_LENGTH = 6;
22+
23+
private static final Font customFont;
24+
25+
static {
26+
customFont = loadArialFont();
27+
}
28+
29+
public static BufferedImage generateCaptchaImage(String captchaText) {
30+
Assert.hasText(captchaText, "Captcha text must not be blank");
31+
BufferedImage bufferedImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
32+
Graphics2D g2d = bufferedImage.createGraphics();
33+
34+
// paint white background
35+
g2d.setColor(Color.WHITE);
36+
g2d.fillRect(0, 0, WIDTH, HEIGHT);
37+
38+
g2d.setFont(customFont);
39+
40+
// draw captcha text
41+
Random random = new Random();
42+
for (int i = 0; i < captchaText.length(); i++) {
43+
g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
44+
g2d.drawString(String.valueOf(captchaText.charAt(i)), 20 + i * 24, 30);
45+
}
46+
47+
// add some noise
48+
for (int i = 0; i < 10; i++) {
49+
g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
50+
int x1 = random.nextInt(WIDTH);
51+
int y1 = random.nextInt(HEIGHT);
52+
int x2 = random.nextInt(WIDTH);
53+
int y2 = random.nextInt(HEIGHT);
54+
g2d.drawLine(x1, y1, x2, y2);
55+
}
56+
57+
g2d.dispose();
58+
return bufferedImage;
59+
}
60+
61+
private static Font loadArialFont() {
62+
var fontPath = "/fonts/Arial_Bold.ttf";
63+
try (InputStream is = CaptchaGenerator.class.getResourceAsStream(fontPath)) {
64+
if (is == null) {
65+
throw new RuntimeException("Cannot load font file for " + fontPath + ", please check if it exists.");
66+
}
67+
return Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(Font.BOLD, 24);
68+
} catch (FontFormatException | IOException e) {
69+
log.warn("Failed to load font file for {}, fallback to default font.", fontPath);
70+
return new Font("Serif", Font.BOLD, 24);
71+
}
72+
}
73+
74+
public static String generateRandomText() {
75+
StringBuilder sb = new StringBuilder(CHAR_LENGTH);
76+
Random random = new Random();
77+
for (int i = 0; i < CHAR_LENGTH; i++) {
78+
sb.append(CHAR_STRING.charAt(random.nextInt(CHAR_STRING.length())));
79+
}
80+
return sb.toString();
81+
}
82+
83+
public static String encodeToBase64(BufferedImage image) {
84+
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
85+
ImageIO.write(image, "png", outputStream);
86+
byte[] imageBytes = outputStream.toByteArray();
87+
return Base64.getEncoder().encodeToString(imageBytes);
88+
} catch (IOException e) {
89+
throw new RuntimeException(e);
90+
}
91+
}
92+
}
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+
}

0 commit comments

Comments
 (0)