ユースケース — アカウントページ
アカウントページは、ユーザがパスワードを変更し、認証アプリを置き換え、デバイスを失効させる場所です。セッションを盗んだ攻撃者が真っ先に試す操作ばかりなので、ページは直近にサインインしたことを求めるべきです。また、第 2 要素を差し替える前には、置き換え対象の要素のコードを入力させるべきです。
このページでは、新しいライブラリの機能を足さずに、OP の上でこれを組み立てる方法を示します。アカウントページは、同じ OP の通常の OIDC クライアントです。直近のサインインは max_age で要求し、API 側は rs.RequireMaxAge で検査し、TOTP の要素は totpkit.Verify で再確認します。
このページで触れる仕様
- OpenID Connect Core 1.0 — §3.1.2.1(
max_age、prompt)、§2(auth_time) - RFC 9470 — ステップアップ認証のチャレンジ(
insufficient_user_authentication) - RFC 6238 — TOTP、§5.2(同じコードを 2 度受理してはならない)
同じ OP のクライアントとして作る
OP は、自分のセッション cookie をページから読むヘルパーを公開しません。これは意図的です。セッションが古いとき、更新する手段は、ユーザを /auth にもう一度通すことしかありません。そのためページは、どのみち認可の往復を必要とします。アカウントページをクライアントとして登録し(ブラウザアプリなら PKCE を使う public、サーバ描画なら confidential)、ほかの RP と同じく code フローでサインインします。登録方法はクライアントのオンボーディング、ページが呼ぶ API は API を守るを参照してください。
直近のサインインを求める
重要な操作の前に、ユーザが認証してからどれだけ経ったかを確認します。ID Token には auth_time が入り、JWT のアクセストークンにも入ります。受け入れる範囲より古ければ、その上限を秒で指定した max_age を付けて、新しい認可リクエストを開始します。
GET /auth?response_type=code&client_id=account-page&scope=openid&max_age=300&...OP は max_age を尊重します(OIDC Core §3.1.2.1)。上限より古いセッションは再利用されず、ユーザはもう一度フルで認証します(セッションとログアウト)。年齢にかかわらず新しい認証が必要なら、prompt=login を加えます。
結果の確認は、ページ自身でも行います。コールバックのあと、OIDC クライアントライブラリで ID Token を検証してから、次のように比較します。
const maxAgeSeconds = 300;
const age = Date.now() / 1000 - idToken.auth_time;
if (idToken.auth_time === undefined || age > maxAgeSeconds) {
// 十分に新しくない。先へ進まない
}auth_time のないトークンは要件を満たしません。ブラウザ側の確認が決めるのは画面の表示だけで、データを守るのは次の API 側の検査です。
API 側で強制する
アカウント API はリソースサーバです。scope の要件と並べて rs.RequireMaxAge を置くと、古いサインインから発行されたトークンは、ページが何をしても、そのルートに届きません。
verifier, err := rs.New(rs.Config{
Issuer: "https://op.example.com",
Audience: "https://api.example.com",
})
if err != nil { /* ... */ }
account := verifier.Middleware(
rs.RequireScope("account:write"),
rs.RequireMaxAge(5*time.Minute),
)
mux.Handle("POST /api/account/totp", account(http.HandlerFunc(replaceTOTP)))auth_time が指定した時間幅より古い、または無い場合、API は 401 insufficient_user_authentication を返し、WWW-Authenticate のチャレンジに max_age が秒単位で入ります(RFC 9470 §3)。これがページへの合図です。同じ max_age で認可をやり直し、新しいトークンで呼び出しを再試行します。この交換の OP 側は MFA / ステップアップに、検証器そのものは API を守るにあります。
置き換える前に TOTP コードを再確認する
直近のサインインが証明するのは、セッションが新しいことです。置き換え対象の認証アプリを持っていることまでは証明しません。totpkit.Verify は、ログインのフローの外で、ユーザの確認済み TOTP レコードに対してコードを検証します。
func replaceTOTP(w http.ResponseWriter, r *http.Request) {
tok, _ := rs.FromContext(r.Context())
code := r.FormValue("current_code")
if !limiter.Allow(tok.Subject, clientIP(r)) { // 自前の rate limit
http.Error(w, "too many attempts", http.StatusTooManyRequests)
return
}
err := totpkit.Verify(r.Context(), codec, totpStore, tok.Subject, code, time.Now())
switch {
case err == nil:
// 現在の要素を持っている
case errors.Is(err, totpkit.ErrNotEnrolled):
// 確認済みの要素がまだない。証明する対象がなく、初回の登録になる
case errors.Is(err, totpkit.ErrCodeRejected):
http.Error(w, "code not accepted", http.StatusUnprocessableEntity)
return
case errors.Is(err, totpkit.ErrLocked):
http.Error(w, "try again later", http.StatusTooManyRequests)
return
default:
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// 要素を置き換える: totpkit.NewEnrolment、QR コードの表示、新しいアプリの
// 最初のコードで totpkit.Confirm、その後 totpStore.Put でレコードを保存する。
}codec は totpkit.NewCodec で、ログインのステップと同じ鍵から作った *totpkit.Codec です。totpStore は op.StepTOTP の背後にある store.TOTPStore です。subject は OP 内部のユーザ識別子です。pairwise subject を使う場合、トークンの sub はクライアントごとの値なので、totpkit を呼ぶ前に元の識別子へ戻してください。
| 結果 | 意味 |
|---|---|
nil | 正しく、未使用のコード |
totpkit.ErrCodeRejected | 誤ったコード、または受理済みのコード。両者は区別されないため、どのコードが使用済みかは応答から分かりません |
totpkit.ErrLocked | 失敗が多すぎてレコードがロックされている、またはリセットが必要 |
totpkit.ErrNotEnrolled | レコードがない、または確認されていない |
totpkit.ErrCodecRequired、totpkit.ErrStoreRequired | codec かストアが nil。組み込みのミスです |
| それ以外のエラー | ストアまたは復号の障害(wrap されています) |
Verify は状態をログインのステップと共有します。サインインで受理されたコードは、次の 30 秒のステップまで、ここでは再提出できません(RFC 6238 §5.2)。サインインしたばかりのユーザは、アプリに新しいコードが表示されるまで待つ必要があるかもしれません。
Verify がしないこと
Verify を制限するのは、TOTP レコード自身のカウンタだけです。24 時間で 30 回間違えるとレコードが 1 時間ロックされ、90 回間違えると 24 時間ロックされます。ここでの失敗は、ログインのステップが読むのと同じレコードに積まれます。そのため、ここでの誤入力の連発は、サインイン時の TOTP もロックします。op.WithAuthnLockoutStore で設定する要素をまたぐロックアウトには参加しないので、そのロックアウトはこの試行を数えず、止めもしません。ルートは rs.RequireMaxAge の背後に置き、上の例のように、subject とクライアントアドレスをキーにした自前のレート制限を加えてください。
次に読む
- MFA / ステップアップ: OP 側から見た
acrとmax_ageの要件、および TOTP のログインステップ。 - API を守る: 上で使った検証器、要件、チャレンジ。
- セッションとログアウト: 既存のセッションがリクエストを処理できるかを OP が決める仕組み。