CAPTCHA対応
This commit is contained in:
@@ -7,3 +7,7 @@ VAPID_PUBLIC_KEY=
|
|||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
VAPID_SUBJECT=mailto:admin@example.com
|
VAPID_SUBJECT=mailto:admin@example.com
|
||||||
OPENSSL_PATH=openssl
|
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_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. |
|
| `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`. |
|
| `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`.
|
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
|
## Operational Notes
|
||||||
|
|
||||||
- Run `pnpm monitor:worker` as a long-lived Node process for hourly certificate checks.
|
- Run `pnpm monitor:worker` as a long-lived Node process for hourly certificate checks.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
- 認証必須ミドルウェア
|
- 認証必須ミドルウェア
|
||||||
- 登録 API
|
- 登録 API
|
||||||
- ログイン API
|
- ログイン API
|
||||||
|
- 認証画面向け CAPTCHA 公開設定 API
|
||||||
|
- 登録 / ログイン API の CAPTCHA 検証
|
||||||
- ログアウト API
|
- ログアウト API
|
||||||
- 現在ユーザー取得 API
|
- 現在ユーザー取得 API
|
||||||
- サイト一覧 API
|
- サイト一覧 API
|
||||||
@@ -156,6 +158,7 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/health
|
GET /api/health
|
||||||
|
GET /api/auth/captcha
|
||||||
GET /api/auth/csrf
|
GET /api/auth/csrf
|
||||||
POST /api/auth/register
|
POST /api/auth/register
|
||||||
POST /api/auth/login
|
POST /api/auth/login
|
||||||
@@ -290,6 +293,8 @@ pnpm monitor:worker
|
|||||||
- アカウント削除には現在のパスワードを要求。
|
- アカウント削除には現在のパスワードを要求。
|
||||||
- アカウント削除は `users` の削除を起点に関連データを CASCADE で削除。
|
- アカウント削除は `users` の削除を起点に関連データを CASCADE で削除。
|
||||||
- ログイン時、TOTP が有効なユーザーは OTP 検証を必須化。
|
- ログイン時、TOTP が有効なユーザーは OTP 検証を必須化。
|
||||||
|
- Cloudflare Turnstile / hCaptcha による登録・ログイン時の CAPTCHA 検証を環境変数で有効化可能。
|
||||||
|
- CAPTCHA 有効時、TOTP ログインの各 POST で新しい CAPTCHA トークンを必須化。
|
||||||
- 通知タイミングは 1 時間以上、17520 時間以内に制限。
|
- 通知タイミングは 1 時間以上、17520 時間以内に制限。
|
||||||
- 同一サイト内の重複した通知タイミングを拒否。
|
- 同一サイト内の重複した通知タイミングを拒否。
|
||||||
- SQL はプレースホルダを使用。
|
- SQL はプレースホルダを使用。
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ services:
|
|||||||
VITE_HOST: 0.0.0.0
|
VITE_HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
DATABASE_URL: postgres://certremind:certremind@postgres:5432/certremind
|
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:
|
ports:
|
||||||
- '127.0.0.1:5173:5173'
|
- '127.0.0.1:5173:5173'
|
||||||
volumes:
|
volumes:
|
||||||
@@ -23,6 +32,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
DATABASE_URL: postgres://certremind:certremind@postgres:5432/certremind
|
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:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- app-node-modules:/app/node_modules
|
- app-node-modules:/app/node_modules
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ services:
|
|||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
|
||||||
OPENSSL_PATH: ${OPENSSL_PATH:-openssl}
|
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:
|
ports:
|
||||||
- '127.0.0.1:3000:3000'
|
- '127.0.0.1:3000:3000'
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -42,7 +46,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgres://certremind:certremind@postgres:5432/certremind
|
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_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:admin@example.com}
|
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 { ShieldCheck } from 'lucide-react';
|
||||||
import { request } from '../api/client.js';
|
import { request } from '../api/client.js';
|
||||||
import { Field } from '../components/Field.jsx';
|
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) {
|
function validateAuthForm(mode, form) {
|
||||||
if (mode === 'register' && !form.displayName.trim()) {
|
if (mode === 'register' && !form.displayName.trim()) {
|
||||||
throw new Error('表示名を入力してください');
|
throw new Error('表示名を入力してください');
|
||||||
@@ -29,11 +68,77 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
|
|||||||
const [totpRequired, setTotpRequired] = useState(false);
|
const [totpRequired, setTotpRequired] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setError('');
|
setError('');
|
||||||
setTotpRequired(false);
|
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) {
|
async function submit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -41,6 +146,9 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
validateAuthForm(mode, form);
|
validateAuthForm(mode, form);
|
||||||
|
if (captchaConfig.enabled && !captchaToken) {
|
||||||
|
throw new Error('CAPTCHA認証を完了してください');
|
||||||
|
}
|
||||||
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
|
||||||
const payload =
|
const payload =
|
||||||
mode === 'login'
|
mode === 'login'
|
||||||
@@ -48,11 +156,13 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
|
|||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
password: form.password,
|
password: form.password,
|
||||||
otp: form.otp.trim() || undefined,
|
otp: form.otp.trim() || undefined,
|
||||||
|
captchaToken: captchaToken || undefined,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
displayName: form.displayName.trim(),
|
displayName: form.displayName.trim(),
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
captchaToken: captchaToken || undefined,
|
||||||
};
|
};
|
||||||
const data = await request(endpoint, {
|
const data = await request(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -63,9 +173,11 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
|
|||||||
if (err.totpRequired) {
|
if (err.totpRequired) {
|
||||||
setTotpRequired(true);
|
setTotpRequired(true);
|
||||||
setError('2段階認証コードを入力してください');
|
setError('2段階認証コードを入力してください');
|
||||||
|
resetCaptcha();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
resetCaptcha();
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -150,8 +262,14 @@ export function AuthPanel({ mode, onModeChange, onAuthed }) {
|
|||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{captchaConfig.enabled ? (
|
||||||
|
<div className="captcha-box" aria-busy={!captchaReady}>
|
||||||
|
<div ref={captchaContainerRef} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? <p className="error">{error}</p> : null}
|
{error ? <p className="error">{error}</p> : null}
|
||||||
<button className="primary" disabled={busy}>
|
<button className="primary" disabled={busy || !captchaReady}>
|
||||||
{busy ? '処理中...' : mode === 'login' ? 'ログイン' : '登録'}
|
{busy ? '処理中...' : mode === 'login' ? 'ログイン' : '登録'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -200,6 +200,13 @@ select:focus {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.captcha-box {
|
||||||
|
min-height: 65px;
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.toast-viewport {
|
.toast-viewport {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 60;
|
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 = {
|
export const env = {
|
||||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
host: process.env.HOST ?? '127.0.0.1',
|
host: process.env.HOST ?? '127.0.0.1',
|
||||||
@@ -9,4 +20,8 @@ export const env = {
|
|||||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '',
|
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '',
|
||||||
vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:admin@example.com',
|
vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:admin@example.com',
|
||||||
opensslPath: process.env.OPENSSL_PATH ?? 'openssl',
|
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 { createSession, destroySession, requireAuth } from '../../middleware/auth.js';
|
||||||
import { issueCsrf } from '../../middleware/csrf.js';
|
import { issueCsrf } from '../../middleware/csrf.js';
|
||||||
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
|
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
|
||||||
|
import { getCaptchaPublicConfig, verifyCaptchaToken } from './captcha.js';
|
||||||
|
|
||||||
const router = new Hono();
|
const router = new Hono();
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ const registerSchema = z.object({
|
|||||||
.max(40)
|
.max(40)
|
||||||
.regex(/^[a-zA-Z0-9_.-]+$/),
|
.regex(/^[a-zA-Z0-9_.-]+$/),
|
||||||
password: z.string().min(12).max(200),
|
password: z.string().min(12).max(200),
|
||||||
|
captchaToken: z.string().trim().min(1).max(4096).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -28,6 +30,7 @@ const loginSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.regex(/^\d{6}$/)
|
.regex(/^\d{6}$/)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
captchaToken: z.string().trim().min(1).max(4096).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function publicUser(row) {
|
function publicUser(row) {
|
||||||
@@ -39,12 +42,16 @@ function publicUser(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get('/csrf', (c) => c.json({ csrfToken: issueCsrf(c) }));
|
router.get('/csrf', (c) => c.json({ csrfToken: issueCsrf(c) }));
|
||||||
|
router.get('/captcha', (c) => c.json(getCaptchaPublicConfig()));
|
||||||
|
|
||||||
router.post('/register', async (c) => {
|
router.post('/register', async (c) => {
|
||||||
const body = registerSchema.safeParse(await c.req.json().catch(() => null));
|
const body = registerSchema.safeParse(await c.req.json().catch(() => null));
|
||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
throw badRequest('入力内容を確認してください', body.error.flatten());
|
throw badRequest('入力内容を確認してください', body.error.flatten());
|
||||||
}
|
}
|
||||||
|
if (!(await verifyCaptchaToken(body.data.captchaToken))) {
|
||||||
|
throw badRequest('CAPTCHA認証に失敗しました');
|
||||||
|
}
|
||||||
|
|
||||||
const passwordHash = await hash(body.data.password, {
|
const passwordHash = await hash(body.data.password, {
|
||||||
algorithm: 2,
|
algorithm: 2,
|
||||||
@@ -75,6 +82,9 @@ router.post('/login', async (c) => {
|
|||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
throw unauthorized('ユーザー名またはパスワードが違います');
|
throw unauthorized('ユーザー名またはパスワードが違います');
|
||||||
}
|
}
|
||||||
|
if (!(await verifyCaptchaToken(body.data.captchaToken))) {
|
||||||
|
throw badRequest('CAPTCHA認証に失敗しました');
|
||||||
|
}
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT u.user_id,
|
`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