Skip to content

Commit 3cdade3

Browse files
feat: Implement Multi-Factor Authentication challenge strategies and tests
1 parent a0c22ff commit 3cdade3

8 files changed

Lines changed: 499 additions & 0 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php namespace Strategies\MFA;
2+
3+
use Auth\Exceptions\AuthenticationException;
4+
use Auth\Repositories\IUserRecoveryCodeRepository;
5+
use Auth\User;
6+
use Illuminate\Support\Facades\Hash;
7+
use Illuminate\Support\Facades\Session;
8+
9+
abstract class AbstractMFAChallengeStrategy implements IMFAChallengeStrategy
10+
{
11+
private const SESSION_TTL = 300;
12+
private const KEY_USER_ID = '2fa_pending_user_id';
13+
private const KEY_PENDING_AT = '2fa_pending_at';
14+
private const KEY_REMEMBER = '2fa_remember';
15+
private const KEY_RECOVERY_ATTEMPTS = '2fa_recovery_attempts';
16+
17+
public function __construct(protected IUserRecoveryCodeRepository $recovery_code_repository) {}
18+
19+
public function getPendingState(): ?array
20+
{
21+
$user_id = Session::get(self::KEY_USER_ID);
22+
$pending_at = Session::get(self::KEY_PENDING_AT);
23+
24+
if (is_null($user_id) || is_null($pending_at)) {
25+
return null;
26+
}
27+
28+
if ((time() - $pending_at) > self::SESSION_TTL) {
29+
$this->clearPendingState();
30+
return null;
31+
}
32+
33+
return [
34+
'user_id' => $user_id,
35+
'pending_at' => $pending_at,
36+
'remember' => Session::get(self::KEY_REMEMBER, false),
37+
];
38+
}
39+
40+
public function clearPendingState(): void
41+
{
42+
Session::remove(self::KEY_USER_ID);
43+
Session::remove(self::KEY_PENDING_AT);
44+
Session::remove(self::KEY_REMEMBER);
45+
Session::remove(self::KEY_RECOVERY_ATTEMPTS);
46+
}
47+
48+
public function verifyRecoveryCode(User $user, string $code): void
49+
{
50+
foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) {
51+
if (Hash::check($code, $recoveryCode->getCodeHash())) {
52+
$recoveryCode->markUsed();
53+
return;
54+
}
55+
}
56+
throw new AuthenticationException("Invalid recovery code.");
57+
}
58+
59+
protected function storePendingState(int $userId, bool $remember): void
60+
{
61+
Session::put(self::KEY_USER_ID, $userId);
62+
Session::put(self::KEY_PENDING_AT, time());
63+
Session::put(self::KEY_REMEMBER, $remember);
64+
}
65+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php namespace Strategies\MFA;
2+
3+
use App\libs\OAuth2\Repositories\IOAuth2OTPRepository;
4+
use Auth\Exceptions\AuthenticationException;
5+
use Auth\Repositories\IUserRecoveryCodeRepository;
6+
use Auth\User;
7+
use Models\OAuth2\Client;
8+
use OAuth2\OAuth2Protocol;
9+
use OAuth2\Services\ITokenService;
10+
11+
final class EmailOTPMFAChallengeStrategy extends AbstractMFAChallengeStrategy
12+
{
13+
public function __construct(
14+
IUserRecoveryCodeRepository $recovery_code_repository,
15+
private readonly ITokenService $token_service,
16+
private readonly IOAuth2OTPRepository $otp_repository,
17+
) {
18+
parent::__construct($recovery_code_repository);
19+
}
20+
21+
public function issueChallenge(User $user, ?Client $client, bool $remember): array
22+
{
23+
$this->storePendingState($user->getId(), $remember);
24+
25+
$otp = $this->token_service->createOTPFromPayload([
26+
OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail,
27+
OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode,
28+
OAuth2Protocol::OAuth2PasswordlessEmail => $user->getEmail(),
29+
], $client);
30+
31+
return [
32+
'otp_length' => $otp->getLength(),
33+
'otp_lifetime' => $otp->getLifetime(),
34+
];
35+
}
36+
37+
public function verifyChallenge(User $user, string $code): void
38+
{
39+
$otp = $this->otp_repository->getByValueConnectionAndUserName(
40+
$code,
41+
OAuth2Protocol::OAuth2PasswordlessConnectionEmail,
42+
$user->getEmail()
43+
);
44+
45+
if (is_null($otp)) {
46+
throw new AuthenticationException("Non existent single-use code.");
47+
}
48+
49+
$otp->logRedeemAttempt();
50+
51+
if (!$otp->isAlive()) {
52+
throw new AuthenticationException("Verification code is expired.");
53+
}
54+
55+
if (!$otp->isValid()) {
56+
throw new AuthenticationException("Verification code is not valid.");
57+
}
58+
59+
$otp->redeem();
60+
61+
foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail()) as $otpToRevoke) {
62+
if ($otpToRevoke->getValue() !== $otp->getValue()) {
63+
$otpToRevoke->redeem();
64+
}
65+
}
66+
}
67+
68+
public function resendChallenge(User $user, ?Client $client, bool $remember): array
69+
{
70+
return $this->issueChallenge($user, $client, $remember);
71+
}
72+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php namespace Strategies\MFA;
2+
3+
use Auth\User;
4+
use Models\OAuth2\Client;
5+
6+
interface IMFAChallengeStrategy
7+
{
8+
public function issueChallenge(User $user, ?Client $client, bool $remember): array;
9+
public function verifyChallenge(User $user, string $code): void;
10+
public function resendChallenge(User $user, ?Client $client, bool $remember): array;
11+
public function getPendingState(): ?array;
12+
public function clearPendingState(): void;
13+
public function verifyRecoveryCode(User $user, string $code): void;
14+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php namespace Strategies\MFA;
2+
3+
final class MFAChallengeStrategyFactory
4+
{
5+
public static function create(string $method): IMFAChallengeStrategy
6+
{
7+
return match($method) {
8+
'email_otp' => app()->make(EmailOTPMFAChallengeStrategy::class),
9+
default => throw new \InvalidArgumentException("Unknown MFA method: {$method}"),
10+
};
11+
}
12+
}

phpunit.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
<testsuite name="Two Factor Authentication Test Suite">
2626
<file>./tests/TwoFactorRepositoriesTest.php</file>
2727
<file>./tests/unit/UserTwoFactorTest.php</file>
28+
<file>./tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php</file>
29+
<file>./tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php</file>
30+
<file>./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php</file>
2831
</testsuite>
2932
</testsuites>
3033
<php>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php namespace Tests\Unit\MFA;
2+
3+
use Auth\Exceptions\AuthenticationException;
4+
use Auth\Repositories\IUserRecoveryCodeRepository;
5+
use Auth\User;
6+
use Illuminate\Support\Facades\Hash;
7+
use Illuminate\Support\Facades\Session;
8+
use Models\OAuth2\Client;
9+
use Strategies\MFA\AbstractMFAChallengeStrategy;
10+
use Tests\TestCase;
11+
12+
class AbstractMFAChallengeStrategyTest extends TestCase
13+
{
14+
private AbstractMFAChallengeStrategy $strategy;
15+
16+
protected function setUp(): void
17+
{
18+
parent::setUp();
19+
$repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
20+
$this->strategy = new class($repo) extends AbstractMFAChallengeStrategy {
21+
public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
22+
public function verifyChallenge(User $user, string $code): void {}
23+
public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
24+
public function exposeStorePendingState(int $userId, bool $remember): void {
25+
$this->storePendingState($userId, $remember);
26+
}
27+
};
28+
}
29+
30+
protected function tearDown(): void
31+
{
32+
\Mockery::close();
33+
parent::tearDown();
34+
}
35+
36+
public function testGetPendingState_withValidSession_returnsState(): void
37+
{
38+
$this->strategy->exposeStorePendingState(42, true);
39+
40+
$state = $this->strategy->getPendingState();
41+
42+
$this->assertNotNull($state);
43+
$this->assertSame(42, $state['user_id']);
44+
$this->assertTrue($state['remember']);
45+
$this->assertArrayHasKey('pending_at', $state);
46+
}
47+
48+
public function testGetPendingState_withExpiredSession_returnsNull(): void
49+
{
50+
Session::put('2fa_pending_user_id', 99);
51+
Session::put('2fa_pending_at', time() - 301);
52+
Session::put('2fa_remember', false);
53+
54+
$state = $this->strategy->getPendingState();
55+
56+
$this->assertNull($state);
57+
$this->assertNull(Session::get('2fa_pending_user_id'));
58+
}
59+
60+
public function testGetPendingState_withMissingSession_returnsNull(): void
61+
{
62+
$state = $this->strategy->getPendingState();
63+
64+
$this->assertNull($state);
65+
}
66+
67+
public function testClearPendingState_removesAllSessionKeys(): void
68+
{
69+
Session::put('2fa_pending_user_id', 7);
70+
Session::put('2fa_pending_at', time());
71+
Session::put('2fa_remember', true);
72+
Session::put('2fa_recovery_attempts', 1);
73+
74+
$this->strategy->clearPendingState();
75+
76+
$this->assertNull(Session::get('2fa_pending_user_id'));
77+
$this->assertNull(Session::get('2fa_pending_at'));
78+
$this->assertNull(Session::get('2fa_remember'));
79+
$this->assertNull(Session::get('2fa_recovery_attempts'));
80+
}
81+
82+
public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void
83+
{
84+
$user = new User();
85+
$code = 'VALID-CODE';
86+
87+
$recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class);
88+
$recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make($code));
89+
$recoveryCode->shouldReceive('markUsed')->once();
90+
91+
$repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
92+
$repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]);
93+
94+
$strategy = new class($repo) extends AbstractMFAChallengeStrategy {
95+
public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
96+
public function verifyChallenge(User $user, string $code): void {}
97+
public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
98+
};
99+
100+
$strategy->verifyRecoveryCode($user, $code);
101+
$this->addToAssertionCount(1); // markUsed()->once() verified by Mockery in tearDown
102+
}
103+
104+
public function testVerifyRecoveryCode_withNonMatchingCode_throwsException(): void
105+
{
106+
$user = new User();
107+
108+
$recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class);
109+
$recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make('CORRECT-CODE'));
110+
$recoveryCode->shouldNotReceive('markUsed');
111+
112+
$repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
113+
$repo->shouldReceive('getUnusedByUser')->andReturn([$recoveryCode]);
114+
115+
$strategy = new class($repo) extends AbstractMFAChallengeStrategy {
116+
public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
117+
public function verifyChallenge(User $user, string $code): void {}
118+
public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
119+
};
120+
121+
$this->expectException(AuthenticationException::class);
122+
$this->expectExceptionMessage("Invalid recovery code.");
123+
$strategy->verifyRecoveryCode($user, 'WRONG-CODE');
124+
}
125+
126+
public function testVerifyRecoveryCode_withAllCodesUsed_throwsException(): void
127+
{
128+
$user = new User();
129+
130+
$repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
131+
$repo->shouldReceive('getUnusedByUser')->andReturn([]);
132+
133+
$strategy = new class($repo) extends AbstractMFAChallengeStrategy {
134+
public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
135+
public function verifyChallenge(User $user, string $code): void {}
136+
public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
137+
};
138+
139+
$this->expectException(AuthenticationException::class);
140+
$this->expectExceptionMessage("Invalid recovery code.");
141+
$strategy->verifyRecoveryCode($user, 'ANY-CODE');
142+
}
143+
}

0 commit comments

Comments
 (0)