Feature | MFA Challenge Strategy Pattern (Interface, Abstract, Factory, EmailOTP)#129
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 |
7e9c06b to
82e8fbc
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ 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 86b9j5jup (this task) plus the linked parent 86b8yfdxd phase decomposition (86b9e8wm7 + 86b9j51aa).
The implementation matches the spec — all 6 task items, all 10 ACs, tests green (15/15, 31 assertions). Approving in the context of the phased rollout: this PR is deliberately scoped to the strategy layer with controller wiring deferred to a separate ticket.
A second-pass review surfaced a handful of items that fall between this PR's scope and the (not-yet-created) controller-wiring task. Flagging them here so they don't slip:
-
Cross-client audience check on `verifyChallenge` — the strategy's interface takes `(User, string)` per spec. `AuthService::loginWithOTP:230,273` passes `$client` and rejects on audience mismatch. The controller will need to either pre-check audience or we extend the strategy interface in Phase II.
-
Transaction boundaries — `AuthService::loginWithOTP` uses two separate `tx_service->transaction()` blocks so `logRedeemAttempt` commits before validation can throw. Strategy has none. The controller will need to replicate that two-tx shape.
-
`2fa_recovery_attempts` session key is declared in the abstract (`AbstractMFAChallengeStrategy.php:15`) but never written. Either the controller wires it as throttling, or we drop the constant.
-
Audit logging via `two_factor_audit_log` (introduced in 86b9e8wm7) needs IP / user-agent which the strategy can't see. Confirms this is structurally a controller-layer concern.
-
Minor: `issueChallenge` stores pending session state before OTP creation. If `createOTPFromPayload` throws, session is left dirty. Worth either reordering or having the controller wrap.
@smarcet — happy to file the controller-wiring ticket with these as explicit ACs if that helps. Approving this PR on the assumption that's where they'll be addressed.
|
@caseylocker |
smarcet
left a comment
There was a problem hiding this comment.
@matiasperrone-exo please review
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
|
Hello @smarcet thank for the comments. Please review it at your earliest convenience. |
1f32700 to
95c2e9a
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
95c2e9a to
82e8fbc
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
2 similar comments
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
26d0da0 to
d9beb67
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
28541f5 to
f4fd63d
Compare
d9beb67 to
b1ef3fa
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
7e9fe41 to
a0c22ff
Compare
b1ef3fa to
2c99a62
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
|
Hello @smarcet thank for the comments. I added an extra change, the abstract class Here is the change:
Please review it at your earliest convenience. |
So a consumed recovery code cannot be reused on a subsequent request.
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/ This page is automatically updated on each push to this PR. |
smarcet
left a comment
There was a problem hiding this comment.
Reviewed the new MFA strategy classes against the SDS (idp-mfa.md v2.2) and the existing auth-layer conventions. Two issues, both rooted in transaction ownership — flagged inline.
-
AbstractMFAChallengeStrategy::verifyRecoveryCodeself-flushes (add(..., true)) from a layer that has no transaction boundary. Per the SDS the intended caller is the controller (UserController::verify2FARecovery, §4.11 + route/auth/login/2fa/recovery§4.13), and controllers here do not own DB transactions — services do (ITransactionService::transaction(), seeAuthService::loginWithOTP/finalizeRedemption, with staged TX-A/B/C and pessimistic row-locking). -
EmailOTPMFAChallengeStrategy::verifyChallengebuilds a transient OTP viaOAuth2OTP::fromParams()and never loads or compares the stored OTP, and never flushes itsredeem()— so it neither verifies the code nor persists the redemption.
Controller wiring is out of scope for this PR, so (1) is currently latent, but (2) is a real defect that must be fixed before wiring. Recommendation: the recovery-code and OTP-redemption writes belong in a transaction-managed service (the sibling of the existing AuthService::verifyOTPChallenge()), with the controller calling the service; strategies should not self-flush. Note the SDS recovery snippet (§4.5) also uses raw EntityManager::flush() and should be aligned with the tx_service pattern.
| foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) { | ||
| if (Hash::check($code, $recoveryCode->getCodeHash())) { | ||
| $recoveryCode->markUsed(); | ||
| $this->recovery_code_repository->add($recoveryCode, true); |
There was a problem hiding this comment.
add($recoveryCode, true) forces an immediate persist() + flush() (see DoctrineRepository::add). This strategy has no transaction boundary — its constructor injects only IUserRecoveryCodeRepository, no ITransactionService. Everywhere else in the auth layer, mutations run inside tx_service->transaction() and are flushed once at commit; no repository self-flushes with sync = true (cf. AuthService::finalizeRedemption in app/libs/Auth/AuthService.php).
Per the SDS this method is invoked from the controller (UserController::verify2FARecovery, §4.11 + route §4.13), and controllers here do not own transactions — services do. So a controller-invoked strategy self-flushing a write bypasses the service/transaction layer every other auth write goes through. It is also unsafe to compose inside an outer transaction (premature mid-tx flush) and is excluded from the row-locking / atomicity the OTP redemption path relies on.
Fix: move the mark-used write into a transaction-managed service method and use add(..., false). The SDS snippet here uses raw EntityManager::flush() and should be corrected too.
|
|
||
| public function verifyChallenge(User $user, string $code, ?Client $client = null): void | ||
| { | ||
| $otp = OAuth2OTP::fromParams($user->getEmail(), OAuth2Protocol::OAuth2PasswordlessConnectionEmail, $code); |
There was a problem hiding this comment.
This method does not actually verify the OTP. OAuth2OTP::fromParams() is a pure in-memory factory (new self(...), app/Models/OAuth2/OAuth2OTP.php:448) — it never reads the DB. The code never calls otp_repository->getByValueConnectionAndUserName(...) and never compares the submitted value against the stored OTP. Consequences:
is_null($otp)(L42) is dead code —fromParamsalways returns an object.isAlive()/isValid()(L48–53) run against a freshly-built object with default state, so they effectively always pass.$otp->redeem()(L56) and the sibling revoke mutate transient entities that are never persisted (notx_service, noadd, no flush).
Net effect if wired as-is: the submitted code is not validated against what was issued, and nothing is persisted. SDS §4.5 fetches the persisted OTP via the repository and does if ($otp->getValue() != $value) throw — both were dropped here. Recommend delegating to the existing AuthService::verifyOTPChallenge() (transaction-managed, value-checked, row-locked, session-user bound) instead of re-implementing redemption.

Task:
Ref: https://app.clickup.com/t/86b9j5jup
Depends on
Summary
Implements the MFA Challenge Strategy Pattern to provide a flexible, extensible framework for handling different multi-factor authentication methods. This PR introduces a
strategy-based architecture with an interface, abstract base class, factory pattern, and a complete Email OTP implementation.
Changes
New Components
1. Interface:
IMFAChallengeStrategy2. Abstract Base:
AbstractMFAChallengeStrategy3. Concrete Implementation:
EmailOTPMFAChallengeStrategy4. Factory:
MFAChallengeStrategyFactoryTest Coverage
AbstractMFAChallengeStrategyTest(143 lines)EmailOTPMFAChallengeStrategyTest(167 lines)MFAChallengeStrategyFactoryTest(23 lines)email_otpmethodConfiguration
phpunit.xmlto register the new test suite for MFA functionalityDesign Rationale
Related Issues
Migration Notes
Requested GOAL
Current state: No MFA challenge infrastructure exists. The login flow has no concept of multi-factor verification strategies.
Target state
A complete strategy pattern is in place: IMFAChallengeStrategy interface defines the contract, AbstractMFAChallengeStrategy provides shared session management and recovery code verification, MFAChallengeStrategyFactory resolves method names to strategy instances, and EmailOTPMFAChallengeStrategy wraps the existing OTP infrastructure for email-based 2FA challenges. This pattern is the extension point for Phase II (SMS) and Phase III (TOTP, passkey) without modifying the controller.
TASKS
The factory would just be:
ACCEPTANCE CRITERIA
DEVELOPMENT NOTES
Key files:
Gotchas:
Out of scope:
Controller wiring