パスワードを保存しない代わりに守るもの
一人用の管理画面なら、パスワードをDBへ保存せず、登録済みメールアドレスへ6桁のワンタイムパスワード(OTP)を送る構成を選べます。
ただし、「パスワードレス」は無条件に安全という意味ではありません。
信頼の起点がパスワードからメールアカウント、Turnstile検証、OTPの有効期限、試行制限、署名sessionへ移ります。
このブログでは、OTP送信前にCloudflare Turnstileを検証し、送信回数と検証試行をD1で制限します。
OTPそのものは10分で失効し、認証後は7日間の署名Cookieを発行します。
認証経路はメールで届いた10分有効のOTPだけです。
再利用可能な固定codeやメール配送を迂回する別経路は置きません。
この記事では、このメールOTP flowと残る運用上の境界を説明します。
ブラウザからTurnstile検証、メールOTP、試行制限付き検証、7日間の管理sessionへ進む認証flow*図:bot判定、メール配送、OTP所有確認、管理sessionを別々の境界として扱う。*
認証flow
txt
1. 管理者メールアドレスを入力する
2. admin_login actionでTurnstile tokenを取得する
3. ServerがSiteverifyを呼び、success、hostname、actionを検証する
4. D1で送信枠を予約し、6桁OTPをメールで送る
5. OTPを検証し、試行制限を通過したら7日間のsession Cookieを発行する
Turnstile tokenはOTP送信endpointだけで使います。
OTP検証endpointへ同じtokenを再送しないのは、Cloudflareのtokenが単回使用で5分以内という仕様だからです。
その代わり、OTP検証には独立したD1試行制限を置きます。
Siteverifyでsuccess以外も照合する
client widgetの成功表示だけではrequestを許可できません。
Cloudflare公式仕様では、tokenは最大2,048文字、発行後300秒(5分)で失効し、一度しか検証できません。
ServerはSiteverifyを呼び、responseのsuccessに加えてhostnameとactionを期待値と照合します。
このブログではclientとserverがTURNSTILE_ACTIONS.adminLoginを共有します。
また、apexとwwwのどちらでもWorkerが応答するため、固定hostnameを仮定せず、request URLのhostnameをそのrequestの期待値にします。
ts
const remoteIp = turnstileRemoteIp(request);
const turnstile = await verifyTurnstileToken(body.turnstileToken, remoteIp, {
expectedAction: TURNSTILE_ACTIONS.adminLogin,
expectedHostname: new URL(request.url).hostname,
});
if (!turnstile.ok) {
return NextResponse.json({ error: turnstile.error }, { status: 403 });
}
verifierは文字列でないtoken、8文字未満、2,048文字超過をnetwork request前に拒否します。
AbortControllerでfetchとresponse bodyの読み取りを5秒で打ち切り、timeout、非2xx、JSON失敗、metadata不一致を同じfail-closed結果へまとめます。
5秒はCloudflareのtoken寿命ではなく、このapplicationが外部API待ちに設定した上限です。
remoteipにはCloudflare edgeが設定するcf-connecting-ipだけを使います。
callerが自由に送れるx-forwarded-forは信頼しません。
local dummy metadataを許可するのも、TURNSTILE_TEST_MODE=1かつNODE_ENVが明示的にdevelopmentまたはtestの場合だけです。
OTP送信枠をD1で予約する
Worker instance内の変数は別instanceと共有されません。
そのため、OTP送信制限はD1のotp_request_limitsへ保存します。
同じメールアドレスとIPの組み合わせに対し、60秒のcooldownと15分あたり最大5回を適用します。
routeはreadしてからwriteするのではなく、条件付きUPDATE ... RETURNINGで枠を予約します。
RETURNINGが空ならメールを送りません。
D1 bindingを取得できない場合も、memory fallbackで送信せず503を返します。
sql
UPDATE otp_request_limits
SET count = CASE WHEN ? - window_start >= ? THEN 1 ELSE count + 1 END,
window_start = CASE WHEN ? - window_start >= ? THEN ? ELSE window_start END,
last_sent_at = ?
WHERE key = ?
AND (? - window_start >= ?
OR (? - last_sent_at >= ? AND count < ?))
RETURNING count;
枠を確保した後、serverはrandomInt(100000, 1000000)で6桁OTPを作ります。
OTPをメール本文へ渡す前に、メールアドレス、OTP、有効期限から作ったHMAC-SHA256値を署名付きHttpOnly Cookieへ保存します。
Cookieの寿命は10分で、SameSite=Lax、productionではSecureです。
保存に失敗した場合はメールを送りません。
Cloudflare Emailの送信に失敗した場合はOTP Cookieを消し、予約した送信枠も戻します。
配送されなかったOTPとcooldownを残さないためです。
OTP検証を送信制限と分ける
OTP検証にはotp_attempt_limitsを使います。
productionではD1 bindingの取得・初期化に失敗した場合、local counterへfallbackせずlock状態として拒否します。
ただし、binding取得後のSELECT、UPSERT、DELETEは同じtryの外にあります。
これらの後続operationが失敗すると5xxとして表面化し得るため、すべてのD1障害を429へ変換する実装ではありません。
developmentとtestだけは署名Cookieの試行状態へfallbackできます。
試行keyはメールアドレスとIPからHMACで作り、5回の失敗で15分のlock状態を設定します。
比較にはNode.jsのtimingSafeEqualを使い、メールアドレス、有効期限、入力OTPから再計算したHMACとCookie内の値を比較します。
成功時にはOTP Cookieと試行状態を消します。
このkeyは「account全体」ではなく「メールアドレスとIPの組み合わせ」です。
IPを変えた試行をaccount単位で合算する設計ではありません。
OWASPはlockout counterをaccountへ関連付ける案と、lockout自体をDoSへ悪用される危険の両方を挙げています。
この実装は一人用管理画面でのDoS耐性とのtrade-offを選んでおり、全account横断の試行制限が必要なら別のcounterを追加する必要があります。
generic responseにもstatusとtimingの差が残る
入力されたメールアドレスが管理者と一致しなくても、Turnstile検証後の通常response bodyは{ ok: true }にします。
これは正常系の文言から管理者addressを直接漏らさないための処理です。
ts
if (!allowed || email !== allowed) {
return NextResponse.json({ ok: true });
}
ただし、現在のOTP送信routeで非管理者addressはD1予約とメール送信の前に200を返します。
管理者pathはdependency failure時にotp_rate_limit_unavailableまたはotp_store_failedで503、mail_failedで502を返します。
したがって、異常時には200と502・503のstatusおよびbodyがaddressの存在に応じて分かれます。
正常時にもメール送信を待つ管理者pathとはresponse timeが異なります。
OTP検証endpointにも別の差があります。
非管理者addressはD1へ触れる前に401となる一方、管理者候補は失敗回数を消費し、lock後は429になります。
検証endpointにはTurnstileもないため、401と429の差は現在の実装に残る列挙oracleです。
OWASPはHTTP本文だけでなくstatus codeとresponse timeもdiscrepancy factorとして扱います。
列挙耐性が必要なら、request処理をqueueへ切り離すだけでなく、送信系の失敗responseと検証系の401・429もaccountの存在から分離する必要があります。
認証経路をメールOTPに限定する
verifyOtpは、署名済みOTP Cookieに保存したメールアドレス、有効期限、OTPのHMACを、入力値から再計算したHMACと照合します。
管理sessionを発行できるのは、この10分有効のOTP検証が成功した場合だけです。
runtime secretと入力codeを直接比較する分岐はありません。
そのため、Turnstile付きOTP request、メール配送、署名済みOTP Cookieを通らない再利用可能な認証情報は存在しません。
7日間のsession Cookie
OTPが一致すると、serverは管理者メールアドレスと失効時刻をHMAC署名したkirin_blog_admin Cookieを発行します。
CookieはHttpOnly、SameSite=Lax、productionではSecureで、寿命は7日です。
署名は改ざんを検出しますが、payloadを暗号化しません。
このsessionはserver側のsession tableを持たないため、logoutはbrowserのCookieを削除する処理です。
盗まれたCookieを個別に失効させる仕組みはなく、署名が有効なら最長7日間使える可能性があります。
より強いrevocationが必要なら、server側session ID、rotation、失効listを別途設計します。
メールOTPはパスワードDBをなくせますが、メールアカウントを第二要素にするMFAではありません。
メールアカウントが侵害されればOTPも読まれます。
この構成が適するのは、一人用管理画面という範囲、管理者メールの保護、D1とCloudflare Emailをfail-closedで扱う運用を受け入れられる場合です。
公式資料