diff --git a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php
new file mode 100644
index 00000000..6944ff66
--- /dev/null
+++ b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php
@@ -0,0 +1,65 @@
+ self::SESSION_TTL) {
+ $this->clearPendingState();
+ return null;
+ }
+
+ return [
+ 'user_id' => $user_id,
+ 'pending_at' => $pending_at,
+ 'remember' => Session::get(self::KEY_REMEMBER, false),
+ ];
+ }
+
+ public function clearPendingState(): void
+ {
+ Session::remove(self::KEY_USER_ID);
+ Session::remove(self::KEY_PENDING_AT);
+ Session::remove(self::KEY_REMEMBER);
+ Session::remove(self::KEY_RECOVERY_ATTEMPTS);
+ }
+
+ public function verifyRecoveryCode(User $user, string $code): void
+ {
+ foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) {
+ if (Hash::check($code, $recoveryCode->getCodeHash())) {
+ $recoveryCode->markUsed();
+ return;
+ }
+ }
+ throw new AuthenticationException("Invalid recovery code.");
+ }
+
+ protected function storePendingState(int $userId, bool $remember): void
+ {
+ Session::put(self::KEY_USER_ID, $userId);
+ Session::put(self::KEY_PENDING_AT, time());
+ Session::put(self::KEY_REMEMBER, $remember);
+ }
+}
diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
new file mode 100644
index 00000000..66f9a68b
--- /dev/null
+++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
@@ -0,0 +1,72 @@
+storePendingState($user->getId(), $remember);
+
+ $otp = $this->token_service->createOTPFromPayload([
+ OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail,
+ OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode,
+ OAuth2Protocol::OAuth2PasswordlessEmail => $user->getEmail(),
+ ], $client);
+
+ return [
+ 'otp_length' => $otp->getLength(),
+ 'otp_lifetime' => $otp->getLifetime(),
+ ];
+ }
+
+ public function verifyChallenge(User $user, string $code): void
+ {
+ $otp = $this->otp_repository->getByValueConnectionAndUserName(
+ $code,
+ OAuth2Protocol::OAuth2PasswordlessConnectionEmail,
+ $user->getEmail()
+ );
+
+ if (is_null($otp)) {
+ throw new AuthenticationException("Non existent single-use code.");
+ }
+
+ $otp->logRedeemAttempt();
+
+ if (!$otp->isAlive()) {
+ throw new AuthenticationException("Verification code is expired.");
+ }
+
+ if (!$otp->isValid()) {
+ throw new AuthenticationException("Verification code is not valid.");
+ }
+
+ $otp->redeem();
+
+ foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail()) as $otpToRevoke) {
+ if ($otpToRevoke->getValue() !== $otp->getValue()) {
+ $otpToRevoke->redeem();
+ }
+ }
+ }
+
+ public function resendChallenge(User $user, ?Client $client, bool $remember): array
+ {
+ return $this->issueChallenge($user, $client, $remember);
+ }
+}
diff --git a/app/Strategies/MFA/IMFAChallengeStrategy.php b/app/Strategies/MFA/IMFAChallengeStrategy.php
new file mode 100644
index 00000000..9f59e6ef
--- /dev/null
+++ b/app/Strategies/MFA/IMFAChallengeStrategy.php
@@ -0,0 +1,14 @@
+ app()->make(EmailOTPMFAChallengeStrategy::class),
+ default => throw new \InvalidArgumentException("Unknown MFA method: {$method}"),
+ };
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
index 4750f428..a653e0ce 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -25,6 +25,9 @@
./tests/TwoFactorRepositoriesTest.php
./tests/unit/UserTwoFactorTest.php
+ ./tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php
+ ./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php
+ ./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php
diff --git a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php
new file mode 100644
index 00000000..64c6d6f1
--- /dev/null
+++ b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php
@@ -0,0 +1,143 @@
+strategy = new class($repo) extends AbstractMFAChallengeStrategy {
+ public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ public function verifyChallenge(User $user, string $code): void {}
+ public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ public function exposeStorePendingState(int $userId, bool $remember): void {
+ $this->storePendingState($userId, $remember);
+ }
+ };
+ }
+
+ protected function tearDown(): void
+ {
+ \Mockery::close();
+ parent::tearDown();
+ }
+
+ public function testGetPendingState_withValidSession_returnsState(): void
+ {
+ $this->strategy->exposeStorePendingState(42, true);
+
+ $state = $this->strategy->getPendingState();
+
+ $this->assertNotNull($state);
+ $this->assertSame(42, $state['user_id']);
+ $this->assertTrue($state['remember']);
+ $this->assertArrayHasKey('pending_at', $state);
+ }
+
+ public function testGetPendingState_withExpiredSession_returnsNull(): void
+ {
+ Session::put('2fa_pending_user_id', 99);
+ Session::put('2fa_pending_at', time() - 301);
+ Session::put('2fa_remember', false);
+
+ $state = $this->strategy->getPendingState();
+
+ $this->assertNull($state);
+ $this->assertNull(Session::get('2fa_pending_user_id'));
+ }
+
+ public function testGetPendingState_withMissingSession_returnsNull(): void
+ {
+ $state = $this->strategy->getPendingState();
+
+ $this->assertNull($state);
+ }
+
+ public function testClearPendingState_removesAllSessionKeys(): void
+ {
+ Session::put('2fa_pending_user_id', 7);
+ Session::put('2fa_pending_at', time());
+ Session::put('2fa_remember', true);
+ Session::put('2fa_recovery_attempts', 1);
+
+ $this->strategy->clearPendingState();
+
+ $this->assertNull(Session::get('2fa_pending_user_id'));
+ $this->assertNull(Session::get('2fa_pending_at'));
+ $this->assertNull(Session::get('2fa_remember'));
+ $this->assertNull(Session::get('2fa_recovery_attempts'));
+ }
+
+ public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void
+ {
+ $user = new User();
+ $code = 'VALID-CODE';
+
+ $recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class);
+ $recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make($code));
+ $recoveryCode->shouldReceive('markUsed')->once();
+
+ $repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
+ $repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]);
+
+ $strategy = new class($repo) extends AbstractMFAChallengeStrategy {
+ public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ public function verifyChallenge(User $user, string $code): void {}
+ public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ };
+
+ $strategy->verifyRecoveryCode($user, $code);
+ $this->addToAssertionCount(1); // markUsed()->once() verified by Mockery in tearDown
+ }
+
+ public function testVerifyRecoveryCode_withNonMatchingCode_throwsException(): void
+ {
+ $user = new User();
+
+ $recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class);
+ $recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make('CORRECT-CODE'));
+ $recoveryCode->shouldNotReceive('markUsed');
+
+ $repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
+ $repo->shouldReceive('getUnusedByUser')->andReturn([$recoveryCode]);
+
+ $strategy = new class($repo) extends AbstractMFAChallengeStrategy {
+ public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ public function verifyChallenge(User $user, string $code): void {}
+ public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ };
+
+ $this->expectException(AuthenticationException::class);
+ $this->expectExceptionMessage("Invalid recovery code.");
+ $strategy->verifyRecoveryCode($user, 'WRONG-CODE');
+ }
+
+ public function testVerifyRecoveryCode_withAllCodesUsed_throwsException(): void
+ {
+ $user = new User();
+
+ $repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
+ $repo->shouldReceive('getUnusedByUser')->andReturn([]);
+
+ $strategy = new class($repo) extends AbstractMFAChallengeStrategy {
+ public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ public function verifyChallenge(User $user, string $code): void {}
+ public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ };
+
+ $this->expectException(AuthenticationException::class);
+ $this->expectExceptionMessage("Invalid recovery code.");
+ $strategy->verifyRecoveryCode($user, 'ANY-CODE');
+ }
+}
diff --git a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php
new file mode 100644
index 00000000..cb12c3ab
--- /dev/null
+++ b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php
@@ -0,0 +1,167 @@
+tokenService = \Mockery::mock(ITokenService::class);
+ $this->otpRepository = \Mockery::mock(IOAuth2OTPRepository::class);
+ $recoveryRepo = \Mockery::mock(IUserRecoveryCodeRepository::class);
+
+ $this->strategy = new EmailOTPMFAChallengeStrategy(
+ $recoveryRepo,
+ $this->tokenService,
+ $this->otpRepository,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ \Mockery::close();
+ parent::tearDown();
+ }
+
+ private function buildUser(int $id, string $email): User
+ {
+ $user = \Mockery::mock(User::class);
+ $user->shouldReceive('getId')->andReturn($id);
+ $user->shouldReceive('getEmail')->andReturn($email);
+ return $user;
+ }
+
+ // ---------- issueChallenge ----------
+
+ public function testIssueChallenge_storesPendingStateAndReturnsOtpInfo(): void
+ {
+ $user = $this->buildUser(42, 'user@example.com');
+
+ $otp = \Mockery::mock(OAuth2OTP::class);
+ $otp->shouldReceive('getLength')->andReturn(6);
+ $otp->shouldReceive('getLifetime')->andReturn(120);
+
+ $this->tokenService
+ ->shouldReceive('createOTPFromPayload')
+ ->once()
+ ->withArgs(function (array $payload, $client) {
+ return $payload['connection'] === 'email'
+ && $payload['send'] === 'code'
+ && $payload['email'] === 'user@example.com'
+ && is_null($client);
+ })
+ ->andReturn($otp);
+
+ $result = $this->strategy->issueChallenge($user, null, true);
+
+ $this->assertSame(['otp_length' => 6, 'otp_lifetime' => 120], $result);
+ $this->assertSame(42, Session::get('2fa_pending_user_id'));
+ $this->assertTrue(Session::get('2fa_remember'));
+ }
+
+ // ---------- resendChallenge ----------
+
+ public function testResendChallenge_delegatesToIssueChallenge(): void
+ {
+ $user = $this->buildUser(7, 'resend@example.com');
+
+ $otp = \Mockery::mock(OAuth2OTP::class);
+ $otp->shouldReceive('getLength')->andReturn(6);
+ $otp->shouldReceive('getLifetime')->andReturn(120);
+
+ $this->tokenService
+ ->shouldReceive('createOTPFromPayload')
+ ->once()
+ ->andReturn($otp);
+
+ $result = $this->strategy->resendChallenge($user, null, false);
+
+ $this->assertSame(['otp_length' => 6, 'otp_lifetime' => 120], $result);
+ $this->assertSame(7, Session::get('2fa_pending_user_id'));
+ }
+
+ // ---------- verifyChallenge ----------
+
+ public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers(): void
+ {
+ $user = $this->buildUser(1, 'verify@example.com');
+ $code = '123456';
+
+ $otp = \Mockery::mock(OAuth2OTP::class);
+ $otp->shouldReceive('logRedeemAttempt')->once();
+ $otp->shouldReceive('isAlive')->andReturn(true);
+ $otp->shouldReceive('isValid')->andReturn(true);
+ $otp->shouldReceive('redeem')->once();
+ $otp->shouldReceive('getValue')->andReturn($code);
+
+ $otherOtp = \Mockery::mock(OAuth2OTP::class);
+ $otherOtp->shouldReceive('getValue')->andReturn('654321');
+ $otherOtp->shouldReceive('redeem')->once();
+
+ $this->otpRepository
+ ->shouldReceive('getByValueConnectionAndUserName')
+ ->andReturn($otp);
+
+ $this->otpRepository
+ ->shouldReceive('getByUserNameNotRedeemed')
+ ->andReturn([$otp, $otherOtp]);
+
+ $this->strategy->verifyChallenge($user, $code);
+ $this->addToAssertionCount(1);
+ }
+
+ public function testVerifyChallenge_withExpiredOtp_throwsException(): void
+ {
+ $user = $this->buildUser(2, 'expired@example.com');
+
+ $otp = \Mockery::mock(OAuth2OTP::class);
+ $otp->shouldReceive('logRedeemAttempt')->once();
+ $otp->shouldReceive('isAlive')->andReturn(false);
+
+ $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn($otp);
+
+ $this->expectException(AuthenticationException::class);
+ $this->expectExceptionMessage("Verification code is expired.");
+ $this->strategy->verifyChallenge($user, '000000');
+ }
+
+ public function testVerifyChallenge_withMaxAttemptsExceeded_throwsException(): void
+ {
+ $user = $this->buildUser(3, 'maxattempts@example.com');
+
+ $otp = \Mockery::mock(OAuth2OTP::class);
+ $otp->shouldReceive('logRedeemAttempt')->once();
+ $otp->shouldReceive('isAlive')->andReturn(true);
+ $otp->shouldReceive('isValid')->andReturn(false);
+
+ $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn($otp);
+
+ $this->expectException(AuthenticationException::class);
+ $this->expectExceptionMessage("Verification code is not valid.");
+ $this->strategy->verifyChallenge($user, '111111');
+ }
+
+ public function testVerifyChallenge_withNonExistentOtp_throwsException(): void
+ {
+ $user = $this->buildUser(4, 'noexist@example.com');
+
+ $this->otpRepository->shouldReceive('getByValueConnectionAndUserName')->andReturn(null);
+
+ $this->expectException(AuthenticationException::class);
+ $this->expectExceptionMessage("Non existent single-use code.");
+ $this->strategy->verifyChallenge($user, 'BADCODE');
+ }
+}
diff --git a/tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php b/tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php
new file mode 100644
index 00000000..d51cf1cc
--- /dev/null
+++ b/tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php
@@ -0,0 +1,23 @@
+assertInstanceOf(EmailOTPMFAChallengeStrategy::class, $strategy);
+ }
+
+ public function testCreate_withUnknownMethod_throwsInvalidArgumentException(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage("Unknown MFA method: sms_otp");
+
+ MFAChallengeStrategyFactory::create('sms_otp');
+ }
+}