Skip to content

二要素認証(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_atnull = セットアップ中(まだコードで確認していない)。非 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/totptx 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日間コードを聞かない」)