Appearance
二要素認証(TOTP)
対象バージョン: v1.0(2026-08-27 実装) 関連: 要件定義 §7 テナント・認証・通知、SSO(OIDC)ログイン、権限カタログ §1.8
KIZAMI の自前認証(メール+パスワード)に、本人が任意で有効化できる二要素認証を足す。 方式は TOTP(RFC 6238。Google Authenticator・1Password・Authy 等の認証アプリが表示する 6 桁)。
0. なぜ入れるのか
勤怠は人事データである。氏名・メールアドレス・所属・入社日・毎日の在社時刻・(設定次第で) 打刻位置が入っており、給与計算の入力そのものでもある。パスワード1本で守るには重い。
SSO(OIDC)を設定している会社は IdP 側の MFA でこれを満たせるが、SSO を使わない配備 (小規模事業者・オンプレ運用)には現状その手段が無かった。自前認証の底上げとして入れる。
1. 決定事項の一覧
| 論点 | 決定 | 理由 |
|---|---|---|
| 方式 | TOTP(30秒・6桁・SHA-1・±1ステップ許容) | 認証アプリの事実上の標準。パスキー(WebAuthn)は将来の別課題 |
| 実装 | 自前(@kizami/crypto の totp.ts / base32.ts) | §2 |
| 適用範囲 | パスワードログインのみ。SSO はバイパス、APIキーは無関係 | §6 |
| 有効化 | 本人が任意で(テナント強制はしない) | §7 の将来課題 |
| 共有鍵の保存 | AES-256-GCM で暗号化(KIZAMI_ENCRYPTION_KEY)。鍵が無ければ 2FA は使えない | §3 |
| ロックアウト対策 | リカバリコード10本(単回使用)+ 管理者リセット | §5 |
| リプレイ防止 | 最後に受理したカウンタを保存し、それ以下を拒否 | §4 |
2. TOTP を自前実装した判断
TOTP の本体は「HMAC-SHA1 を1回計算して 31 ビットを切り出し 10 進 6 桁にする」だけで、 実装は 20 行に収まる(packages/crypto/src/totp.ts の hotp())。
依存パッケージを1つ増やすと、認証の中核が第三者のリリース運用に乗る(サプライチェーンの 面積が広がる)一方、得られるのは 20 行の節約でしかない。加えて KIZAMI は workerd でも動かす 前提で node:crypto を使わない制約があり、npm の TOTP 実装の多くは node:crypto 前提のため 選択肢自体が狭い。
そこで WebCrypto (crypto.subtle) だけで自前実装し、正しさは RFC 6238 Appendix B のテストベクタをそのまま入れて固定した(packages/crypto/test/totp.test.ts)。 base32(RFC 4648)も同様に §10 のテストベクタで固定してある。
置き場所を @kizami/crypto(ランタイム非依存パッケージ)にしたのは、pnpm test:workers の workerd レグで同じテストが走るため。ここが緑でなくなったら「ランタイム非依存」が壊れている。
SHA-1 について
TOTP の既定は SHA-1 で、Google Authenticator を含む主要アプリは SHA-1 しか読まない実装を持つ。 ここでの SHA-1 の用途は HMAC であり、衝突耐性への攻撃(SHAttered 等)は該当しない。 相互運用性を優先して SHA-1 のままにする。
QR コードは v1 では出さない(判断点)
otpauth:// URI を QR にするには、Reed-Solomon 誤り訂正を含む 300 行規模の実装か 新しい依存が要る。どの認証アプリにも「セットアップキーを手動入力」の経路があるため、 v1 では base32 の共有鍵と otpauth:// URI をコピー可能なテキストで表示するに留める。 QR は将来、UI の体験改善として別途検討する。
3. データモデル
マイグレーション 0029(PostgreSQL ミラーは migrations-pg/0004)。
user_totp(1ユーザー1行)
| 列 | 内容 |
|---|---|
user_id (PK) | 対象ユーザー |
tenant_id | テナント |
secret_encrypted | 共有鍵(base32)を enc:v1:... で暗号化した値 |
enabled_at | null = セットアップ中(まだコードで確認していない)。非 null = 有効 |
last_used_counter | 最後に受理した TOTP カウンタ(リプレイ防止、§4) |
created_at | 作成時刻 |
auth_credentials に列を足さず別テーブルにした。auth_credentials は「パスワードを設定した = 招待を受諾した」ことを表す行であり、TOTP は本人が後から任意で足す別の要素。同じ行に混ぜると 「2FA だけリセットしたい」が UPDATE の部分適用になって意図が読めなくなる。将来パスキーを足す ときも同じ形(要素ごとに1テーブル)で並べられる。
共有鍵は暗号化必須。パスワードと違い、検証に平文が要るので一方向ハッシュにはできない。 DB 単体の流出で 2FA が無力化されないよう、鍵(KIZAMI_ENCRYPTION_KEY)を別に要求する。 鍵が未設定の配備では 2FA を有効化できず、既に有効なユーザーはログインできない(503)。 「鍵が無いから素通り」は、鍵の設定を落とすだけで 2FA を無効化できるという意味なので採らない。
user_totp_recovery_codes
id / tenant_id / user_id / code_hash(SHA-256 hex)/ consumed_at / created_at。 平文は保存しない(パスワードリセットトークンと同じ作法)。UNIQUE は (user_id, code_hash) — 全テナント横断の UNIQUE にすると、別人のコードとの衝突が制約違反として漏れる。
4. フロー
有効化(2段階)
POST /auth/totp/setup → { secret, otpauthUri } ... enabled_at = null で保存
(利用者が認証アプリへ登録)
POST /auth/totp/enable { code }
→ コード検証 → enabled_at を立てる + リカバリコード10本を1度だけ返す1段階にしない理由: 「QR/キーをうまく登録できていなかった」「端末の時計が大きくずれていた」 に気づかないまま有効になると、次のログインで自分を締め出す。確認できたときだけ有効にする。
有効化の確認に使ったコードは、その時点で last_used_counter に記録する (同じコードでログインの第2段階を通せると、有効化直後だけリプレイ防止に穴が空くため)。
ログイン(2段階)
POST /auth/login { email, password }
2FA 無効 → 従来どおり { user } + Set-Cookie: kizami_session
2FA 有効 → 200 { status: "totp_required" } + Set-Cookie: kizami_totp_tx(5分・暗号化)
※ セッションは張らない。ユーザーの情報も返さない
POST /auth/login/totp { code } | { recoveryCode }
→ 検証 → { user } + Set-Cookie: kizami_session、kizami_totp_tx は削除kizami_totp_tx は { tenantId, userId, issuedAt } を Encryptor(AES-256-GCM)で暗号化した httpOnly / SameSite=Lax / 5分の Cookie。サーバー側に中間状態テーブルを持たないという、 OIDC の kizami_oidc_tx(sso-oidc.md §3)と同じ判断。5分としたのは、 端末を探す余裕はあり、かつ「パスワードは通っている」状態を長く漂流させないため。
第2段階ではもう一度ユーザーの有効性と 2FA の有効性を確認する(第1段階のあとに退職処理や 管理者リセットが走っている可能性がある)。どちらも失敗すれば totp_expired で最初からやり直し。
リプレイ防止
TOTP のコードは 30 秒間有効なので、肩越しに見られた/中間者に取られたコードが同じ窓の内に 再送されうる。user_totp.last_used_counter に最後に受理したカウンタを保存し、 それ以下のカウンタは正しいコードでも拒否する(verifyTotp({ minCounterExclusive }))。
±1 ステップの許容(受理窓 90 秒)と組み合わせると、「1つ前のコードを使った直後に、 より新しいコードを使う」は通り、「同じコードを2回」は通らない。
レート制限
ip|userId ごとに 10回/15分(RATE_LIMITS.totpPerIpUser)。6桁は 100 万通りしかなく、 受理窓が3つあるため、制限が無ければ総当たりが現実的な範囲に入る。
ログインの第2段階(POST /auth/login/totp)と、セルフサービスの有効化・無効化・リカバリコード 再生成は同じ RateLimiter インスタンスを共有する。攻撃者から見れば「6桁を当てる」という 同じ試行であり、経路を変えれば回数が倍になるのでは意味がない。
5. ロックアウト対策
認証アプリを入れた端末を失くすと本人はログインできなくなる。2段構えで救う。
リカバリコード(本人が使う)
有効化時に 10 本発行し、1度だけ表示する(XXXXX-XXXXX、base32 アルファベット = O/0・I/1 の取り違えが起きない)。DB には SHA-256 のみ。単回使用で、 POST /auth/login/totp { recoveryCode } で消費する。
消費は「consumed_at IS NULL を WHERE に含めた UPDATE の更新行数」で判定する — SELECT してから UPDATE すると、同じコードの同時送信で2回通りうる。
再生成(POST /auth/totp/recovery-codes)は古いコードを全て無効にする。 残数は設定画面(/settings/security)に表示する。
管理者リセット(最後の手段)
POST /members/:id/two-factor/reset。TOTP の登録とリカバリコードを消すだけで、 パスワードには触れない(次のログインはパスワードのみで通り、本人が改めて設定し直す)。
権限は member.deactivate(退職処理と同格)。専用キーは新設しない。これは 「他人のログイン要件を一段弱める」操作で、攻撃者から見れば「2FA を消してからパスワードを 総当たり/リセットする」踏み台になる。member.invite(招待・パスワードリセット発行)より 重い扱いが妥当で、カタログ上「危険」と印の付いた既存キーが member.deactivate である。
事後の可視性として、監査ログ(member.totp.reset)に加えて本人へアプリ内通知を送る (security_totp_reset)。管理者が黙って 2FA を外せてしまうと、乗っ取られた管理者アカウントに よる 2FA 解除に本人が気づけない。
この通知は個人の受け取り設定のカテゴリには載せない(resolveNotificationCategory に 意図的に追加していない)。セキュリティ事象は本人が黙らせられるべきではないため、 OFF にできないアプリ内通知に限定する。
6. 他の認証経路との関係
SSO(OIDC)はバイパスする
SSO ログインでは KIZAMI の TOTP を要求しない。SSO では「誰であるか」を IdP が保証しており、 多要素を課すかどうかも IdP 側の条件付きアクセスポリシーで決まる。KIZAMI が二重に要求すると、 IdP で既に MFA を済ませた利用者に無意味な二度手間を強いることになる。
KIZAMI の 2FA は自前認証(パスワード)を補強するものという位置づけを守る。 (リグレッションテスト: apps/api/test/oidc-login.test.ts「2FA を有効にしていても SSO ログインは TOTP を要求しない」)
APIキーは無関係
公開打刻 API(Authorization: Bearer kzm_...)は人間の対話的ログインではなく、 IC カードリーダー等の機械が使う。TOTP を課す余地がない。逆に、APIキー認証では 2FA のエンドポイントに一切触れない — auth/api-key-scope-guard.ts の許可表に 載せていないため、どんなスコープのキーでも 403 になる。
招待受諾・パスワードリセット
どちらも 2FA より前段の「パスワードを設定する」経路であり、変更していない。 パスワードリセットの使用後はそのままセッションが張られるが、そのユーザーが 2FA を 有効にしている場合でも第2段階は挟まない — 管理者が発行したリンクを持っているという 事実が既に別要素として働いており、かつここで塞ぐと「パスワードも 2FA も失った人」の 救済経路が消えるため(管理者リセットと組み合わせて運用する)。
7. エンドポイント一覧
| メソッド | パス | 認証 | 権限 | 内容 |
|---|---|---|---|---|
| POST | /auth/login | 不要 | — | 2FA 有効なら { status: "totp_required" } |
| POST | /auth/login/totp | tx Cookie | — | { code } または { recoveryCode } を検証しセッション発行 |
| GET | /auth/totp | セッション | 不要(本人) | { available, enabled, enabledAt, recoveryCodesRemaining } |
| POST | /auth/totp/setup | セッション | 不要(本人) | 共有鍵を発行し { secret, otpauthUri } |
| POST | /auth/totp/enable | セッション | 不要(本人) | { code } で確認して有効化。リカバリコードを返す |
| POST | /auth/totp/disable | セッション | 不要(本人) | { password, code } の両方が必要 |
| POST | /auth/totp/recovery-codes | セッション | 不要(本人) | { password, code } で再生成 |
| POST | /members/:id/two-factor/reset | セッション | member.deactivate | 管理者によるリセット |
無効化・再生成がパスワードとコードの両方を求めるのは、盗まれたセッションで 2FA を外す/ リカバリコードを本人の知らないものへ置き換えることを許すと 2FA の意味がなくなるため。 セッションを盗んだ攻撃者は、そのどちらか片方しか持っていないのが普通である。
8. 監査ログ
| action | 発生元 |
|---|---|
auth.totp.enable | 本人が有効化 |
auth.totp.disable | 本人が無効化 |
auth.totp.recovery_codes.regenerate | 本人がリカバリコードを再生成 |
member.totp.reset | 管理者がリセット(detail は空、target は対象ユーザー) |
auth.login (detail.method = "totp" / "recovery_code") | 2FA を通ったログイン |
素のパスワードログインは従来どおり記録しない(v0.1 以来の方針)。2FA を通った事実と、 とりわけリカバリコードの使用は残す — 「端末を失くした」のか「攻撃者が使った」のかを 見分ける唯一の手掛かりになる。
9. 将来課題
- テナントによる強制(「全員 2FA 必須」設定)。今回は本人の任意のみ。強制を入れるなら 「猶予期間」「未設定者のログイン時に設定画面へ誘導」の設計が要る
- パスキー(WebAuthn)。TOTP よりフィッシング耐性が高い。
user_totpと同じ形で 「要素ごとに1テーブル」を並べれば足りるようにしてある - QR コード表示(§2)
- 信頼済み端末(「このブラウザでは30日間コードを聞かない」)