CAPTCHA対応
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 はプレースホルダを使用。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
45
src/server/modules/auth/captcha.js
Normal file
45
src/server/modules/auth/captcha.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
252
tests/captcha.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user