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

View File

@@ -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

View File

@@ -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.

View File

@@ -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 はプレースホルダを使用。

View File

@@ -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

View File

@@ -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}

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>

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;

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:admin@example.com',
opensslPath: process.env.OPENSSL_PATH ?? 'openssl',
captchaProvider,
captchaSiteKey,
captchaSecretKey,
captchaVerifyTimeoutMs: Number.parseInt(process.env.CAPTCHA_VERIFY_TIMEOUT_MS ?? '3000', 10),
};

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

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,

252
tests/captcha.test.js Normal file
View File

@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../src/server/app.js';
import { env } from '../src/server/config/env.js';
import { query } from '../src/server/db/pool.js';
import { hash, verify } from '@node-rs/argon2';
import * as otplib from 'otplib';
vi.mock('../src/server/db/pool.js', () => ({
pool: {
connect: vi.fn(),
},
query: vi.fn(),
}));
vi.mock('@node-rs/argon2', () => ({
hash: vi.fn(async () => 'hashed-password'),
verify: vi.fn(async () => true),
}));
vi.mock('otplib', () => ({
verify: vi.fn(async () => ({ valid: true })),
}));
const USER_ID = '11111111-1111-4111-8111-111111111111';
function csrfHeaders() {
return {
'Content-Type': 'application/json',
Cookie: 'certremind_csrf=csrf-token',
'x-csrf-token': 'csrf-token',
};
}
function enableCaptcha(provider = 'turnstile') {
env.captchaProvider = provider;
env.captchaSiteKey = 'site-key';
env.captchaSecretKey = 'secret-key';
env.captchaVerifyTimeoutMs = 3000;
}
function mockCaptchaResponse(success) {
globalThis.fetch = vi.fn(async () => ({
ok: true,
json: async () => ({ success }),
}));
}
function mockUserRow({ otpSecret = null } = {}) {
query.mockResolvedValueOnce({
rows: [
{
user_id: USER_ID,
username: 'alice',
display_name: 'Alice',
password_hash: 'hashed-password',
otp_secret: otpSecret,
},
],
});
}
describe('auth CAPTCHA', () => {
beforeEach(() => {
env.captchaProvider = 'off';
env.captchaSiteKey = '';
env.captchaSecretKey = '';
env.captchaVerifyTimeoutMs = 3000;
query.mockReset();
hash.mockClear();
verify.mockClear();
otplib.verify.mockClear();
vi.unstubAllGlobals();
});
it('returns disabled CAPTCHA public config by default', async () => {
const app = createApp();
const response = await app.request('/api/auth/captcha');
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
enabled: false,
provider: null,
siteKey: null,
});
});
it('keeps registration compatible when CAPTCHA is disabled', async () => {
query
.mockResolvedValueOnce({
rows: [{ user_id: USER_ID, username: 'alice', display_name: 'Alice' }],
})
.mockResolvedValueOnce({ rows: [{ session_id: 'session-1' }] });
const app = createApp();
const response = await app.request('/api/auth/register', {
method: 'POST',
headers: csrfHeaders(),
body: JSON.stringify({
displayName: 'Alice',
username: 'alice',
password: 'very-secure-password',
}),
});
expect(response.status).toBe(201);
expect(hash).toHaveBeenCalledWith('very-secure-password', expect.any(Object));
expect(query).toHaveBeenCalledTimes(2);
});
it('rejects registration with missing CAPTCHA before hashing or inserting', async () => {
enableCaptcha();
const app = createApp();
const response = await app.request('/api/auth/register', {
method: 'POST',
headers: csrfHeaders(),
body: JSON.stringify({
displayName: 'Alice',
username: 'alice',
password: 'very-secure-password',
}),
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'CAPTCHA認証に失敗しました',
});
expect(hash).not.toHaveBeenCalled();
expect(query).not.toHaveBeenCalled();
});
it('continues registration after successful provider verification', async () => {
enableCaptcha('hcaptcha');
mockCaptchaResponse(true);
query
.mockResolvedValueOnce({
rows: [{ user_id: USER_ID, username: 'alice', display_name: 'Alice' }],
})
.mockResolvedValueOnce({ rows: [{ session_id: 'session-1' }] });
const app = createApp();
const response = await app.request('/api/auth/register', {
method: 'POST',
headers: csrfHeaders(),
body: JSON.stringify({
displayName: 'Alice',
username: 'alice',
password: 'very-secure-password',
captchaToken: 'captcha-token',
}),
});
expect(response.status).toBe(201);
expect(fetch).toHaveBeenCalledWith(
'https://api.hcaptcha.com/siteverify',
expect.objectContaining({ method: 'POST' }),
);
expect(hash).toHaveBeenCalled();
expect(query).toHaveBeenCalledTimes(2);
});
it('rejects login when provider verification fails before user lookup', async () => {
enableCaptcha();
mockCaptchaResponse(false);
const app = createApp();
const response = await app.request('/api/auth/login', {
method: 'POST',
headers: csrfHeaders(),
body: JSON.stringify({
username: 'alice',
password: 'very-secure-password',
captchaToken: 'captcha-token',
}),
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'CAPTCHA認証に失敗しました',
});
expect(query).not.toHaveBeenCalled();
expect(verify).not.toHaveBeenCalled();
});
it('rejects login when provider verification cannot be reached', async () => {
enableCaptcha();
globalThis.fetch = vi.fn(async () => {
throw new Error('network unavailable');
});
const app = createApp();
const response = await app.request('/api/auth/login', {
method: 'POST',
headers: csrfHeaders(),
body: JSON.stringify({
username: 'alice',
password: 'very-secure-password',
captchaToken: 'captcha-token',
}),
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: 'CAPTCHA認証に失敗しました',
});
expect(query).not.toHaveBeenCalled();
expect(verify).not.toHaveBeenCalled();
});
it('requires CAPTCHA on both TOTP login posts', async () => {
enableCaptcha();
mockCaptchaResponse(true);
mockUserRow({ otpSecret: 'otp-secret' });
const app = createApp();
const firstResponse = await app.request('/api/auth/login', {
method: 'POST',
headers: csrfHeaders(),
body: JSON.stringify({
username: 'alice',
password: 'very-secure-password',
captchaToken: 'captcha-token-1',
}),
});
expect(firstResponse.status).toBe(401);
await expect(firstResponse.json()).resolves.toEqual({ totpRequired: true });
expect(fetch).toHaveBeenCalledTimes(1);
query.mockClear();
verify.mockClear();
otplib.verify.mockClear();
const secondResponse = await app.request('/api/auth/login', {
method: 'POST',
headers: csrfHeaders(),
body: JSON.stringify({
username: 'alice',
password: 'very-secure-password',
otp: '123456',
}),
});
expect(secondResponse.status).toBe(400);
await expect(secondResponse.json()).resolves.toMatchObject({
error: 'CAPTCHA認証に失敗しました',
});
expect(query).not.toHaveBeenCalled();
expect(verify).not.toHaveBeenCalled();
expect(otplib.verify).not.toHaveBeenCalled();
});
});