From ef476402fc8ad95081adf109cc221dfa7d61d6d8 Mon Sep 17 00:00:00 2001
From: CyberRex <26585194+CyberRex0@users.noreply.github.com>
Date: Mon, 25 May 2026 11:46:42 +0900
Subject: [PATCH] =?UTF-8?q?CAPTCHA=E5=AF=BE=E5=BF=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 4 +
README.md | 6 +
development_status.md | 5 +
docker-compose.dev.yml | 13 ++
docker-compose.yml | 5 +-
src/client/routes/AuthPanel.jsx | 124 +++++++++++++-
src/client/styles/app.css | 7 +
src/server/config/env.js | 15 ++
src/server/modules/auth/captcha.js | 45 ++++++
src/server/modules/auth/routes.js | 10 ++
tests/captcha.test.js | 252 +++++++++++++++++++++++++++++
11 files changed, 482 insertions(+), 4 deletions(-)
create mode 100644 src/server/modules/auth/captcha.js
create mode 100644 tests/captcha.test.js
diff --git a/.env.example b/.env.example
index b1286f3..02d051f 100644
--- a/.env.example
+++ b/.env.example
@@ -7,3 +7,7 @@ VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@example.com
OPENSSL_PATH=openssl
+CAPTCHA_PROVIDER=off
+CAPTCHA_SITE_KEY=
+CAPTCHA_SECRET_KEY=
+CAPTCHA_VERIFY_TIMEOUT_MS=3000
diff --git a/README.md b/README.md
index 9c21cd3..d7a59f1 100644
--- a/README.md
+++ b/README.md
@@ -99,9 +99,15 @@ Copy `.env.example` to `.env` for local development.
| `VAPID_PRIVATE_KEY` | For Push | empty | Browser Push private key. Push delivery fails gracefully if missing. |
| `VAPID_SUBJECT` | For Push | `mailto:admin@example.com` | VAPID contact subject. |
| `OPENSSL_PATH` | No | `openssl` | OpenSSL executable path. On Windows, the app can also detect Git's bundled `openssl.exe`. |
+| `CAPTCHA_PROVIDER` | No | `off` | Auth CAPTCHA provider. Use `turnstile`, `hcaptcha`, or `off`. |
+| `CAPTCHA_SITE_KEY` | When CAPTCHA enabled | empty | Public site key used by the login/register widget. |
+| `CAPTCHA_SECRET_KEY` | When CAPTCHA enabled | empty | Server-side secret key used to verify CAPTCHA tokens. |
+| `CAPTCHA_VERIFY_TIMEOUT_MS` | No | `3000` | Timeout for provider verification requests. |
For local host execution, `DATABASE_URL` normally points to `localhost:5432`. For Docker Compose services, it points to the internal service name: `postgres://certremind:certremind@postgres:5432/certremind`.
+CAPTCHA is disabled by default. To enable it, set `CAPTCHA_PROVIDER` to `turnstile` or `hcaptcha` and provide both keys. Login and registration then require a provider token, and TOTP logins require a fresh token for each POST.
+
## Operational Notes
- Run `pnpm monitor:worker` as a long-lived Node process for hourly certificate checks.
diff --git a/development_status.md b/development_status.md
index 720da2f..54f4156 100644
--- a/development_status.md
+++ b/development_status.md
@@ -20,6 +20,8 @@
- 認証必須ミドルウェア
- 登録 API
- ログイン API
+- 認証画面向け CAPTCHA 公開設定 API
+- 登録 / ログイン API の CAPTCHA 検証
- ログアウト API
- 現在ユーザー取得 API
- サイト一覧 API
@@ -156,6 +158,7 @@
```text
GET /api/health
+GET /api/auth/captcha
GET /api/auth/csrf
POST /api/auth/register
POST /api/auth/login
@@ -290,6 +293,8 @@ pnpm monitor:worker
- アカウント削除には現在のパスワードを要求。
- アカウント削除は `users` の削除を起点に関連データを CASCADE で削除。
- ログイン時、TOTP が有効なユーザーは OTP 検証を必須化。
+- Cloudflare Turnstile / hCaptcha による登録・ログイン時の CAPTCHA 検証を環境変数で有効化可能。
+- CAPTCHA 有効時、TOTP ログインの各 POST で新しい CAPTCHA トークンを必須化。
- 通知タイミングは 1 時間以上、17520 時間以内に制限。
- 同一サイト内の重複した通知タイミングを拒否。
- SQL はプレースホルダを使用。
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 01b32d8..8f6c561 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -7,6 +7,15 @@ services:
VITE_HOST: 0.0.0.0
PORT: 3000
DATABASE_URL: postgres://certremind:certremind@postgres:5432/certremind
+ COOKIE_SECRET: ${COOKIE_SECRET:-replace-with-a-long-random-string}
+ VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
+ VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
+ VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
+ OPENSSL_PATH: ${OPENSSL_PATH:-openssl}
+ CAPTCHA_PROVIDER: ${CAPTCHA_PROVIDER:-off}
+ CAPTCHA_SITE_KEY: ${CAPTCHA_SITE_KEY:-}
+ CAPTCHA_SECRET_KEY: ${CAPTCHA_SECRET_KEY:-}
+ CAPTCHA_VERIFY_TIMEOUT_MS: ${CAPTCHA_VERIFY_TIMEOUT_MS:-3000}
ports:
- '127.0.0.1:5173:5173'
volumes:
@@ -23,6 +32,10 @@ services:
environment:
NODE_ENV: development
DATABASE_URL: postgres://certremind:certremind@postgres:5432/certremind
+ VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
+ VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
+ VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
+ OPENSSL_PATH: ${OPENSSL_PATH:-openssl}
volumes:
- .:/app
- app-node-modules:/app/node_modules
diff --git a/docker-compose.yml b/docker-compose.yml
index c272e61..c54ad1c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -29,6 +29,10 @@ services:
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
OPENSSL_PATH: ${OPENSSL_PATH:-openssl}
+ CAPTCHA_PROVIDER: ${CAPTCHA_PROVIDER:-off}
+ CAPTCHA_SITE_KEY: ${CAPTCHA_SITE_KEY:-}
+ CAPTCHA_SECRET_KEY: ${CAPTCHA_SECRET_KEY:-}
+ CAPTCHA_VERIFY_TIMEOUT_MS: ${CAPTCHA_VERIFY_TIMEOUT_MS:-3000}
ports:
- '127.0.0.1:3000:3000'
depends_on:
@@ -42,7 +46,6 @@ services:
environment:
NODE_ENV: production
DATABASE_URL: postgres://certremind:certremind@postgres:5432/certremind
- COOKIE_SECRET: ${COOKIE_SECRET:-replace-with-a-long-random-string}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
diff --git a/src/client/routes/AuthPanel.jsx b/src/client/routes/AuthPanel.jsx
index c239b6f..6e13707 100644
--- a/src/client/routes/AuthPanel.jsx
+++ b/src/client/routes/AuthPanel.jsx
@@ -1,8 +1,47 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { ShieldCheck } from 'lucide-react';
import { request } from '../api/client.js';
import { Field } from '../components/Field.jsx';
+const CAPTCHA_SCRIPTS = {
+ turnstile: 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit',
+ hcaptcha: 'https://js.hcaptcha.com/1/api.js?render=explicit',
+};
+
+const captchaScriptPromises = new Map();
+
+function loadCaptchaScript(provider) {
+ if (!provider) return Promise.resolve();
+ if (captchaScriptPromises.has(provider)) return captchaScriptPromises.get(provider);
+
+ const existing = document.querySelector(`script[data-captcha-provider="${provider}"]`);
+ if (existing) {
+ const promise = new Promise((resolve, reject) => {
+ existing.addEventListener('load', resolve, { once: true });
+ existing.addEventListener('error', reject, { once: true });
+ });
+ captchaScriptPromises.set(provider, promise);
+ return promise;
+ }
+
+ const promise = new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = CAPTCHA_SCRIPTS[provider];
+ script.async = true;
+ script.defer = true;
+ script.dataset.captchaProvider = provider;
+ script.addEventListener('load', resolve, { once: true });
+ script.addEventListener('error', reject, { once: true });
+ document.head.append(script);
+ });
+ captchaScriptPromises.set(provider, promise);
+ return promise;
+}
+
+function captchaApi(provider) {
+ return provider === 'turnstile' ? window.turnstile : window.hcaptcha;
+}
+
function validateAuthForm(mode, form) {
if (mode === 'register' && !form.displayName.trim()) {
throw new Error('表示名を入力してください');
@@ -29,11 +68,77 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
const [totpRequired, setTotpRequired] = useState(false);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
+ const [captchaConfig, setCaptchaConfig] = useState({ enabled: false, provider: null, siteKey: null });
+ const [captchaToken, setCaptchaToken] = useState('');
+ const [captchaReady, setCaptchaReady] = useState(true);
+ const captchaContainerRef = useRef(null);
+ const captchaWidgetRef = useRef(null);
+
+ const resetCaptcha = useCallback(() => {
+ setCaptchaToken('');
+ const api = captchaApi(captchaConfig.provider);
+ if (captchaWidgetRef.current !== null && api?.reset) {
+ api.reset(captchaWidgetRef.current);
+ }
+ }, [captchaConfig.provider]);
useEffect(() => {
setError('');
setTotpRequired(false);
- }, [mode]);
+ resetCaptcha();
+ }, [mode, resetCaptcha]);
+
+ useEffect(() => {
+ let active = true;
+ request('/api/auth/captcha')
+ .then((config) => {
+ if (active) setCaptchaConfig(config);
+ })
+ .catch(() => {
+ if (active) setCaptchaConfig({ enabled: false, provider: null, siteKey: null });
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!captchaConfig.enabled) {
+ setCaptchaReady(true);
+ return undefined;
+ }
+
+ let active = true;
+ setCaptchaReady(false);
+ loadCaptchaScript(captchaConfig.provider)
+ .then(() => {
+ if (!active || !captchaContainerRef.current) return;
+ const api = captchaApi(captchaConfig.provider);
+ if (!api?.render) {
+ throw new Error('CAPTCHA widget API is not available');
+ }
+ captchaContainerRef.current.innerHTML = '';
+ captchaWidgetRef.current = api.render(captchaContainerRef.current, {
+ sitekey: captchaConfig.siteKey,
+ callback: (token) => setCaptchaToken(token),
+ 'expired-callback': () => setCaptchaToken(''),
+ 'error-callback': () => setCaptchaToken(''),
+ });
+ setCaptchaReady(true);
+ })
+ .catch(() => {
+ if (active) setError('CAPTCHAを読み込めませんでした');
+ });
+
+ return () => {
+ active = false;
+ const api = captchaApi(captchaConfig.provider);
+ if (captchaWidgetRef.current !== null && api?.remove) {
+ api.remove(captchaWidgetRef.current);
+ }
+ captchaWidgetRef.current = null;
+ };
+ }, [captchaConfig]);
async function submit(event) {
event.preventDefault();
@@ -41,6 +146,9 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
setError('');
try {
validateAuthForm(mode, form);
+ if (captchaConfig.enabled && !captchaToken) {
+ throw new Error('CAPTCHA認証を完了してください');
+ }
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
const payload =
mode === 'login'
@@ -48,11 +156,13 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
username: form.username.trim(),
password: form.password,
otp: form.otp.trim() || undefined,
+ captchaToken: captchaToken || undefined,
}
: {
displayName: form.displayName.trim(),
username: form.username.trim(),
password: form.password,
+ captchaToken: captchaToken || undefined,
};
const data = await request(endpoint, {
method: 'POST',
@@ -63,9 +173,11 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
if (err.totpRequired) {
setTotpRequired(true);
setError('2段階認証コードを入力してください');
+ resetCaptcha();
return;
}
setError(err.message);
+ resetCaptcha();
} finally {
setBusy(false);
}
@@ -150,8 +262,14 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
) : null}
+ {captchaConfig.enabled ? (
+
+ ) : null}
+
{error ? {error}
: null}
-