fix: MFA-enforced accounts leak enumeration via passwordless login guard order - #154
Conversation
…ard order Root cause: UserController::postLogin()'s shouldRequire2FA() guard ran before loginWithOTP() validated the submitted OTP, so a caller without the real code could distinguish MFA-enforced accounts from the rejection message alone - an account-enumeration oracle requiring no credentials. Fix: introduce AuthService::loginWithOTPEnforcing2FA(), which checks shouldRequire2FA() after the OTP is proven valid and before finalizeRedemption()/Auth::login() - so a guessed/invalid code is rejected generically before reaching the account-status branch, and a valid code against an enforced account is rejected before any login side effect (redemption, Auth::login, the Login event / queued PostLoginUser job) fires. loginWithOTP() (used by InteractiveGrantType and TokenService's OAuth2 grants) is unchanged - only UserController::postLogin()'s interactive web login now enforces 2FA at this layer. Avoids a boolean flag parameter (flags-over-objects antipattern) by exposing two explicitly-named public methods delegating to a shared, flag-free resolveOTPUser() helper. Adds a regression test proving an invalid OTP against an MFA-enforced account gets the same generic rejection as any other invalid code.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughPasswordless login now uses ChangesPasswordless OTP MFA enforcement
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant UserController
participant AuthService
participant OAuth2OTP
participant User
UserController->>AuthService: invoke loginWithOTPEnforcing2FA
AuthService->>OAuth2OTP: validate OTP claim
AuthService->>User: resolve and validate OTP user
AuthService-->>UserController: reject MFA-required user or authenticate user
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-154/ This page is automatically updated on each push to this PR. |
…t unworded testInvalidOtpAgainstEnforcedUserDoesNotLeakMFAStatus only asserted that the flash message lacked the phrase "two-factor authentication". Enumeration is about distinguishability, so that assertion still passed if a future enforced-only branch leaked a differently worded message. It now drives the same invalid code through the same endpoint for the MFA-enforced admin and for a non-enforced control user, and asserts the two rejections are byte-identical. The stale flash is cleared between requests so the comparison cannot read a value against itself. Verified by mutation: restoring the pre-fix guard order with reworded text fails the new assertSame and would have passed the old substring assertion. Also records why testEnforcedUserCannotBypassMFAViaPasswordlessLogin adds no separate "no login side effect" assertions. Both candidates were tried and removed as change detectors that cannot fail: the DB-visible effects (OTP redemption, sibling revocation) are rolled back by the AuthService transaction regardless of where the guard sits, and the session-visible one (Auth::login() and the Login event queuing PostLoginUser) is already caught first by the existing Auth::check() assertion. Both confirmed by mutation. Full file green: OK (58 tests, 380 assertions).
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-154/ This page is automatically updated on each push to this PR. |
Root cause
UserController::postLogin()'sshouldRequire2FA()guard ran beforeloginWithOTP()validated the submitted OTP. A caller submitting any garbage/guessed code against an MFA-enforced account's email got the distinguishing "requires password and two-factor authentication" message without ever proving control of the inbox — an account-enumeration oracle usable with zero credentials.Fix
Introduces
AuthService::loginWithOTPEnforcing2FA(). TheshouldRequire2FA()check now runs after the OTP is proven valid and beforefinalizeRedemption()/Auth::login():Auth::login(), theLoginevent, the queuedPostLoginUserjob) — avoids the account-state mutation (last_login_date,resetLoginFailedAttempts()) that a naive reordering would have introduced.loginWithOTP()(used byInteractiveGrantTypeandTokenService's OAuth2 grants) is unchanged — onlyUserController::postLogin()'s interactive web login enforces 2FA at this layer, matching the reported scope.Avoids a boolean flag parameter (flags-over-objects antipattern — see DevIQ and Ardalis) by exposing two explicitly-named public methods delegating to a shared, flag-free
resolveOTPUser()helper.Testing
testInvalidOtpAgainstEnforcedUserDoesNotLeakMFAStatus, verified RED (fails against the pre-fix code with the exact leaking message) → GREEN.TwoFactorLoginFlowTest.php, isolated run) green:OK (4 tests, 12 assertions).Scope note
Not included:
removeTrustedDevices()never being called (finding #2) and the OTP brute-force counter depending solely on the cache-backed rate limiter (finding #3) — separate findings from the same review, not addressed here.Summary by CodeRabbit
Bug Fixes
Tests