Skip to content

Feature | MFA Challenge Strategy Pattern (Interface, Abstract, Factory, EmailOTP)#129

Open
matiasperrone-exo wants to merge 1 commit into
feat/mfa-phase1-authservice-validatecredentials-methodfrom
feat/mfa-challenge-strategy-pattern
Open

Feature | MFA Challenge Strategy Pattern (Interface, Abstract, Factory, EmailOTP)#129
matiasperrone-exo wants to merge 1 commit into
feat/mfa-phase1-authservice-validatecredentials-methodfrom
feat/mfa-challenge-strategy-pattern

Conversation

@matiasperrone-exo
Copy link
Copy Markdown
Contributor

@matiasperrone-exo matiasperrone-exo commented May 8, 2026

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: IMFAChallengeStrategy

 - Defines the contract for MFA challenge strategies
 - Methods:
   - `issueChallenge()` — initiates a challenge (sends OTP, etc.)
   - `verifyChallenge()` — validates the user's response
   - `resendChallenge()` — resends the challenge
   - `getPendingState()` — retrieves temporary challenge state
   - `clearPendingState()` — cleans up session data
   - `verifyRecoveryCode()` — validates recovery codes as fallback

2. Abstract Base: AbstractMFAChallengeStrategy

 - Provides common MFA logic shared across all strategies
 - Features:
   - **Session State Management**: Stores pending user ID and challenge timestamp with 5-minute TTL
   - **Recovery Code Verification**: Hash-based recovery code validation against stored codes
   - **Time-based Expiration**: Automatically clears expired challenge state
 - Dependencies: `IUserRecoveryCodeRepository`

3. Concrete Implementation: EmailOTPMFAChallengeStrategy

 - Implements email-based one-time password (OTP) challenges
 - Features:
   - **Challenge Issuance**: Creates OTP via `ITokenService` with email connection type
   - **Challenge Verification**: Validates OTP from repository with:
     - Existence check
     - Lifetime validation
     - Validity verification
   - **Automatic Cleanup**: Redeems other unused OTPs for the user after successful verification
   - **Resend Support**: Allows users to request new OTPs
 - Dependencies: `ITokenService`, `IOAuth2OTPRepository`

4. Factory: MFAChallengeStrategyFactory

 - Uses match expression for clean, type-safe strategy instantiation
 - Extensible design for adding new MFA methods (TOTP, SMS, etc.)
 - Throws `InvalidArgumentException` for unknown methods

Test Coverage

  • AbstractMFAChallengeStrategyTest (143 lines)

    • Session state lifecycle (store, retrieve, expire, clear)
    • Time-based expiration logic
    • Recovery code validation and error handling
  • EmailOTPMFAChallengeStrategyTest (167 lines)

    • Challenge issuance and OTP creation
    • Challenge verification (valid, expired, invalid codes)
    • Automatic OTP cleanup after verification
    • Resend functionality
    • Error cases and exception handling
  • MFAChallengeStrategyFactoryTest (23 lines)

    • Factory creation for email_otp method
    • Invalid method handling

Configuration

  • Updated phpunit.xml to register the new test suite for MFA functionality

Design Rationale

  • Strategy Pattern: Allows easy addition of new MFA methods (TOTP, SMS, WebAuthn) without modifying existing code
  • Separation of Concerns: Abstract class handles state management; concrete classes handle method-specific logic
  • Testability: Full unit test coverage for all scenarios
  • Session Management: 5-minute TTL prevents long-lived sessions
  • Recovery Fallback: Supports recovery codes as secondary verification method

Related Issues

Migration Notes

  • This PR introduces new classes only; no breaking changes to existing code
  • Ready for integration with OAuth2 authentication flows

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

  • Create IMFAChallengeStrategy interface with methods: issueChallenge(User, ?Client, bool): array, verifyChallenge(User, string): void, resendChallenge(User, ?Client, bool): array, getPendingState(): ?array, clearPendingState(): void, verifyRecoveryCode(User, string): void
  • Create AbstractMFAChallengeStrategy implementing getPendingState() (reads 2fa_pending_user_id, 2fa_pending_at, 2fa_remember from session with TTL check), clearPendingState() (forgets all 2FA session keys), and verifyRecoveryCode() (iterates unused recovery codes, Hash::check against input, marks used_at on match, throws AuthenticationException on no match)
  • Create MFAChallengeStrategyFactory with a static create(string $method) method that resolves 'email_otp' to EmailOTPMFAChallengeStrategy via the service container. Throws InvalidArgumentException for unknown methods.
    The factory would just be:
  public static function create(string $method): IMFAChallengeStrategy                                                                                                                            
  {                                                                    
      return match($method) {                                   
          'email_otp' => app()->make(EmailOTPMFAChallengeStrategy::class),
          default => throw new \InvalidArgumentException("Unknown MFA method: {$method}"),
      };
  }
  • Create EmailOTPMFAChallengeStrategy extending AbstractMFAChallengeStrategy: issueChallenge() stores pending state in session, creates OTP via ITokenService::createOTPFromPayload with connection='email', returns otp_length and otp_lifetime. verifyChallenge() looks up OTP by value/connection/username, validates alive+valid+attempts, redeems, revokes other pending OTPs. resendChallenge() delegates to issueChallenge().
  • Write unit tests for AbstractMFAChallengeStrategy::getPendingState() with valid session, expired session, missing session
  • Write unit tests for AbstractMFAChallengeStrategy::verifyRecoveryCode() with matching code, non-matching code, all codes used
  • Write unit tests for EmailOTPMFAChallengeStrategy::issueChallenge() (mock ITokenService)
  • Write unit tests for EmailOTPMFAChallengeStrategy::verifyChallenge() with valid OTP, expired OTP, max attempts exceeded, wrong value
  • Write unit tests for MFAChallengeStrategyFactory::create() with valid and invalid method names

ACCEPTANCE CRITERIA

  • MFAChallengeStrategyFactory::create('email_otp') returns an EmailOTPMFAChallengeStrategy instance
  • MFAChallengeStrategyFactory::create('unknown') throws InvalidArgumentException
  • issueChallenge() stores user ID and timestamp in session, creates an OTP via the existing token service, returns array with otp_length and otp_lifetime
  • verifyChallenge() with valid unexpired OTP redeems it successfully and revokes other pending OTPs for the user
  • verifyChallenge() with expired OTP throws AuthenticationException("Verification code is expired.")
  • verifyChallenge() with max attempts exceeded throws AuthenticationException("Verification code is not valid.")
  • getPendingState() returns null after session TTL expires (default 300 seconds)
  • verifyRecoveryCode() marks the matching code as used (sets used_at) and returns void
  • verifyRecoveryCode() with no matching code throws AuthenticationException("Invalid recovery code.")
  • All unit tests pass

DEVELOPMENT NOTES

Key files:

  • New: app/Strategies/MFA/IMFAChallengeStrategy.php
  • New: app/Strategies/MFA/AbstractMFAChallengeStrategy.php
  • New: app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
  • New: app/Strategies/MFA/MFAChallengeStrategyFactory.php
  • New: tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php
  • New: tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php
  • New: tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php

Gotchas:

  • EmailOTPMFAChallengeStrategy reuses the EXISTING OTP infrastructure: ITokenService::createOTPFromPayload() and IOAuth2OTPRepository. It does NOT create a parallel OTP system.
  • Session keys for pending state: '2fa_pending_user_id', '2fa_pending_at', '2fa_remember', '2fa_recovery_attempts'. These are shared across all strategy implementations via the abstract class.
  • verifyChallenge() must call logRedeemAttempt() BEFORE comparing values, per SDS section 4.5. This ensures attempt exhaustion even on mismatch.
  • OTP revocation after successful verification: query getByUserNameNotRedeemed() and redeem all OTPs except the one just verified.
  • The SDS shows OAuth2OTP::fromParams() usage for looking up OTPs. Follow the existing pattern in the codebase for OTP validation.

Out of scope:

Controller wiring

@matiasperrone-exo matiasperrone-exo self-assigned this May 8, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b5c4a16-f066-4b0a-9a3e-601c0fb41c52

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mfa-challenge-strategy-pattern

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@matiasperrone-exo matiasperrone-exo force-pushed the feat/mfa-challenge-strategy-pattern branch from 7e9c06b to 82e8fbc Compare May 8, 2026 22:33
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-129/

This page is automatically updated on each push to this PR.

@matiasperrone-exo matiasperrone-exo marked this pull request as ready for review May 8, 2026 23:00
Copy link
Copy Markdown
Contributor

@caseylocker caseylocker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.

  2. 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.

  3. `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.

  4. 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.

  5. 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.

@smarcet smarcet removed the request for review from tomrndom May 9, 2026 20:34
@smarcet
Copy link
Copy Markdown
Collaborator

smarcet commented May 9, 2026

@caseylocker
Thanks for the pass.
Most of those concerns are already covered by the existing integration/audit/rate-limit tickets and are intentionally outside the scope of Ticket 4, which is limited to the MFA strategy layer itself.
how ever i will review those 2 first bc it seems that SDS left some gaps many thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants