ユースケース — MFA / ステップアップ
ログイン層は、組み合わせて使う 3 つの部品でできています。
Step: 1 つの認証要素を検証します(パスワード、TOTP、パスキー、メール OTP、またはop.ExternalStepで足した独自の要素)。Rule: その試行でステップが必要かどうかを判定します。LoginFlow:Primaryステップ 1 つと、順序付きのRulesのリストです。
Primary が成功すると、OP はルールを順に調べ、条件に合ったルールのステップをすべて実行します。「パスワードと TOTP を常に要求」も 1 つのフローで、「パスワードは常に、3 回失敗したら captcha、リスクが高ければ TOTP」も別の 1 つのフローです。
このページは、ログインの判定が「パスワードが通ったか」だけでは終わらない場合に使います。たとえば、必須の MFA、不審な試行への captcha、リスクに応じた追加の認証要素、RP が要求するより強い acr です。ユーザ名とパスワードだけでよいなら、既定のパスワード用 primary ステップとユーザストアのページで足ります。必要になる前にルールを増やしても、テストと復旧の範囲が広がるだけです。
このページで触れる仕様
- RFC 6238 — TOTP(時刻ベースワンタイムパスワード)
- RFC 8176 — Authentication Method Reference Values(
amr) - RFC 9470 — OAuth 2.0 Step-up Authentication Challenge(リソースサーバ側)
- WebAuthn Level 3 — パスキー
- NIST SP 800-63B — Authenticator Assurance Levels(AAL)
- OpenID Connect Core 1.0 — §2(
acr/amr/auth_time)
用語の補足
- MFA(Multi-Factor Authentication): ユーザが複数の認証要素(知っているもの、持っているもの、生体)を示してから、OP がトークンを発行する仕組みです。
- ステップアップ: RP が機微な操作のために、より高い保証水準を求めることです。必要なクラスを
acr_valuesに指定して送ります(OIDC Core §3.1.2.1)。OP は追加の認証要素を実行してから、新しいid_tokenを発行します。リソースサーバがクライアントにこれを求める方法は、RFC 9470 が標準化しています。 - AAL(Authenticator Assurance Level): NIST が定める 3 段階の保証水準です。AAL1 はおおよそパスワード、AAL2 はパスワードに第 2 要素を加えたもの、AAL3 はハードウェアで裏付けた所持の証明です。多くの OP と RP が
acrのラベルに使います。 amrclaim: RFC 8176 が標準値(pwd、otp、mfa、hwk、face、fptなど)を列挙しているので、RP は実際にどの認証要素が使われたかを監査できます。
ソース:
examples/20-mfa-totp— パスワードと常時 TOTP。examples/21-risk-based-mfa— リスクに応じたステップアップ。examples/22-login-captcha— N 回失敗後の captcha。examples/23-step-up— ACR によるステップアップ。examples/27-durable-mfa-store— 同梱の SQL 認証要素ストアを、OP のコアテーブルと 1 つの DB で使う構成。
構成
LoginFlow は構造体で、Primary ステップ、Rules のリスト、任意の 2 つのフィールドを持ちます。1 つは Decider(ルールでは表せない方針を、ルールより先に判定するフック)、もう 1 つは Risk(RiskAssessor)です。各 Rule は条件とステップの組で、コンストラクタで作ります。
| コンストラクタ | 発火する条件 |
|---|---|
op.RuleAlways(step) | 毎回 |
op.RuleAfterFailedAttempts(n, step) | この試行で拒否された送信が n 回以上 |
op.RuleRisk(threshold, step) | assessor のスコアが threshold 以上 |
op.RuleNewDevice(step) | assessor が新しいデバイスと報告した |
op.RuleClient(clientID, step) | そのクライアントからのリクエスト |
op.RuleScope(scope, step) | その scope を要求している |
op.RuleACR(acr, step) | リクエストの acr_values に acr が含まれる |
op.RuleWhen(pred, step) | op.LoginContext に対する自前の条件が true を返す |
op.LoginContext はリクエストの情報に加えて、AAL(ここまでに到達した水準。再利用されたセッションの水準とこの試行の水準のうち高い方)、SessionReused(既存のセッションが Primary の代わりになっている)、Identity.AuthenticationContext(amr、acr、セッション再利用時は auth_time)を持ちます。独自の op.ACRPolicy の Resolve にも同じ値が渡されます。
OP は次のように評価します。
Primaryの後、ルールを宣言順に調べ、条件に合い、まだ実行されていない最初のルールのステップを実行します。実行したらもう一度調べ、どれにも合わなくなったら認証は完了です。op.StepCaptchaをステップに持つルールは、並び順にかかわらず他のルールより先に調べます。同じ種類のルール同士は宣言順を保ちます。- 同じ種類のステップは 1 つのルールにしか置けません。繰り返したフローは
op.Newが拒否します。 Whenが nil のルール(op.Rule{Then: step}の構造体リテラルも含む)は常に合致します。条件が panic した場合も合致として扱います。条件に欠陥があっても、認証要素を飛ばさず要求する側に倒れます。Primaryは利用者を特定できなければなりません。op.StepTOTP、op.StepEmailOTP、op.StepRecoveryCodeは特定済みのユーザを検証するものなので、Primaryに置くとop.Newが拒否します。op.PrimaryPassword、op.PrimaryPasskey、op.ExternalStepのいずれかの後ろのルールに置いてください。Primary(またはそこに置いたStepOneOf)のop.PrimaryPasskeyはパスワードレスのログインになり、RequireUserVerificationを設定しないとop.Newが失敗します。WithLoginFlowを呼べるのは 1 回です。
常時 TOTP
import "github.com/libraz/go-oidc-provider/op"
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleAlways(op.StepTOTP{
Store: st.TOTPs(),
EncryptionKey: keys.TOTPKey,
}),
},
}
op.New(
/* ... */
op.WithLoginFlow(flow),
)N 回失敗後の captcha
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleAfterFailedAttempts(3, op.StepCaptcha{Verifier: myCaptchaVerifier}), // hCaptcha / Turnstile など
},
}
op.New(
/* ... */
op.WithLoginFlow(flow),
)captcha プロバイダは、StepCaptcha の Verifier フィールドだけで指定します。
RuleAfterFailedAttempts が読むのは、現在の試行で拒否された送信の回数で、LoginAttemptObserver(op.WithLoginAttemptObserver で渡す)から得ます。observer は認証試行ごとに op.LoginAttempt を 1 つ受け取ります。Outcome は op.AttemptSuccess、op.AttemptFailure、op.AttemptLocked のいずれかです。AttemptLocked は、送信された資格情報を評価せずに総当たり対策のゲートが試行を終了させたことを表すので、通常の失敗とは別に処理してください。
リスクに応じたステップアップ
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleRisk(op.RiskScoreHigh, op.StepTOTP{Store: st.TOTPs(), EncryptionKey: keys.TOTPKey}),
},
Risk: myRiskAssessor, // LoginFlow の Risk フィールド
}
op.New(
/* ... */
op.WithLoginFlow(flow),
)RiskAssessor はログイン試行ごとに 1 回、Primary の前に実行され、4 段階の順序付き列挙(RiskScoreNone < RiskScoreLow < RiskScoreMedium < RiskScoreHigh)の RiskScore を返します。リスク評価サービスの出力をこの列挙値に変換するのは、組み込み側の assessor の役目です。RuleRisk(threshold, step) は、スコアが threshold 以上のときに発火します。
assessor は、試行の拒否や、特定の認証要素の要求を直接返すこともできます。assessor は LoginFlow.Risk に指定します。assessor がなければスコアは RiskScoreNone のままなので、RuleRisk は発火しません。エラーにもなりません。
ACR によるステップアップ
RP がより強い Authentication Context Class を要求したとき、その値に対する RuleACR がステップを実行します。標準の値は op.AAL1.ACRURI()(urn:mace:incommon:iap:bronze)、op.AAL2.ACRURI()(urn:mace:incommon:iap:silver)、op.AAL3.ACRURI()(http://idmanagement.gov/ns/assurance/loa/4)です。
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleACR(op.AAL2.ACRURI(), op.StepTOTP{Store: st.TOTPs(), EncryptionKey: keys.TOTPKey}),
},
}
op.New(
/* ... */
op.WithLoginFlow(flow),
)このルールは acr_values に対する単純な一致判定です。指定した認証要素を実行し、どの認証要素を使うかを OP が選ぶことはありません。再利用されたセッションでは、セッションがすでにその要素を持っていればステップは完了済みとして扱われます(再利用されたセッション上のルール を参照)。
発行される id_token の acr には、ログインが実際に到達した水準だけが載ります。判定は ACRPolicy が行い、既定のポリシーは、要求された値が標準の URI で、そのレベルがログインの到達水準以下のときに満たされたとみなします。
acr_valuesによる任意(voluntary)の要求では、満たされた最初の値を返します。どれも満たされなければ、ログインが実際に到達したレベルの標準 URI を返すので、RP は水準が足りなかったことを読み取れます。acrの essential 要求をどの値も満たせないときは、OP がunmet_authentication_requirementsで拒否します。- 独自の
op.ACRPolicyも、Resolveがok=falseを返したときは同じ扱いになります。
独自の acr 値
標準の URI でない acr は、既定では満たされません。op.ACRLevels で宣言します。値ごとに、それを満たす最小の AAL を対応づけます。
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
Rules: []op.Rule{
op.RuleACR("urn:example:mfa", op.StepTOTP{Store: st.TOTPs(), EncryptionKey: keys.TOTPKey}),
},
}
op.New(
/* ... */
op.WithLoginFlow(flow),
op.WithACRPolicy(op.ACRLevels{"urn:example:mfa": op.AAL2}),
op.WithACRValuesSupported("urn:example:mfa"),
)キーは空にできず、値は op.AAL1 から op.AAL3 の範囲に限られます。標準 URI と同じキーには、その URI 本来のレベルを指定してください。map は op.New に渡した後で変更しないでください。既定のポリシーまたは op.ACRLevels を使うとき、op.WithACRValuesSupported にポリシーが解決できない値があると、op.New が拒否します。OP が報告できない値を広告することになるためです。独自の ACRPolicy は検査の対象外です。
セッションに記録されるのは、到達したレベルの標準 URI です。既存のセッションは、要求された値のいずれかを、そのレベルについて ACRPolicy が受け入れるとき、acr_values(または essential な acr)を満たします。上の op.ACRLevels なら、AAL2 のセッションは acr_values=urn:example:mfa に再利用され、AAL1 のセッションは再認証になります。
独自の op.ACRPolicy では、この判定に Satisfies(ctx, requested, internal, completed) が呼ばれます。internal にはセッションのレベル、completed にはセッションが代わりを務めるステップの種類が渡されます。セッションから応答する場合、Resolve も同じ一覧を LoginContext.CompletedSteps で受け取ります。したがって Satisfies が緩いと、既存のセッションがステップアップを飛ばすのも同じ程度に許されます。
再利用されたセッション上のルール
ログインなしで、既存のセッションだけでリクエストを処理できる場合があります。たとえば、キャッシュ済みの grant が scope を満たしている場合や、クライアントがファーストパーティの場合です。OP はその方法で code を発行する前に、Primary と、セッションがすでに代わりを務めているステップを完了済みとして、Decider とルールを評価します。ルールがステップを要求すれば、OP は発行せず、セッションの subject からチェーンを実行します。prompt=none なら interaction_required を返します。
ルールを設計するときの注意点は次のとおりです。
- ルールのステップは、セッションの
amrがそのステップの記録するものを持っていれば完了済みとして扱われます。パスワードはpwd、TOTP・recovery code・メール OTP はotp、passkey はhwk(ステップが user verification を要求しない場合はswkも)です。RuleAlways(StepTOTP)は、セッションがotpを持っていれば、シングルサインオンのたびには再度要求しなくなります。パスワードだけのセッションには、引き続き要求します。 - 3 つの
otp要素は互いの代わりになります。メール OTP で作られたセッションは TOTP を指定するルールを満たします。ID Token が同じamrと水準で報告するためです。op.StepOneOfは、含まれるステップのいずれかが満たされれば完了済みです。op.ExternalStepとop.StepCaptchaはamrを残さないため、ログインのたびに再度要求されます。 - 新しい認証要素が必要な RP は、
max_ageやprompt=loginを送るか、セッションの水準を超える essential なacrを要求してください。 IfNoneEnrolled: op.UnenrolledSkipのStepOneOfは、そのステップのどれにも登録していないユーザを止めません。セッションで処理され、prompt=noneなら code を返します。登録の確認に失敗したときは、そのステップは必須として扱われます。- 再利用されたセッション上のステップアップでは、発行されるトークンの
acr、amr、auth_timeに、セッションの値とステップアップの認証要素の両方が残ります。リスク assessor にはセッションの認証方法が見え、ルールにはPrimaryが完了済みに見えます。 - captcha のルールとリスク駆動のルールは、この再利用の判定には使われません。チェーンの内側でだけ動きます。
- 再利用されたセッション上のステップアップは、
login.*ではなくmfa.*イベントとして監査されます。
リソースサーバ側: op.StepUpChallenge
RFC 9470 には 2 つの側面があります。OP 側は上のとおりで、/auth の acr_values と max_age を尊重して再認証します。もう一方はリソースサーバにあります。機微な呼び出しに対してアクセストークンの強度や新しさが足りないとき、リソースサーバは 401 を返し、error="insufficient_user_authentication" と必要な acr_values や max_age を載せた WWW-Authenticate: Bearer チャレンジを添えます。クライアントはその値で再認可し、上で説明した OP 側のステップアップが動きます。
op.StepUpChallenge は、そのヘッダ値を組み立てます。OP 自身はこれを発行しません。トークン検証と 401 の応答はリソースサーバの仕事なので、ライブラリが行うのはチャレンジ文字列の生成までです。
maxAge := int64(300)
challenge := op.StepUpChallenge("api", []string{"urn:acr:high", "urn:acr:mfa"}, &maxAge)
// challenge == `Bearer realm="api", error="insufficient_user_authentication",
// acr_values="urn:acr:high urn:acr:mfa", max_age="300"`
w.Header().Set("WWW-Authenticate", challenge)
w.WriteHeader(http.StatusUnauthorized)空の realm、空の acrValues スライス、nil の maxAge はそれぞれ省略され、必須の error="insufficient_user_authentication" は常に含まれます。acrValues は acr_values リクエストパラメータと同じく、スペース区切りの引用符付き 1 文字列にエンコードされます。
ワンタイムの認証要素は単回使用
メール OTP(op.StepEmailOTP)、TOTP(op.StepTOTP)、recovery code(op.StepRecoveryCode)は、並行処理のもとでも単回使用です。同じコードが 2 度受理されることはありません。store は atomic な compare-and-set でこれを強制し、再提示には ErrAlreadyConsumed を返します。同じコードを提示した 2 つの競合リクエストが、両方とも成功することはありません。認証要素ストアを自前で実装する場合は、これらの消費操作を CAS にする必要があります(in-memory のリファレンス実装が形を示しています)。
終端の認証要素の失敗は、500 ではなく HTTP 400 で返ります。期限切れまたは消費済みのワンタイムコード、ロックアウト、必須のリセット、再送回数の超過、すでに確定した subject と別の subject を指す結果、ユーザ検証なしで完了した AAL3 の認証要素が該当します。サーバ障害ではなく送信内容に起因する状態であり、ユーザは新しい試行を始められます。
ユーザが登録していない第 2 要素も HTTP 400 で終わります。TOTP のレコードがない、または登録が未確認の場合、recovery code がない場合、メール OTP に紐づくメールアドレスがない場合が該当します。OP はステップを飛ばさないので、全ユーザに適用するルールを置くなら、全ユーザが登録済みである必要があります。
recovery code の prompt の AttemptsRemaining は、失敗した提出をあと何回許容するかを表す残り回数です。未使用の recovery code の数ではないため、ドライバはこの値から残りのコード数を推測しないでください。
監査記録
認証要素の結果は、audit.* カタログのイベントとして発行されます。primary の認証要素には audit.LoginSuccess と audit.LoginFailed、追加の認証要素(再利用されたセッション上のステップアップを含む)には audit.MFASuccess と audit.MFAFailed が使われます。ロックアウトやリセット必須の状態を引き起こした推測は失敗として記録されます。ロックされたままの間の拒否は、ログイン失敗には数えられません。認証要素をまたぐロックアウトカウンタの競合で、失敗した試行がカウントされずに中断された場合は audit.LockoutStalled です。
パスワードの失敗も、TOTP、メール OTP、recovery code と同じ subject 単位のカウンタに数えられます(op.WithAuthnLockoutStore を参照)。subject がロックされると、正しいパスワードでも拒否され、その応答は誤ったパスワードの応答と同じです。応答からアカウントの存在は分かりません。運用者向けの合図は audit.LockoutEngaged で、失敗した試行によって新たにロックが成立したとき、subject を ActorID として 1 回だけ発行されます。
audit.MFARequired、audit.StepUpRequired、audit.StepUpSuccess は予約されたカタログ名で、OP は発行しません。mfa.* イベントは、再試行を二重に数えることなく同じ情報を伝えます。
各イベントは解決した factor(password、totp、passkey など)を extras に載せ、op.WithAuditLogger(*slog.Logger)経由で流れます。発行は request goroutine 上で同期的に行われるため、ハンドラはブロックせず、並行呼び出しに安全にしてください。
同梱のステップ
ライブラリは一般的な認証要素向けに、すぐ使えるステップを同梱しています。
| ステップ | 検証対象 | ストア型(Store フィールド) |
|---|---|---|
op.PrimaryPassword | ユーザ名 / email + パスワード | store.UserPasswordStore |
op.PrimaryPasskey | WebAuthn / パスキーを primary の認証要素として | store.PasskeyStore |
op.StepTOTP | RFC 6238 TOTP、AES-256-GCM 静止時暗号化 | store.TOTPStore |
op.StepEmailOTP | メール配信 one-time code | store.EmailOTPStore |
op.StepRecoveryCode | 単発 recovery code | store.RecoveryStore |
op.StepCaptcha | hCaptcha / Turnstile / 自前 verifier | n/a(Verifier) |
同梱アダプタは、これらを具象の *Store 型から返します(st.UserPasswords()、st.Passkeys()、st.TOTPs()、st.EmailOTPs()、st.RecoveryCodes())。store.Store interface のメソッドではありません。
各ステップのストレージは組み込み側の責任です。ライブラリはユーザレコードもパスワードハッシュも所有しません。リファレンスの inmem アダプタは、例とテストには十分です。本番では、既存のユーザテーブルに合わせて op/store/* のサブストアを実装してください。
組み込みのステップがない認証要素は、op.Authenticator を実装して、ドット形式の KindLabel を付けた op.ExternalStep で包みます。手順は カスタム認証器 にあります。
アカウント管理の画面では、credential record を自作せず companion package を使います。op/passkeykit はログイン時の PrimaryPasskey と同じ設定から WebAuthn の登録 ceremony を実行します。op/recoverykit は recovery code batch を作成・置換します。Result.Codes は 1 回だけ表示し、保存もログ出力もしません。メール OTP は、LoginFlow の中で op.StepEmailOTP として設定します。28-email-otp-recovery と 29-passkey を参照してください。
passkey には IP ではなくドメインが要る
WebAuthn の Relying Party ID はドメイン名でなければならず、ブラウザは IP リテラルを RP ID として受け付けません。そのため http://127.0.0.1 で開発している OP には、対にできる RP ID が存在しません。issuer を localhost にした上で op.WithAllowLocalhostIssuerForDev() を指定してください。このオプションは issuer に文字列の localhost を許可します。redirect URI の localhost には別途 op.WithAllowLocalhostRedirectURIs() が必要です。緩和が許されるのは開発マシンの中だけで、本番の RP は https の後ろに置きます。
op.PrimaryPasskey に AAGUIDAllowlist を設定すると、登録 ceremony は direct の attestation conveyance に切り替わります。attestation のない AAGUID は自己申告にすぎず、ソフトウェア authenticator が承認済みのモデルを名乗るだけで登録できてしまうためです。そのため allowlist には AttestationRoots(attestation 証明書チェーンのトラストアンカー。OP は FIDO MDS を取得しません)が必須で、roots のない allowlist は op.New が拒否します。ユーザエージェントの attestation プロンプトが出ることを想定してください。チェーンを検証できない登録は、allowlist と照合されずに拒否されます。プロンプトの出ない ceremony を保つには allowlist を空のままにします。
RequireUserVerification: true は passkey の登録とログインの両方に適用されます。PIN や biometric などの user-verification gesture が必要で、presence-only の登録や assertion は拒否されます。user verification は保存済み credential の値ではなく、assertion ごとのフラグから読み取ります。この設定がない場合、presence-only の assertion は 1 要素として扱われ、amr は swk、AAL1 で、AAL2 には届きません。登録時の credential がログインでも使えるように、op/passkeykit に渡す設定と op.PrimaryPasskey を共有してください。
TOTP の登録(enrolment)
op.StepTOTP は組み込み側がすでに永続化済みの store.TOTPRecord に対してコードを検証します。これと対になる登録経路は op/totpkit パッケージにあります。秘密鍵の生成、QR コードとして描画される otpauth:// プロビジョニング URI、登録を確定する所有証明(proof-of-possession) ステップを所有しています。
import (
"github.com/libraz/go-oidc-provider/op/totpkit"
)
// 起動時に codec を 1 つだけ作り、同じ key bytes を
// op.StepTOTP{EncryptionKey: keys.TOTPKey} と共有します。
// 検証 / 登録の両側が同じ AES-256-GCM blob 形を produce / consume します。
codec, err := totpkit.NewCodec(keys.TOTPKey /*, previousKey, ... */)
// 1. primary 認証成功後、登録を開始。
pending, err := totpkit.NewEnrolment(codec,
user.Subject, // OP 内部の安定 user ID (AAD としてバインド)
"Example Identity", // authenticator app に表示される issuer ラベル
user.Email, // issuer の下に表示される account ラベル
)
// pending.OTPAuthURI — HTML で QR コードとして描画
// pending.SecretBase32 — 「手入力 (manual entry)」UX 用の表示
// pending.Record — 封緘済 TOTPRecord、まだ永続化してはいけない
// 2. `pending` を短命な登録セッション (server-side row、cookie で参照)
// に置き、QR コードと手入力用 secret をユーザに表示します。
// 3. ユーザが authenticator app の表示するコードを入力。
record, err := totpkit.Confirm(codec, pending, submittedCode, time.Now())
// totpkit.ErrCodeRejected の場合、`pending` は不変のままなので
// フォームを再描画してユーザに retry させます。codec の保持期間を
// 越えて鍵がローテーションされている場合は ErrDecrypt が発火します。
// 4. 確定済 record を永続化。この瞬間から op.StepTOTP は同じ
// secret に対するコードを受理します。
_ = storage.TOTPs().Put(ctx, record)totpkit は意図的に HTTP 面に出てきません。HTML、QR 描画、登録セッションは組み込み側が所有します。NewEnrolment と Confirm のいずれも、subject を GCM の AAD (additional-authenticated-data) としてバインドします。あるユーザの登録 row を抜き出しても、別の subject では replay できません。GCM タグ検証が AAD 不一致で拒否します。検証経路も同じ AAD 形を使うので、両端で同じバインディングが効きます。
デモ / CLI 用途の登録(端末向け QR 描画、確定済 seed record の生成) には examples/internal/seedkit を参照してください。//go:build example タグで隔離されているため、QR 描画ライブラリがホストモジュールの go.sum に入りません。
ソース:
examples/23-step-up— in-process OP+RP デモ。登録から RFC 9470 ACR step-up までを通しで実行します。