CAPTCHA対応

This commit is contained in:
CyberRex
2026-05-25 11:46:42 +09:00
parent d4918762d2
commit ef476402fc
11 changed files with 482 additions and 4 deletions
+121 -3
View File
@@ -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 }) {
</Field>
) : null}
{captchaConfig.enabled ? (
<div className="captcha-box" aria-busy={!captchaReady}>
<div ref={captchaContainerRef} />
</div>
) : null}
{error ? <p className="error">{error}</p> : null}
<button className="primary" disabled={busy}>
<button className="primary" disabled={busy || !captchaReady}>
{busy ? '処理中...' : mode === 'login' ? 'ログイン' : '登録'}
</button>
</form>
+7
View File
@@ -200,6 +200,13 @@ select:focus {
font-weight: 700;
}
.captcha-box {
min-height: 65px;
display: grid;
align-items: center;
overflow-x: auto;
}
.toast-viewport {
position: fixed;
z-index: 60;
+15
View File
@@ -1,3 +1,14 @@
const captchaProvider = process.env.CAPTCHA_PROVIDER ?? 'off';
if (!['off', 'turnstile', 'hcaptcha'].includes(captchaProvider)) {
throw new Error('CAPTCHA_PROVIDER must be one of: off, turnstile, hcaptcha');
}
const captchaSiteKey = process.env.CAPTCHA_SITE_KEY ?? '';
const captchaSecretKey = process.env.CAPTCHA_SECRET_KEY ?? '';
if (captchaProvider !== 'off' && (!captchaSiteKey || !captchaSecretKey)) {
throw new Error('CAPTCHA_SITE_KEY and CAPTCHA_SECRET_KEY are required when CAPTCHA is enabled');
}
export const env = {
nodeEnv: process.env.NODE_ENV ?? 'development',
host: process.env.HOST ?? '127.0.0.1',
@@ -9,4 +20,8 @@ export const env = {
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '',
vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:[email protected]',
opensslPath: process.env.OPENSSL_PATH ?? 'openssl',
captchaProvider,
captchaSiteKey,
captchaSecretKey,
captchaVerifyTimeoutMs: Number.parseInt(process.env.CAPTCHA_VERIFY_TIMEOUT_MS ?? '3000', 10),
};
+45
View File
@@ -0,0 +1,45 @@
import { env } from '../../config/env.js';
const VERIFY_ENDPOINTS = {
turnstile: 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
hcaptcha: 'https://api.hcaptcha.com/siteverify',
};
export function getCaptchaPublicConfig() {
if (env.captchaProvider === 'off') {
return { enabled: false, provider: null, siteKey: null };
}
return {
enabled: true,
provider: env.captchaProvider,
siteKey: env.captchaSiteKey,
};
}
export async function verifyCaptchaToken(token) {
if (env.captchaProvider === 'off') return true;
if (!token) return false;
const body = new URLSearchParams({
secret: env.captchaSecretKey,
response: token,
});
try {
const response = await fetch(VERIFY_ENDPOINTS[env.captchaProvider], {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
signal: AbortSignal.timeout(env.captchaVerifyTimeoutMs),
});
if (!response.ok) return false;
const data = await response.json().catch(() => null);
return data?.success === true;
} catch {
return false;
}
}
+10
View File
@@ -6,6 +6,7 @@ import { query } from '../../db/pool.js';
import { createSession, destroySession, requireAuth } from '../../middleware/auth.js';
import { issueCsrf } from '../../middleware/csrf.js';
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
import { getCaptchaPublicConfig, verifyCaptchaToken } from './captcha.js';
const router = new Hono();
@@ -18,6 +19,7 @@ const registerSchema = z.object({
.max(40)
.regex(/^[a-zA-Z0-9_.-]+$/),
password: z.string().min(12).max(200),
captchaToken: z.string().trim().min(1).max(4096).optional(),
});
const loginSchema = z.object({
@@ -28,6 +30,7 @@ const loginSchema = z.object({
.trim()
.regex(/^\d{6}$/)
.optional(),
captchaToken: z.string().trim().min(1).max(4096).optional(),
});
function publicUser(row) {
@@ -39,12 +42,16 @@ function publicUser(row) {
}
router.get('/csrf', (c) => c.json({ csrfToken: issueCsrf(c) }));
router.get('/captcha', (c) => c.json(getCaptchaPublicConfig()));
router.post('/register', async (c) => {
const body = registerSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
if (!(await verifyCaptchaToken(body.data.captchaToken))) {
throw badRequest('CAPTCHA認証に失敗しました');
}
const passwordHash = await hash(body.data.password, {
algorithm: 2,
@@ -75,6 +82,9 @@ router.post('/login', async (c) => {
if (!body.success) {
throw unauthorized('ユーザー名またはパスワードが違います');
}
if (!(await verifyCaptchaToken(body.data.captchaToken))) {
throw badRequest('CAPTCHA認証に失敗しました');
}
const result = await query(
`SELECT u.user_id,