generated from halo-dev/plugin-starter
-
Notifications
You must be signed in to change notification settings - Fork 26
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add CAPTCHA verification for new anonymous comments to enhance …
…security
- Loading branch information
Showing
14 changed files
with
539 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
14
src/main/java/run/halo/comment/widget/SettingConfigGetter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
76
src/main/java/run/halo/comment/widget/SettingConfigGetterImpl.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
src/main/java/run/halo/comment/widget/captcha/CaptchaCookieResolver.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} |
49 changes: 49 additions & 0 deletions
49
src/main/java/run/halo/comment/widget/captcha/CaptchaCookieResolverImpl.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
} | ||
} |
92 changes: 92 additions & 0 deletions
92
src/main/java/run/halo/comment/widget/captcha/CaptchaGenerator.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
14
src/main/java/run/halo/comment/widget/captcha/CaptchaManager.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { | ||
} | ||
} |
Oops, something went wrong.