Skip to content

Commit

Permalink
feat: Add CAPTCHA verification for new anonymous comments to enhance …
Browse files Browse the repository at this point in the history
…security
  • Loading branch information
guqing committed Jun 18, 2024
1 parent 40de277 commit 8fa0306
Show file tree
Hide file tree
Showing 14 changed files with 539 additions and 1 deletion.
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ repositories {
}

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

testImplementation 'run.halo.app:api'
Expand All @@ -42,4 +42,5 @@ build {

halo {
version = "2.15.0-rc.1"
debug = true
}
22 changes: 22 additions & 0 deletions packages/comment-widget/src/base-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,21 @@ export class BaseForm extends LitElement {
@state()
name = '';

@property({ type: Boolean })
captchaRequired = false;

@property({ type: String })
captchaImage = '';

@property({ type: Boolean })
submitting = false;

@property({ type: String })
captchaCode = '';

@property({ type: String })
captchaCodeMsg = '';

textareaRef: Ref<HTMLTextAreaElement> = createRef<HTMLTextAreaElement>();

get customAccount() {
Expand Down Expand Up @@ -167,6 +179,16 @@ export class BaseForm extends LitElement {
placeholder="网站"
/>
<a href=${this.loginUrl} rel="nofollow"> (已有该站点的账号) </a>
<div ?hidden=${!this.captchaRequired}>
<input
name="captchaCode"
value=${this.captchaCode}
type="text"
placeholder="验证码"
/>
<span>${this.captchaCodeMsg}</span>
<img src="${this.captchaImage}" alt="captcha" width="100%" />
</div>
</div>`
: ''}
Expand Down
25 changes: 25 additions & 0 deletions packages/comment-widget/src/comment-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from './context';
import { Comment, CommentRequest, User } from '@halo-dev/api-client';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { isRequireCaptcha, getCaptchaCodeHeader } from './utils/captcha';
import { BaseForm } from './base-form';
import './base-form';
import { ToastManager } from './lit-toast';
Expand Down Expand Up @@ -53,11 +54,23 @@ export class CommentForm extends LitElement {
@state()
submitting = false;

@state()
captchaRequired = false;

@state()
captchaImageBase64 = '';

@state()
captchaCodeMsg = '';

baseFormRef: Ref<BaseForm> = createRef<BaseForm>();

override render() {
return html` <base-form
.submitting=${this.submitting}
.captchaRequired=${this.captchaRequired}
.captchaImage=${this.captchaImageBase64}
.captchaCodeMsg=${this.captchaCodeMsg}
${ref(this.baseFormRef)}
@submit="${this.onSubmit}"
></base-form>`;
Expand Down Expand Up @@ -110,10 +123,22 @@ export class CommentForm extends LitElement {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getCaptchaCodeHeader(data.captchaCode),
},
body: JSON.stringify(commentRequest),
});

console.log(response);
if (isRequireCaptcha(response)) {
this.captchaRequired = true;
const { captcha, detail } = await response.json();
this.captchaImageBase64 = captcha;
this.captchaCodeMsg = detail;
return;
}
this.captchaCodeMsg = ''
this.captchaRequired = false;

if (!response.ok) {
throw new Error('评论失败,请稍后重试');
}
Expand Down
13 changes: 13 additions & 0 deletions packages/comment-widget/src/utils/captcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const getCaptchaCodeHeader = (code: string): Record<string, string> => {
console.log('code input:', code)
if (!code || code.trim().length === 0) {
return {};
}
return {
'X-Captcha-Code': code,
};
};

export const isRequireCaptcha = (response: Response) => {
return response.status === 403 && response.headers.get('X-Require-Captcha');
};
14 changes: 14 additions & 0 deletions src/main/java/run/halo/comment/widget/SettingConfigGetter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package run.halo.comment.widget;

import reactor.core.publisher.Mono;

public interface SettingConfigGetter {

Mono<SecurityConfig> getSecurityConfig();

record SecurityConfig(boolean anonymousCommentCaptcha) {
public static SecurityConfig empty() {
return new SecurityConfig(false);
}
}
}
76 changes: 76 additions & 0 deletions src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package run.halo.comment.widget;

import static run.halo.app.extension.index.query.QueryFactory.equal;

import java.util.function.Function;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.DefaultExtensionMatcher;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.plugin.ReactiveSettingFetcher;

@Component
@RequiredArgsConstructor
public class SettingConfigGetterImpl implements SettingConfigGetter {
private final ReactiveSettingFetcher settingFetcher;
private final SettingConfigCache settingConfigCache;

@Override
public Mono<SecurityConfig> getSecurityConfig() {
return settingConfigCache.get("security",
key -> settingFetcher.fetch("security", SecurityConfig.class)
.defaultIfEmpty(SecurityConfig.empty())
);
}

interface SettingConfigCache {
<T> Mono<T> get(String key, Function<String, Mono<T>> loader);
}

@Component
@RequiredArgsConstructor
static class SettingConfigCacheImpl implements Reconciler<Reconciler.Request>, SettingConfigCache {
private static final String CONFIG_NAME = "plugin-comment-widget-configmap";

private final Cache<String, Object> cache = CacheBuilder.newBuilder()
.maximumSize(10)
.build();

private final ExtensionClient client;

@SuppressWarnings("unchecked")
public <T> Mono<T> get(String key, Function<String, Mono<T>> loader) {
return Mono.justOrEmpty(cache.getIfPresent(key))
.switchIfEmpty(loader.apply(key).doOnNext(value -> cache.put(key, value)))
.map(object -> (T) object);
}

@Override
public Result reconcile(Request request) {
cache.invalidateAll();
return Result.doNotRetry();
}

@Override
public Controller setupWith(ControllerBuilder builder) {
var extension = new ConfigMap();
var extensionMatcher = DefaultExtensionMatcher.builder(client, extension.groupVersionKind())
.fieldSelector(FieldSelector.of(equal("metadata.name", CONFIG_NAME)))
.build();
return builder
.extension(extension)
.syncAllOnStart(false)
.onAddMatcher(extensionMatcher)
.onUpdateMatcher(extensionMatcher)
.build();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package run.halo.comment.widget.captcha;

import java.time.Duration;
import org.springframework.http.HttpCookie;
import org.springframework.lang.Nullable;
import org.springframework.web.server.ServerWebExchange;

public interface CaptchaCookieResolver {
@Nullable
HttpCookie resolveCookie(ServerWebExchange exchange);

void setCookie(ServerWebExchange exchange, String value);

void expireCookie(ServerWebExchange exchange);

String getCookieName();

Duration getCookieMaxAge();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package run.halo.comment.widget.captcha;

import java.time.Duration;
import lombok.Getter;
import org.springframework.http.HttpCookie;
import org.springframework.http.ResponseCookie;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.server.ServerWebExchange;

@Getter
@Component
public class CaptchaCookieResolverImpl implements CaptchaCookieResolver {
public static final String CAPTCHA_COOKIE_KEY = "comment-widget-captcha";

private final String cookieName = CAPTCHA_COOKIE_KEY;

private final Duration cookieMaxAge = Duration.ofHours(1);

@Override
@Nullable
public HttpCookie resolveCookie(ServerWebExchange exchange) {
return exchange.getRequest().getCookies().getFirst(getCookieName());
}

@Override
public void setCookie(ServerWebExchange exchange, String value) {
Assert.notNull(value, "'value' is required");
exchange.getResponse().getCookies()
.set(getCookieName(), initCookie(exchange, value).build());
}

@Override
public void expireCookie(ServerWebExchange exchange) {
ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build();
exchange.getResponse().getCookies().set(this.cookieName, cookie);
}

private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange,
String value) {
return ResponseCookie.from(this.cookieName, value)
.path(exchange.getRequest().getPath().contextPath().value() + "/")
.maxAge(getCookieMaxAge())
.httpOnly(true)
.secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme()))
.sameSite("Lax");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package run.halo.comment.widget.captcha;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import java.util.Random;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.Assert;

@Slf4j
@UtilityClass
public class CaptchaGenerator {
private static final String CHAR_STRING = "ABCDEFGHJKMNPRSTUVWXYZabcdefghjkmnpqrstuvwxyz0123456789";
private static final int WIDTH = 160;
private static final int HEIGHT = 40;
private static final int CHAR_LENGTH = 6;

private static final Font customFont;

static {
customFont = loadArialFont();
}

public static BufferedImage generateCaptchaImage(String captchaText) {
Assert.hasText(captchaText, "Captcha text must not be blank");
BufferedImage bufferedImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = bufferedImage.createGraphics();

// paint white background
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, WIDTH, HEIGHT);

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);
}

// add some noise
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);
int y1 = random.nextInt(HEIGHT);
int x2 = random.nextInt(WIDTH);
int y2 = random.nextInt(HEIGHT);
g2d.drawLine(x1, y1, x2, y2);
}

g2d.dispose();
return bufferedImage;
}

private static Font loadArialFont() {
var fontPath = "/fonts/Arial_Bold.ttf";
try (InputStream is = CaptchaGenerator.class.getResourceAsStream(fontPath)) {
if (is == null) {
throw new RuntimeException("Cannot load font file for " + fontPath + ", please check if it exists.");
}
return Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(Font.BOLD, 24);
} catch (FontFormatException | IOException e) {
log.warn("Failed to load font file for {}, fallback to default font.", fontPath);
return new Font("Serif", Font.BOLD, 24);
}
}

public static String generateRandomText() {
StringBuilder sb = new StringBuilder(CHAR_LENGTH);
Random random = new Random();
for (int i = 0; i < CHAR_LENGTH; i++) {
sb.append(CHAR_STRING.charAt(random.nextInt(CHAR_STRING.length())));
}
return sb.toString();
}

public static String encodeToBase64(BufferedImage image) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(image, "png", outputStream);
byte[] imageBytes = outputStream.toByteArray();
return Base64.getEncoder().encodeToString(imageBytes);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
14 changes: 14 additions & 0 deletions src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package run.halo.comment.widget.captcha;

import reactor.core.publisher.Mono;

public interface CaptchaManager {
Mono<Boolean> verify(String id, String captchaCode);

Mono<Void> invalidate(String id);

Mono<Captcha> generate();

record Captcha(String id, String code, String imageBase64) {
}
}
Loading

0 comments on commit 8fa0306

Please sign in to comment.