Feature | UserController MFA Integration, Device Trust Cookie Management, Audit Wiring, and 2FA Rate Limiting#136
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 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-136/ This page is automatically updated on each push to this PR. |
77c0c4f to
0fb9adf
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-136/ This page is automatically updated on each push to this PR. |
0fb9adf to
84252f7
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-136/ This page is automatically updated on each push to this PR. |
… Audit Wiring, and 2FA Rate Limiting
84252f7 to
56e1468
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-136/ This page is automatically updated on each push to this PR. |
caseylocker
left a comment
There was a problem hiding this comment.
Reviewed against ClickUp 86ba2zc6p and SDS idp-mfa.md (§4.4 controller flow, §4.5 strategies, §4.9.1 AuthService MFA methods).
The transaction-context design rule is correctly implemented — the controller orchestrates only, all strategy DB-mutating calls go through AuthService::*MFA* wrappers, and no add(entity, true) self-flush remains. Every acceptance criterion is met on the happy path, and verifyChallenge now correctly loads the persisted OTP and compares its value.
However, five issues should be addressed before merge. All five are consequences of work first made live in this PR (the MFA strategies and loginUser() were previously unwired/dead code), so they are in scope here rather than the base PR. The first is a critical regression in the shared password-login path. Inline comments below.
Also, a few test gaps versus the ticket's TESTS list: concurrent OTP redeem, concurrent recovery-code redeem, client-bound MFA verification, recovery rate-limit blocking, and rollback/partial-failure after a successful redeem.
| } | ||
|
|
||
| $otp->redeem(); | ||
| $this->otp_repository->add($otp, false); |
There was a problem hiding this comment.
OTP redeem lacks the pessimistic row lock (TOCTOU double-redeem).
verifyChallenge fetches via getByValueConnectionAndUserName (plain SELECT) then redeem() at :62, without acquiring IOAuth2OTPRepository::refreshExclusiveLock() (DoctrineOAuth2OTPRepository.php:142). With the transaction service defaulting to READ COMMITTED (DoctrineTransactionService.php:133), two concurrent submissions of the same valid code can both read the unredeemed row and both succeed.
SDS §4.5 and the ticket gotcha explicitly say to prefer delegating to AuthService::verifyOTPChallenge() (tx-managed, value-checked, row-locked, session-bound) rather than reimplementing redemption. Either route through verifyOTPChallenge() or call refreshExclusiveLock() and re-check redemption state before redeem().
There was a problem hiding this comment.
@matiasperrone-exo remove all the repository->add from everywhere this is an antipattern
| string $value | ||
| ): void { | ||
| $this->tx_service->transaction(function () use ($user, $strategy, $value) { | ||
| $strategy->verifyChallenge($user, $value); |
There was a problem hiding this comment.
MFA OTP is issued with a client but verified with client = null.
The challenge is issued with the resolved client (UserController.php:480), but verifyMFAChallenge calls verifyChallenge($user, $value) with no client, so the lookup skips client scoping (DoctrineOAuth2OTPRepository.php:130). More concretely, the sibling-revocation in the strategy (EmailOTPMFAChallengeStrategy.php:65) calls getByUserNameNotRedeemed($email) without a client, so verifying an MFA OTP redeems all of the user's pending email OTPs — including unrelated passwordless-login OTPs for other clients.
Fix: carry the client id in the pending MFA state and pass the same client into verification (and into the sibling-revoke query), or delegate to verifyOTPChallenge($claim, $user, $client).
| foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) { | ||
| if (Hash::check($code, $recoveryCode->getCodeHash())) { | ||
| $recoveryCode->markUsed(); | ||
| $this->recovery_code_repository->add($recoveryCode, false); |
There was a problem hiding this comment.
Recovery-code redemption has a double-spend race.
verifyRecoveryCode iterates getUnusedByUser (:51), Hash::checks, then markUsed() + add(false) with no pessimistic lock or conditional update, under READ COMMITTED (DoctrineTransactionService.php:133). Two concurrent requests can both observe the same code unused and both succeed.
Fix: lock the user's unused recovery rows before hash-checking, or redeem via an atomic UPDATE ... WHERE id = ? AND used_at IS NULL after identifying the match.
There was a problem hiding this comment.
@matiasperrone-exo this is an anti pattern , remove it
|
|
||
| $strategy->clearPendingState(); | ||
|
|
||
| $this->two_factor_audit_service->log( |
There was a problem hiding this comment.
Post-verification side effects can 500 after the OTP is already burned.
By this point the OTP redeem has committed (verifyMFAChallenge, :670) and the session is established (loginUser, :686). queueDeviceTrustCookie (:689, its own tx in DeviceTrustService) and this audit log() (its own tx in TwoFactorAuditService) run afterward; if either throws, the outer catch (Exception) returns error500 even though the user is effectively logged in and the single-use OTP is spent — on retry they are locked out. If trustDevice throws, pending state is not cleared either.
Fix: make audit best-effort/non-blocking, and/or order durable side effects before loginUser(), keeping device-trust persistence consistent with the redeem boundary. Ticket Task 5 asks for exactly this decision ("redeem and device-trust persistence are atomic; audit may be best-effort").
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-136/ This page is automatically updated on each push to this PR. |
| } | ||
|
|
||
| // Scope verification to the client the challenge was issued for. | ||
| $client = $this->resolveClientFromPendingState($pending); |
There was a problem hiding this comment.
Review: client resolution in verify2FA duplicates state the memento already owns
resolveClientFromPendingState (app/Http/Controllers/UserController.php:642) should be replaced with the existing resolveClientFromMemento() helper. Findings:
1. Duplicated source of truth
The pending-state client_id is a session copy of the client that resolveClientFromMemento() already resolved at challenge-issue time:
postLogin (UserController.php:480) → resolveClientFromMemento()
→ issueMFAChallenge → EmailOTPMFAChallengeStrategy::issueChallenge (line 25)
→ storePendingState(..., $client?->getClientId()) // copies into 2fa_pending_client_id
The OAuth2 memento survives the challenge→verify round-trip — resolveClientFromMemento re-serializes after load() (UserController.php:628) and no session regeneration happens until loginUser runs after verification. The copy adds a second source of truth for a fact the session already holds canonically.
2. Internally inconsistent within this PR
verify2FAresolves via pending state (UserController.php:688)resend2FAresolves viaresolveClientFromMemento()(UserController.php:853)
Two endpoints of the same MFA lifecycle disagree on where the client comes from. Worse, resend's path re-runs issueChallenge → storePendingState, silently overwriting the pending-state client with the memento client — so the "snapshot the client at issue time" semantic the comment at UserController.php:634-637 claims only holds until the first resend.
Concrete drift scenario (two tabs): challenge issued for client A; user starts an authorization request for client B in another tab (memento now = B). Verify succeeds scoped to A, then login_strategy->postLogin() (UserController.php:742) resumes the memento flow — client B. The OTP verification scope and the flow that actually completes disagree. Memento-based resolution makes verify, resend, and post-login continuation agree by construction.
3. Silent degradation on stale client
resolveClientFromMemento throws ValidationException("client does not exists") when the client can't be resolved (UserController.php:626); resolveClientFromPendingState returns null (UserController.php:648) — and a null client makes the OTP lookup unscoped (DoctrineOAuth2OTPRepository.php:131 skips the client filter). A deleted/stale client id degrades silently to a wider lookup instead of failing loudly.
Recommended fix
Swap UserController.php:688 to $client = $this->resolveClientFromMemento();. That makes all of this removable:
resolveClientFromPendingState(UserController.php:642-649)KEY_CLIENT_ID+ the?string $clientIdparam onstorePendingState+client_idingetPendingState/clearPendingState(AbstractMFAChallengeStrategy.php:16,39,48,72-81)- The scoping comments at
EmailOTPMFAChallengeStrategy.php:23-24andUserController.php:633-637
Safety of the swap is already proven in-PR: resend2FA calls resolveClientFromMemento() mid-MFA and the memento survives for postLogin because the helper re-serializes it. user_id and remember legitimately stay in pending state — they have no memento equivalent. The existing AbstractMFAChallengeStrategyTest already calls storePendingState($userId, $remember) without a client id, so the signature change is test-compatible.
smarcet
left a comment
There was a problem hiding this comment.
@matiasperrone-exo review
| if (!$user->canLogin()) | ||
| throw new AuthenticationException("User is not active or cannot login."); | ||
|
|
||
| $this->principal_service->clear(); |
There was a problem hiding this comment.
Minor / non-blocking — pre-existing dead code surfaced while mirroring login().
Now that loginUser() mirrors login()'s post-auth principal registration: do not also copy $this->last_login_error = ""; (login(), AuthService.php:396) into the new methods — that line is dead code:
- It is the only reference to
last_login_errorin the entire codebase: no property declaration, no getter, no read anywhere (checkedIAuthService, controllers, views). - Because the property is never declared, the assignment creates a dynamic property — deprecated since PHP 8.2, so it emits a deprecation notice on every password login under the project's PHP 8.3 runtime.
Since this PR already reworks the login path, deleting that one line in login() is a fair drive-by cleanup here; otherwise it can go in a follow-up. Not blocking for this PR.
There was a problem hiding this comment.
Same category, same file: the import use Auth\Exceptions\UnverifiedEmailMemberException; (AuthService.php:22) is also unused — it is the only reference to that symbol in the whole file (no code reference, no docblock reference). Safe to drop alongside the last_login_error line.
To be clear: only the import is dead. The exception class itself is alive — thrown by CustomAuthProvider.php:132 and caught in UserController.php:562 — so don't remove the class, just this import.
Task:
Ref: https://app.clickup.com/t/9014802374/86ba2zc6p
What Changed
UserController(app/Http/Controllers/UserController.php)IDeviceTrustService,ITwoFactorAuditService, andITwoFactorGateServicevia constructor.MFAGateService::requiresChallenge()returnstrue, issues a challenge (viaAuthService::issueMFAChallenge) and returns{ error_code: "mfa_required", ... }— no session created yet.resolveClientFromMemento()private helper to extract the OAuth2 client from a pending session memento (deduplicates logic previously repeated in password and OTP flows).postVerify2FA(),postResend2FA(), andpostRecovery2FA()action methods to handle the full MFA step-up login lifecycle.MFACookieManagertrait for reading/writing the trusted-device cookie.MFACookieManagertrait (app/Http/Controllers/Traits/MFACookieManager.php) — new filegetCookieToken(): ?string(reads raw token from cookie).queueDeviceTrustCookie(User $user): void(callsIDeviceTrustService::trustDeviceand queues a secure, HttpOnly cookie).TwoFactorRateLimitMiddleware(app/Http/Middleware/TwoFactorRateLimitMiddleware.php) — new fileverify,recovery, andresendMFA endpoints.verify/recovery: increments only on failed response (mfa_verification_failed,mfa_invalid_recovery).resend: increments on every request.HTTP 429witherror_code: mfa_rate_limitwhen the limit is exceeded.app/Http/Kernel.phpand wired to the new routes inroutes/web.php.AuthService(app/libs/Auth/AuthService.php) andIAuthService(app/libs/Utils/Services/IAuthService.php)validateCredentials(string $username, string $password): User— validates without creating a session.loginUser(User $user, bool $remember): void— establishes the session for an already-validated user.issueMFAChallenge(User $user, MFAChallengeStrategy $strategy, ?Client $client, bool $remember): array— triggers the challenge strategy and stores pending state.DeviceTrustServiceandTwoFactorAuditService(minor fixes)config/two_factor.php— new filecookie_name,device_trust_lifetime_days, rate-limit thresholds per action.EncryptCookiesmiddlewaredevice_trust_tokencookie from encryption (raw HMAC token must be read as-is byIDeviceTrustService).Tests
tests/TwoFactorLoginFlowTest.php(new)tests/DeviceTrustServiceTest.phptests/Unit/TwoFactorAuditServiceTest.phptests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.phpissueMFAChallengepath.phpunit.xml