diff --git a/build.gradle b/build.gradle index 16f124f..d6c7d09 100644 --- a/build.gradle +++ b/build.gradle @@ -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' @@ -42,4 +42,5 @@ build { halo { version = "2.15.0-rc.1" + debug = true } diff --git a/packages/comment-widget/src/base-form.ts b/packages/comment-widget/src/base-form.ts index 216ff34..62de873 100644 --- a/packages/comment-widget/src/base-form.ts +++ b/packages/comment-widget/src/base-form.ts @@ -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 = createRef(); get customAccount() { @@ -167,6 +179,16 @@ export class BaseForm extends LitElement { placeholder="网站" /> (已有该站点的账号) +
+ + ${this.captchaCodeMsg} + captcha +
` : ''} diff --git a/packages/comment-widget/src/comment-form.ts b/packages/comment-widget/src/comment-form.ts index ebabd7c..f31b8ce 100644 --- a/packages/comment-widget/src/comment-form.ts +++ b/packages/comment-widget/src/comment-form.ts @@ -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'; @@ -53,11 +54,23 @@ export class CommentForm extends LitElement { @state() submitting = false; + @state() + captchaRequired = false; + + @state() + captchaImageBase64 = ''; + + @state() + captchaCodeMsg = ''; + baseFormRef: Ref = createRef(); override render() { return html` `; @@ -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('评论失败,请稍后重试'); } diff --git a/packages/comment-widget/src/utils/captcha.ts b/packages/comment-widget/src/utils/captcha.ts new file mode 100644 index 0000000..e0eca3d --- /dev/null +++ b/packages/comment-widget/src/utils/captcha.ts @@ -0,0 +1,13 @@ +export const getCaptchaCodeHeader = (code: string): Record => { + 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'); +}; diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetter.java b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java new file mode 100644 index 0000000..f181506 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetter.java @@ -0,0 +1,14 @@ +package run.halo.comment.widget; + +import reactor.core.publisher.Mono; + +public interface SettingConfigGetter { + + Mono getSecurityConfig(); + + record SecurityConfig(boolean anonymousCommentCaptcha) { + public static SecurityConfig empty() { + return new SecurityConfig(false); + } + } +} diff --git a/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java b/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java new file mode 100644 index 0000000..07e8113 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java @@ -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 getSecurityConfig() { + return settingConfigCache.get("security", + key -> settingFetcher.fetch("security", SecurityConfig.class) + .defaultIfEmpty(SecurityConfig.empty()) + ); + } + + interface SettingConfigCache { + Mono get(String key, Function> loader); + } + + @Component + @RequiredArgsConstructor + static class SettingConfigCacheImpl implements Reconciler, SettingConfigCache { + private static final String CONFIG_NAME = "plugin-comment-widget-configmap"; + + private final Cache cache = CacheBuilder.newBuilder() + .maximumSize(10) + .build(); + + private final ExtensionClient client; + + @SuppressWarnings("unchecked") + public Mono get(String key, Function> 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(); + } + } +} diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaCookieResolver.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaCookieResolver.java new file mode 100644 index 0000000..87c739f --- /dev/null +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaCookieResolver.java @@ -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(); +} diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaCookieResolverImpl.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaCookieResolverImpl.java new file mode 100644 index 0000000..f62cc35 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaCookieResolverImpl.java @@ -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"); + } +} diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java new file mode 100644 index 0000000..97d6b95 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java @@ -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); + } + } +} diff --git a/src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java b/src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java new file mode 100644 index 0000000..9e92172 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java @@ -0,0 +1,14 @@ +package run.halo.comment.widget.captcha; + +import reactor.core.publisher.Mono; + +public interface CaptchaManager { + Mono verify(String id, String captchaCode); + + Mono invalidate(String id); + + Mono generate(); + + 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 new file mode 100644 index 0000000..7714774 --- /dev/null +++ b/src/main/java/run/halo/comment/widget/captcha/CaptchaManagerImpl.java @@ -0,0 +1,52 @@ +package run.halo.comment.widget.captcha; + +import java.awt.image.BufferedImage; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +@Component +public class CaptchaManagerImpl implements CaptchaManager { + public static final long CODE_EXPIRATION_MINUTES = 1; + + private final Cache captchaCache = + CacheBuilder.newBuilder() + .expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES) + .maximumSize(100) + .build(); + + @Override + public Mono verify(String key, String captchaCode) { + return Mono.justOrEmpty(captchaCache.getIfPresent(key)) + .filter(captcha -> captcha.code().equalsIgnoreCase(captchaCode)) + .hasElement(); + } + + @Override + public Mono invalidate(String id) { + captchaCache.invalidate(id); + return Mono.empty(); + } + + @Override + public Mono generate() { + var captchaCode = CaptchaGenerator.generateRandomText(); + return Mono.fromSupplier(() -> { + var image = CaptchaGenerator.generateCaptchaImage(captchaCode); + var imageBase64 = encodeBufferedImageToDataUri(image); + var id = UUID.randomUUID().toString(); + return new Captcha(id, captchaCode, imageBase64); + }) + .subscribeOn(Schedulers.boundedElastic()) + .doOnNext(captcha -> captchaCache.put(captcha.id(), captcha)); + } + + private static String encodeBufferedImageToDataUri(BufferedImage image) { + var imageBase64 = CaptchaGenerator.encodeToBase64(image); + return "data:image/png;base64," + imageBase64; + } +} diff --git a/src/main/java/run/halo/comment/widget/captcha/CommentCaptchaFilter.java b/src/main/java/run/halo/comment/widget/captcha/CommentCaptchaFilter.java new file mode 100644 index 0000000..54975aa --- /dev/null +++ b/src/main/java/run/halo/comment/widget/captcha/CommentCaptchaFilter.java @@ -0,0 +1,152 @@ +package run.halo.comment.widget.captcha; + +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; + +import java.net.URI; +import java.util.Locale; +import java.util.Optional; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.ProblemDetailJacksonMixin; +import org.springframework.lang.NonNull; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; +import org.springframework.security.web.server.context.ServerSecurityContextRepository; +import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import run.halo.app.infra.AnonymousUserConst; +import run.halo.app.security.AdditionalWebFilter; +import run.halo.comment.widget.SettingConfigGetter; + +@Component +@RequiredArgsConstructor +public class CommentCaptchaFilter implements AdditionalWebFilter { + static final String CAPTCHA_INVALID_TYPE = "https://www.halo.run/probs/captcha-invalid"; + static final String CAPTCHA_REQUIRED_TYPE = "https://www.halo.run/probs/captcha-required"; + private final static String CAPTCHA_CODE_HEADER = "X-Captcha-Code"; + private final static String CAPTCHA_REQUIRED_HEADER = "X-Require-Captcha"; + private static final String CONTENT_TYPE = "application/problem+json"; + + private final ServerWebExchangeMatcher pathMatcher = createPathMatcher(); + private final ObjectMapper objectMapper = createObjectMapper(); + + private final SettingConfigGetter settingConfigGetter; + private final CaptchaManager captchaManager; + private final CaptchaCookieResolverImpl captchaCookieResolver; + private final ServerSecurityContextRepository contextRepository; + + + @Override + @NonNull + public Mono filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { + return pathMatcher.matches(exchange) + .filter(ServerWebExchangeMatcher.MatchResult::isMatch) + .flatMap(result -> settingConfigGetter.getSecurityConfig()) + .filterWhen(securityConfig -> isAnonymousCommenter(exchange)) + .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) + .flatMap(securityConfig -> { + if (!securityConfig.anonymousCommentCaptcha()) { + return chain.filter(exchange); + } + return validateCaptcha(exchange, chain); + }); + } + + private Mono sendCaptchaRequiredResponse(ServerWebExchange exchange, ResponseStatusException e) { + exchange.getResponse().getHeaders().addIfAbsent(CAPTCHA_REQUIRED_HEADER, "true"); + exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); + return captchaManager.generate() + .flatMap(captcha -> { + captchaCookieResolver.setCookie(exchange, captcha.id()); + var problemDetail = toProblemDetail(e); + problemDetail.setProperty("captcha", captcha.imageBase64()); + var responseData = getResponseData(problemDetail); + exchange.getResponse().getHeaders().addIfAbsent("Content-Type", CONTENT_TYPE); + return exchange.getResponse() + .writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(responseData))); + }); + } + + private byte[] getResponseData(ProblemDetail problemDetail) { + try { + return objectMapper.writeValueAsBytes(problemDetail); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private Mono validateCaptcha(ServerWebExchange exchange, WebFilterChain chain) { + var captchaCodeOpt = getCaptchaCode(exchange); + var cookie = captchaCookieResolver.resolveCookie(exchange); + if (captchaCodeOpt.isEmpty() || cookie == null) { + return sendCaptchaRequiredResponse(exchange, 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()); + }); + } + + private static Optional getCaptchaCode(ServerWebExchange exchange) { + var captchaCode = exchange.getRequest().getHeaders().getFirst(CAPTCHA_CODE_HEADER); + return Optional.ofNullable(captchaCode) + .filter(StringUtils::isNotBlank); + } + + private OrServerWebExchangeMatcher createPathMatcher() { + var commentMatcher = pathMatchers(HttpMethod.POST, "/apis/api.halo.run/v1alpha1/comments"); + var replyMatcher = pathMatchers(HttpMethod.POST, "/apis/api.halo.run/v1alpha1/comments/{name}/reply"); + return new OrServerWebExchangeMatcher(commentMatcher, replyMatcher); + } + + static class InvalidCaptchaCodeException extends ResponseStatusException { + public InvalidCaptchaCodeException() { + super(HttpStatus.FORBIDDEN, "验证码错误,请重新输入"); + setType(URI.create(CAPTCHA_INVALID_TYPE)); + } + } + + static class CaptchaCodeMissingException extends ResponseStatusException { + public CaptchaCodeMissingException() { + super(HttpStatus.FORBIDDEN, "请先输入验证码"); + setType(URI.create(CAPTCHA_REQUIRED_TYPE)); + } + } + + ProblemDetail toProblemDetail(ResponseStatusException e) { + var problemDetail = e.updateAndGetBody(null, Locale.getDefault()); + problemDetail.setTitle("Captcha Verification"); + return problemDetail; + } + + static ObjectMapper createObjectMapper() { + return Jackson2ObjectMapperBuilder.json() + .mixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class) + .build(); + } + + Mono isAnonymousCommenter(ServerWebExchange exchange) { + return contextRepository.load(exchange) + .map(context -> AnonymousUserConst.isAnonymousUser(context.getAuthentication().getName())) + .defaultIfEmpty(true); + } + + @Override + public int getOrder() { + return SecurityWebFiltersOrder.AUTHORIZATION.getOrder(); + } +} diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index 3939d92..40fdf02 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -31,6 +31,15 @@ spec: key: withReplySize validation: required value: 5 + - group: security + label: 安全设置 + formSchema: + - $formkit: checkbox + label: 匿名评论需要验证码 + name: anonymousCommentCaptcha + id: anonymousCommentCaptcha + key: anonymousCommentCaptcha + value: false - group: avatar label: 头像设置 formSchema: diff --git a/src/main/resources/fonts/Arial_Bold.ttf b/src/main/resources/fonts/Arial_Bold.ttf new file mode 100644 index 0000000..c2eb3dd Binary files /dev/null and b/src/main/resources/fonts/Arial_Bold.ttf differ