Add RFC 6238 TOTP enrollment, two-step /login flow, recovery codes, and
the amr=["pwd","mfa"] claim that propagates through refresh-token rotation.
- New endpoints: /users/me/mfa/{enroll,confirm,disable} and /login/mfa.
- /login short-circuits to a 5-min ES256 step-1 token (audience-pinned
azaion-mfa-step2) when the user has MFA enabled; real access+refresh
pair is minted only after /login/mfa.
- mfa_secret encrypted at rest via ASP.NET Core IDataProtector
(purpose=Azaion.Mfa.Secret.v1; key folder configurable via
DataProtection:KeysFolder for production persistence).
- Recovery codes (10 single-use, base32, ~80-bit entropy) hashed with
SHA-256 and stored as JSONB; constant-time compare on lookup.
- RFC 6238 §5.2 replay defense via mfa_last_used_window per user.
- Sessions carry mfa_authenticated so /token/refresh re-stamps the
amr claim correctly across the entire 30-day refresh window.
- New audit events: enroll, confirm, disable, login-success/failed,
recovery-used.
- Schema: env/db/10_users_mfa.sql adds users.mfa_* columns and
sessions.mfa_authenticated; mfa_recovery_codes mapped as BinaryJson
in AzaionDbSchemaHolder; disable path uses raw parameterised SQL to
avoid LinqToDB null-literal type-inference on jsonb columns.
E2E: 6 new tests in MfaLoginTests cover all six AC; full suite
82 passed / 0 failed / 3 intentional skips.
Co-authored-by: Cursor <cursoragent@cursor.com>
8.5 KiB
Batch Report
Batch: 4 (cycle 2) Tasks: AZ-534 (totp_2fa_login) Date: 2026-05-14 Total Complexity: 5 points Epic: AZ-529 — Auth Mechanism Modernization
Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|---|---|---|---|---|---|
| AZ-534 | Done | 9 source + 1 sql migration + 1 test | 6/6 pass | 6/6 | None blocking — see review |
Files Touched
Source (production)
Azaion.AdminApi/Program.cs— DI forIMfaService; configure ASP.NET Core DataProtection (with optionalDataProtection:KeysFolderfor production persistence);/loginshort-circuits to step-1 token whenuser.MfaEnabled; new/login/mfaendpoint; new/users/me/mfa/{enroll,confirm,disable}endpoints;IssueDualTokenshelper centralises access+refresh minting;/token/refreshpropagatesamrfrom the persistedMfaAuthenticatedflagAzaion.AdminApi/BusinessExceptionHandler.cs— mapMfaAlreadyEnabled/MfaNotEnrolling/MfaNotEnabled→ 409,InvalidMfaCode/InvalidMfaToken→ 401Azaion.Common/BusinessException.cs— addMfaAlreadyEnabled = 56,MfaNotEnrolling = 57,MfaNotEnabled = 58,InvalidMfaCode = 59,InvalidMfaToken = 61Azaion.Common/Database/AzaionDbShemaHolder.cs—User.MfaRecoveryCodesmapped toDataType.BinaryJsonso Npgsql sends the JSONB type oid on insert/updateAzaion.Common/Entities/User.cs— addMfaEnabled,MfaSecret,MfaRecoveryCodes,MfaEnrolledAt,MfaLastUsedWindow; sensitive fields[JsonIgnore]Azaion.Common/Entities/Session.cs— addMfaAuthenticated(preserves AMR strength across refresh rotations)Azaion.Common/Entities/AuditEvent.cs— new event type strings:MfaEnroll,MfaConfirm,MfaDisable,MfaLoginSuccess,MfaLoginFailed,MfaRecoveryUsedAzaion.Common/Requests/MfaRequests.cs— new;MfaEnrollRequest/Response,MfaConfirmRequest,MfaDisableRequest,MfaRequiredResponse,MfaLoginRequestAzaion.Services/AuthService.cs—CreateTokenaccepts optionalamrcollection; values stamped as repeatedamrclaims per RFC 8176Azaion.Services/AuditLog.cs— newRecordMfa…helpersAzaion.Services/MfaService.cs— new; TOTP enrol / confirm / disable / verify-for-login; ES256 step-1 token (5-min, audience-pinnedazaion-mfa-step2); single-use recovery codes (SHA-256 hashed, JSONB-stored); RFC 6238 replay defence viaMfaLastUsedWindow;IDataProtectorencryptsmfa_secretat restAzaion.Services/RefreshTokenService.cs—IssueForNewLoginacceptsmfaAuthenticated;Rotatecarries the flag forward to the new session row
Migrations / infra
env/db/10_users_mfa.sql— new; ALTER TABLE addsmfa_enabled(default false),mfa_secret(text),mfa_recovery_codes(jsonb),mfa_enrolled_at(timestamp),mfa_last_used_window(bigint);sessions.mfa_authenticated(default false)e2e/db-init/00_run_all.sh— apply 10_users_mfa.sql in test DBe2e/Azaion.E2E/Azaion.E2E.csproj— addOtp.NETpackage (test-side TOTP code generation)
Tests
e2e/Azaion.E2E/Tests/MfaLoginTests.cs— new; 6 tests (enrol payload shape, confirm activates, two-step login + amr, recovery single-use, disable round-trip, ciphertext-at-rest)e2e/Azaion.E2E/Helpers/DbHelper.cs— addGetMfaSecretRaw,GetMfaEnabled
Test Run Results
Batch 4 only (--filter MfaLoginTests): 6 / 6 passed, ~14 s.
Full suite: 82 passed, 0 failed, 3 skipped, ~77 s.
The PasswordHashingTests.AC5_Verify_uses_constant_time_comparator_no_obvious_timing_leak flake noted in batch 3 review passed cleanly in this run, confirming it as an environmental flake rather than a regression.
AC Coverage
- AC-1: Enrol returns base32
secret(32 chars),otpauth://URL, base64 PNG QR, 10 recovery codes ≥12 chars; DB stillmfa_enabled=false—AC1_Enroll_returns_secret_otpauth_qr_and_recovery_codes - AC-2: Confirm with valid TOTP flips
mfa_enabled=true—AC2_Confirm_enables_MFA - AC-3:
/loginreturns{mfa_required, mfa_token, expires_in:300}then/login/mfareturns access+refresh withamr=["pwd","mfa"]—AC3_Login_returns_mfa_required_then_step2_returns_tokens_with_amr_pwd_mfa - AC-4: Recovery code works once (yields
amr=["pwd","mfa","recovery"]); reuse rejected —AC4_Recovery_code_works_once_then_fails - AC-5:
/users/me/mfa/disablerequires password + valid TOTP; subsequent/loginreturns access+refresh directly without step 2 —AC5_Disable_requires_password_and_code_then_login_returns_tokens_directly - AC-6:
users.mfa_secretread directly from Postgres is ciphertext (DataProtection envelope), not the base32 secret —AC6_Mfa_secret_is_encrypted_at_rest
Key Implementation Decisions
-
IDataProtectorformfa_secret, not a hand-rolled AES wrapper. ASP.NET Core's DataProtection handles key generation, automatic 90-day rotation, and a versioned envelope format that survives key rolls without re-encrypting all rows. Custom AES-GCM would have given the same security guarantee but with three new test vectors and a manual rotation runbook.Purpose = "Azaion.Mfa.Secret.v1"namespaces the keys so an accidental cross-purpose decrypt fails. Key persistence is opt-in viaDataProtection:KeysFolder— production deployments MUST set it (Program.cs comment is explicit), or restarts invalidate every enrolled secret. -
SHA-256 for recovery code hashing, not Argon2id. Recovery codes are 16-character base32 strings (~80 bits of entropy from
KeyGeneration.GenerateRandomKey(10)). Argon2id at the calibrated~250 mscost would add 2.5 s to every wrong-code attempt (we walk all unused codes). High-entropy secrets need a fast hash, not a slow KDF — the same reasoning the refresh-token store uses. Constant-time compare viaCryptographicOperations.FixedTimeEqualsdefends against timing oracles on the hash bytes. -
mfa_authenticatedpersisted on the session row, not re-derived from the access token. Refresh-token rotation produces a brand-new access token; we'd otherwise have no source of truth for "was this session born of MFA?" once the original access token expires. Storing the boolean on the session lets/token/refreshre-stampamr=["pwd","mfa"]correctly across the entire 30-day refresh window. Costs one boolean column. -
Step-1 MFA token is ES256, audience-pinned
azaion-mfa-step2. Re-uses the JWKS keypair so verifiers don't need to learn a second key. The narrow audience makes the mainJwtBearermiddleware reject this token for normal endpoints, andMfaService.ValidateMfaStepTokenrejects any other audience — so a step-1 token cannot be presented at/users/me, and an access token cannot be presented at/login/mfa. -
VerifyTotpCodecheckslastUsedWindow > matchedWindowfirst. RFC 6238 §5.2 says "the verifier MUST reject any code that was already used in the current or previous window".OtpNet.VerificationWindow.RfcSpecifiedNetworkDelayaccepts the prior + current + next 30-second window. Without the per-usermfa_last_used_windowcheck, a man-in-the-middle who captured the code mid-flight could replay it within the 30-90 s acceptance window. Persisting the matched window is one extraUPDATE usersper successful login. -
Disable uses raw SQL parameter for the JSONB null. LinqToDB's
UpdateAsynclambda compilesMfaRecoveryCodes = nullinto an untypedNULLliteral which Postgres parses astextand rejects against thejsonbcolumn (42804). TheBinaryJsonmapping handles non-null values fine, but null literals in expression bodies bypass parameter typing. Switched the disable path to a single parameterisedUPDATE … SET mfa_recovery_codes = NULL::jsonb …. Local fix, doesn't affect the enrol/confirm/login paths.
Backward Compatibility
- All new
userscolumns default to MFA-off (mfa_enabled=false, others NULL). Existing rows untouched. - Pre-existing
sessionsrows defaultmfa_authenticated=false;/token/refreshagainst an old session continues to issueamr=["pwd"]— same behaviour as before. /loginresponse shape is unchanged for users without MFA enabled — no client-visible change for the existing CompanionPC fleet or any non-enrolled admin.LoginResponseandLoginRequestDTOs unchanged. The MFA branch returns a different DTO (MfaRequiredResponse); clients that don't recognise themfaRequiredfield will see an unexpected payload — UI workspace ticket flagged in the spec under "Risks / Notes".