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} - diff --git a/src/client/styles/app.css b/src/client/styles/app.css index ca578ae..f308a24 100644 --- a/src/client/styles/app.css +++ b/src/client/styles/app.css @@ -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; diff --git a/src/server/config/env.js b/src/server/config/env.js index 7a2b660..7e2c0ff 100644 --- a/src/server/config/env.js +++ b/src/server/config/env.js @@ -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), }; diff --git a/src/server/modules/auth/captcha.js b/src/server/modules/auth/captcha.js new file mode 100644 index 0000000..4bd6bfc --- /dev/null +++ b/src/server/modules/auth/captcha.js @@ -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; + } +} diff --git a/src/server/modules/auth/routes.js b/src/server/modules/auth/routes.js index 4406f51..4a06e9a 100644 --- a/src/server/modules/auth/routes.js +++ b/src/server/modules/auth/routes.js @@ -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, diff --git a/tests/captcha.test.js b/tests/captcha.test.js new file mode 100644 index 0000000..df4a2c7 --- /dev/null +++ b/tests/captcha.test.js @@ -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(); + }); +});