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'); + } +}