Skip to content

Commit cac5fd3

Browse files
chore: Add PR's requested changed
1 parent 56e1468 commit cac5fd3

13 files changed

Lines changed: 315 additions & 28 deletions

app/Http/Controllers/Traits/MFACookieManager.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
**/
1414

1515
use Auth\User;
16+
use Exception;
1617
use Illuminate\Support\Facades\Config;
1718
use Illuminate\Support\Facades\Cookie;
1819
use Illuminate\Support\Facades\Request;
20+
use Keepsuit\LaravelOpenTelemetry\Facades\Logger;
1921
use Utils\IPHelper;
2022

2123
/**

app/Http/Controllers/UserController.php

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ public function postLogin()
468468
$connection = $data['connection'] ?? null;
469469

470470
try {
471-
if ($flow == "password") {
471+
if ($flow == IAuthService::AuthenticationFlowPassword) {
472472
// Validate credentials WITHOUT establishing a session, so the
473473
// MFA gate can run before the user is authenticated.
474474
$user = $this->auth_service->validateCredentials($username, $password);
@@ -500,7 +500,7 @@ public function postLogin()
500500
return $this->login_strategy->postLogin();
501501
}
502502

503-
if ($flow == "otp") {
503+
if ($flow == IAuthService::AuthenticationFlowPasswordless) {
504504

505505
$client = $this->resolveClientFromMemento();
506506

@@ -630,6 +630,24 @@ private function resolveClientFromMemento(): ?Client
630630
return $client;
631631
}
632632

633+
/**
634+
* Resolves the OAuth2 client carried in the pending MFA state (the client the
635+
* challenge was issued for), so verification scopes the OTP lookup and
636+
* sibling-revoke to the same client. Returns null when the challenge was not
637+
* issued in a client context.
638+
*
639+
* @param array $pending
640+
* @return Client|null
641+
*/
642+
private function resolveClientFromPendingState(array $pending): ?Client
643+
{
644+
$clientId = $pending['client_id'] ?? null;
645+
if (is_null($clientId)) {
646+
return null;
647+
}
648+
return $this->client_repository->getClientById($clientId);
649+
}
650+
633651
/**
634652
* Verifies a 2FA OTP challenge and, on success, establishes the session.
635653
*
@@ -666,8 +684,19 @@ public function verify2FA()
666684
return $this->mfaSessionExpired();
667685
}
668686

687+
// Scope verification to the client the challenge was issued for.
688+
$client = $this->resolveClientFromPendingState($pending);
689+
669690
try {
670-
$this->auth_service->verifyMFAChallenge($user, $strategy, $otp_value);
691+
// Commits the OTP redeem (+ sibling revoke) in its own tx. The
692+
// session, trusted-device enrollment and audit are applied below
693+
// as separate post-verification steps.
694+
$this->auth_service->verifyMFAChallenge(
695+
$user,
696+
$strategy,
697+
$otp_value,
698+
$client
699+
);
671700
} catch (AuthenticationException $ex) {
672701
Log::warning($ex);
673702
// Re-fetch user: the tx wrapper closed/reset the EM on failure, detaching the entity.
@@ -686,17 +715,29 @@ public function verify2FA()
686715
$this->auth_service->loginUser($user, (bool) $pending['remember']);
687716

688717
if ($trust_device) {
689-
$this->queueDeviceTrustCookie($user);
718+
// Best-effort: the OTP is already redeemed and the session
719+
// established, so a trusted-device enrollment failure must not
720+
// 500 the user (which would lock them out on retry against a
721+
// burned OTP). Log and continue; the device just isn't remembered.
722+
try {
723+
$this->queueDeviceTrustCookie($user);
724+
} catch (Exception $ex) {
725+
Log::warning($ex);
726+
}
690727
}
691728

692729
$strategy->clearPendingState();
693730

694-
$this->two_factor_audit_service->log(
695-
$user,
696-
TwoFactorAuditLog::EventChallengeSucceeded,
697-
$method,
698-
IPHelper::getUserIp()
699-
);
731+
try {
732+
$this->two_factor_audit_service->log(
733+
$user,
734+
TwoFactorAuditLog::EventChallengeSucceeded,
735+
$method,
736+
IPHelper::getUserIp()
737+
);
738+
} catch (Exception $ex) {
739+
Log::warning($ex);
740+
}
700741

701742
return $this->login_strategy->postLogin();
702743
} catch (ValidationException $ex) {

app/Repositories/DoctrineUserRecoveryCodeRepository.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ public function getUnusedByUser(User $user): array
3131
]);
3232
}
3333

34+
public function refreshExclusiveLock(UserRecoveryCode $code): void
35+
{
36+
// Single round-trip: SELECT ... FOR UPDATE that also re-hydrates the entity.
37+
$this->getEntityManager()->refresh($code, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE);
38+
}
39+
3440
public function deleteAllForUser(User $user): int
3541
{
3642
$em = $this->getEntityManager();

app/Strategies/MFA/AbstractMFAChallengeStrategy.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ abstract class AbstractMFAChallengeStrategy implements IMFAChallengeStrategy
1313
private const KEY_USER_ID = '2fa_pending_user_id';
1414
private const KEY_PENDING_AT = '2fa_pending_at';
1515
private const KEY_REMEMBER = '2fa_remember';
16+
private const KEY_CLIENT_ID = '2fa_pending_client_id';
1617
private const KEY_RECOVERY_ATTEMPTS = '2fa_recovery_attempts';
1718

1819
public function __construct(protected IUserRecoveryCodeRepository $recovery_code_repository) {}
@@ -35,6 +36,7 @@ public function getPendingState(): ?array
3536
'user_id' => $user_id,
3637
'pending_at' => $pending_at,
3738
'remember' => Session::get(self::KEY_REMEMBER, false),
39+
'client_id' => Session::get(self::KEY_CLIENT_ID),
3840
];
3941
}
4042

@@ -43,13 +45,22 @@ public function clearPendingState(): void
4345
Session::remove(self::KEY_USER_ID);
4446
Session::remove(self::KEY_PENDING_AT);
4547
Session::remove(self::KEY_REMEMBER);
48+
Session::remove(self::KEY_CLIENT_ID);
4649
Session::remove(self::KEY_RECOVERY_ATTEMPTS);
4750
}
4851

4952
public function verifyRecoveryCode(User $user, string $code): void
5053
{
5154
foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) {
5255
if (Hash::check($code, $recoveryCode->getCodeHash())) {
56+
// Concurrency: acquire a PESSIMISTIC_WRITE row lock and re-hydrate
57+
// used_at before mutating. This closes the check->markUsed race
58+
// window: a second concurrent submitter blocks on the lock and, on
59+
// resume, sees the code already used instead of double-spending it.
60+
$this->recovery_code_repository->refreshExclusiveLock($recoveryCode);
61+
if ($recoveryCode->isUsed()) {
62+
throw new AuthenticationException("Invalid recovery code.");
63+
}
5364
$recoveryCode->markUsed();
5465
$this->recovery_code_repository->add($recoveryCode, false);
5566
return;
@@ -58,11 +69,16 @@ public function verifyRecoveryCode(User $user, string $code): void
5869
throw new AuthenticationException("Invalid recovery code.");
5970
}
6071

61-
protected function storePendingState(int $userId, bool $remember): void
72+
protected function storePendingState(int $userId, bool $remember, ?string $clientId = null): void
6273
{
6374
Session::put(self::KEY_USER_ID, $userId);
6475
Session::put(self::KEY_PENDING_AT, time());
6576
Session::put(self::KEY_REMEMBER, $remember);
77+
if (is_null($clientId)) {
78+
Session::remove(self::KEY_CLIENT_ID);
79+
} else {
80+
Session::put(self::KEY_CLIENT_ID, $clientId);
81+
}
6682
}
6783

6884
public function verifyChallenge(User $user, string $code, ?Client $client = null): void

app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ public function __construct(
2020

2121
public function issueChallenge(User $user, ?Client $client, bool $remember): array
2222
{
23-
$this->storePendingState($user->getId(), $remember);
23+
// Carry the issuing client into the pending state so verification scopes
24+
// the OTP lookup and sibling-revoke to the same client (see verifyChallenge).
25+
$this->storePendingState($user->getId(), $remember, $client?->getClientId());
2426

2527
$otp = $this->token_service->createOTPFromPayload([
2628
OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail,
@@ -38,6 +40,8 @@ public function verifyChallenge(User $user, string $code, ?Client $client = null
3840
{
3941
// Look up the STORED single-use code so the submitted value is actually
4042
// validated against what was issued (a non-matching code resolves to null).
43+
// Scope the lookup to the issuing client so an MFA OTP is only matched
44+
// against the client it was issued for.
4145
$otp = $this->otp_repository->getByValueConnectionAndUserName(
4246
$code,
4347
OAuth2Protocol::OAuth2PasswordlessConnectionEmail,
@@ -59,10 +63,23 @@ public function verifyChallenge(User $user, string $code, ?Client $client = null
5963
throw new AuthenticationException("Verification code is not valid.");
6064
}
6165

66+
// Concurrency: acquire a PESSIMISTIC_WRITE row lock and re-hydrate redemption
67+
// state before redeeming, mirroring AuthService::finalizeRedemption(). This
68+
// closes the validate->redeem race so two concurrent submissions of the same
69+
// valid code cannot both succeed. Runs inside the verifyMFAChallenge tx.
70+
if ($otp->getConnection() !== OAuth2Protocol::OAuth2PasswordlessConnectionInline) {
71+
$this->otp_repository->refreshExclusiveLock($otp);
72+
if ($otp->isRedeemed()) {
73+
throw new AuthenticationException("Verification code is already redeemed.");
74+
}
75+
}
76+
6277
$otp->redeem();
6378
$this->otp_repository->add($otp, false);
6479

65-
foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail()) as $otpToRevoke) {
80+
// Revoke other pending OTPs for this user, scoped to the same client so we
81+
// never burn unrelated OTPs (e.g. passwordless-login codes for other clients).
82+
foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail(), $client) as $otpToRevoke) {
6683
if ($otpToRevoke->getValue() !== $otp->getValue()) {
6784
$otpToRevoke->redeem();
6885
$this->otp_repository->add($otpToRevoke, false);

app/libs/Auth/AuthService.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,13 @@ public function loginUser(User $user, bool $remember): void
447447
Log::debug("AuthService::loginUser");
448448
if (!$user->canLogin())
449449
throw new AuthenticationException("User is not active or cannot login.");
450+
451+
$this->principal_service->clear();
452+
$this->principal_service->register
453+
(
454+
$user->getId(),
455+
time()
456+
);
450457
Auth::login($user, $remember);
451458
}
452459

@@ -789,10 +796,15 @@ public function issueMFAChallenge(
789796
public function verifyMFAChallenge(
790797
User $user,
791798
IMFAChallengeStrategy $strategy,
792-
string $value
799+
string $value,
800+
?Client $client = null
793801
): void {
794-
$this->tx_service->transaction(function () use ($user, $strategy, $value) {
795-
$strategy->verifyChallenge($user, $value);
802+
// Commits the OTP redeem (+ sibling revoke) as a single tx. Trusted-device
803+
// enrollment and audit are applied by the caller as best-effort,
804+
// non-blocking side effects after this commits, so a failure in either
805+
// does not block (or roll back) an already-verified second factor.
806+
$this->tx_service->transaction(function () use ($user, $strategy, $value, $client) {
807+
$strategy->verifyChallenge($user, $value, $client);
796808
});
797809
}
798810

app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ interface IUserRecoveryCodeRepository extends IBaseRepository
2222
*/
2323
public function getUnusedByUser(User $user): array;
2424

25+
/**
26+
* Acquires a PESSIMISTIC_WRITE row lock on the given recovery code and
27+
* re-hydrates its used_at state in the same round-trip. Required before
28+
* redeeming a recovery code to close the check->markUsed double-spend race.
29+
*/
30+
public function refreshExclusiveLock(UserRecoveryCode $code): void;
31+
2532
/**
2633
* Delete every recovery code for a user (used when regenerating).
2734
*/

app/libs/Utils/Services/IAuthService.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ public function issueMFAChallenge(
205205
public function verifyMFAChallenge(
206206
User $user,
207207
IMFAChallengeStrategy $strategy,
208-
string $value
208+
string $value,
209+
?Client $client = null
209210
): void;
210211

211212
public function verifyMFARecoveryCode(

storage/framework/cache/data/.gitignore

Lines changed: 0 additions & 2 deletions
This file was deleted.

tests/TwoFactorLoginFlowTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use App\libs\Auth\Models\UserRecoveryCode;
1818
use App\libs\Auth\Models\UserTrustedDevice;
1919
use App\Services\Auth\IDeviceTrustService;
20+
use App\Services\Auth\ITwoFactorAuditService;
2021
use Auth\AuthHelper;
2122
use Auth\User;
2223
use Illuminate\Support\Facades\App;
@@ -239,6 +240,58 @@ public function testTrustedDeviceCookieBypassesMFA(): void
239240
$this->assertTrue(Auth::check(), 'a valid trusted-device cookie must bypass MFA');
240241
}
241242

243+
// -------------------------------------------------------------------------
244+
// post-verify transaction boundary (Task 5: device-trust atomic, audit best-effort)
245+
// -------------------------------------------------------------------------
246+
247+
public function testAuditFailureDoesNotBlockLogin(): void
248+
{
249+
// Audit is best-effort: a failure emitting challenge_succeeded must NOT
250+
// 500 a user whose OTP is already redeemed and session established.
251+
$auditMock = \Mockery::mock(ITwoFactorAuditService::class);
252+
$auditMock->shouldReceive('log')
253+
->andReturnUsing(function (User $user, string $eventType) {
254+
// Allow challenge_issued (postLogin) so the challenge is created;
255+
// blow up only on the post-success event.
256+
if ($eventType === TwoFactorAuditLog::EventChallengeSucceeded) {
257+
throw new \Exception('audit sink unavailable');
258+
}
259+
});
260+
$this->app->instance(ITwoFactorAuditService::class, $auditMock);
261+
262+
$this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
263+
$code = $this->latestOtpCode(self::ADMIN_EMAIL);
264+
265+
$response = $this->verify($code);
266+
267+
$this->assertEquals(302, $response->getStatusCode(), 'a best-effort audit failure must not fail the login');
268+
$this->assertTrue(Auth::check(), 'session must be established despite the audit failure');
269+
}
270+
271+
public function testDeviceTrustFailureDoesNotBlockLogin(): void
272+
{
273+
// Device-trust enrollment is best-effort: by the time it runs the OTP is
274+
// already redeemed and the session established, so a failure must NOT 500
275+
// the user (which would lock them out on retry against a now-burned OTP),
276+
// and the pending MFA state must still be cleared.
277+
$deviceTrustMock = \Mockery::mock(IDeviceTrustService::class);
278+
// Gate path: no cookie -> not trusted, so the challenge is still issued.
279+
$deviceTrustMock->shouldReceive('isDeviceTrusted')->andReturn(false);
280+
// Enrollment blows up AFTER the OTP has been redeemed and the session set.
281+
$deviceTrustMock->shouldReceive('trustDevice')
282+
->andThrow(new \Exception('trusted-device store unavailable'));
283+
$this->app->instance(IDeviceTrustService::class, $deviceTrustMock);
284+
285+
$this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
286+
$code = $this->latestOtpCode(self::ADMIN_EMAIL);
287+
288+
$response = $this->verify($code, true); // trust_device = true
289+
290+
$this->assertEquals(302, $response->getStatusCode(), 'a best-effort device-trust failure must not fail the login');
291+
$this->assertTrue(Auth::check(), 'session must be established despite the device-trust failure');
292+
$this->assertNull(Session::get('2fa_pending_user_id'), 'pending MFA state must be cleared even when device-trust enrollment fails');
293+
}
294+
242295
// -------------------------------------------------------------------------
243296
// recovery codes
244297
// -------------------------------------------------------------------------
@@ -311,6 +364,21 @@ public function testVerifyRateLimitBlocksAfterThreshold(): void
311364
$this->assertSame('mfa_rate_limit', $payload['error_code']);
312365
}
313366

367+
public function testRecoveryRateLimitBlocksAfterThreshold(): void
368+
{
369+
$this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
370+
371+
$max = (int) Config::get('two_factor.rate_limit.max_attempts');
372+
for ($i = 0; $i < $max; $i++) {
373+
$this->recovery('bad-recovery-' . $i);
374+
}
375+
376+
$response = $this->recovery('bad-recovery-final');
377+
$this->assertResponseStatus(429);
378+
$payload = json_decode($response->getContent(), true);
379+
$this->assertSame('mfa_rate_limit', $payload['error_code']);
380+
}
381+
314382
public function testResendRateLimitBlocksAfterThreshold(): void
315383
{
316384
$this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);

0 commit comments

Comments
 (0)