Rewrite 2FA tests for the pending-login refactor

The 2FA flow moved from a middleware-gated i/auth/checkpoint model to a
pending-login model (auth.pending session, POST /login/2fa, /login?step=2fa
challenge). The old tests referenced the removed route and dead session keys
(2fa.session.active, 2fa.attempts) and failed with 404s.

Rewritten against the new code as source of truth:
- Checkpoint test: throttle assertion retargeted to the login/2fa route;
  failed-verification audit log now driven through a pending 2FA session.
- Logout-session test: asserts auth.pending is cleared and the user stays a
  guest after MAX_2FA_ATTEMPTS failures (replacing the old flag cleanup).
- TwoFactorTest: challenge-redirect and challenge-page cases rewritten around
  the login flow; setup/recovery password-confirmation cases unchanged.
- MiddlewarePipelineTest: 2FA is enforced at login, not per-request, so an
  authenticated 2FA user browses normally.

Full suite: 715 passed.
pull/7176/head
Your Name 2 weeks ago
parent c943b13c25
commit 08a442e661

@ -2,6 +2,7 @@
use App\Models\AccountLog;
use App\Models\User;
use App\Services\PendingLoginService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Route;
use PragmaRX\Google2FA\Google2FA;
@ -16,11 +17,33 @@ uses(LazilyRefreshDatabase::class);
| The 2FA verify endpoint must be server-side rate limited (so re-login cannot
| reset an unlimited guess budget) and failed attempts must be audit-logged.
|
| The 2FA flow is a pending-login model: after valid credentials the user is
| NOT logged in but a pending session (auth.pending, step=2fa) is created, and
| the code is submitted to POST /login/2fa.
|
*/
/**
* Seed a pending 2FA login session for the given user, mirroring what
* PendingLoginService::start writes after credentials are verified.
*/
function pending2faSession(User $user, int $attempts = 0): array
{
return [
PendingLoginService::SESSION_KEY => [
'user_id' => $user->id,
'email' => $user->email,
'remember' => false,
'step' => PendingLoginService::STEP_2FA,
'attempts' => $attempts,
'expires_at' => now()->addSeconds(PendingLoginService::TTL_SECONDS)->getTimestamp(),
],
];
}
it('applies throttle middleware to the 2FA verify route', function () {
$route = collect(Route::getRoutes())->first(function ($r) {
return $r->uri() === 'i/auth/checkpoint' && in_array('POST', $r->methods());
return $r->uri() === 'login/2fa' && in_array('POST', $r->methods());
});
expect($route)->not->toBeNull();
@ -38,8 +61,9 @@ it('audit-logs a failed 2FA verification', function () {
$user = User::factory()->create(['2fa_secret' => $secret, '2fa_enabled' => true]);
$user->refresh();
$this->actingAs($user)
->post('/i/auth/checkpoint', ['code' => '000000']);
// A wrong 6-digit code against an active pending 2FA login.
$this->withSession(pending2faSession($user))
->post('/login/2fa', ['code' => '000000']);
expect(
AccountLog::where('user_id', $user->id)

@ -1,6 +1,7 @@
<?php
use App\Models\User;
use App\Services\PendingLoginService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Routing\Middleware\ThrottleRequests;
use PragmaRX\Google2FA\Google2FA;
@ -9,13 +10,12 @@ uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| 2FA forced-logout session cleanup
| 2FA forced-logout / pending-session cleanup
|--------------------------------------------------------------------------
|
| When repeated failed 2FA attempts force a logout, the 2fa.session.active
| flag must be cleared. Otherwise it survives logout/login (session data is
| preserved across regenerate) and lets the next user skip 2FA on a shared
| session.
| When repeated failed 2FA attempts hit the limit, the pending login state
| (auth.pending) must be discarded so a fresh sign-in is required. Otherwise a
| stale pending session could let a subsequent request resume the challenge.
|
*/
@ -23,22 +23,32 @@ beforeEach(function () {
$this->withoutMiddleware(ThrottleRequests::class);
});
it('clears the 2fa.session.active flag when forced logout occurs', function () {
it('clears the pending login state after the final failed attempt', function () {
$google2fa = new Google2FA;
$secret = $google2fa->generateSecretKey();
$user = User::factory()->create(['2fa_secret' => $secret, '2fa_enabled' => true]);
$user->refresh();
$this->actingAs($user)
->withSession([
'2fa.attempts' => 3,
'2fa.session.active' => [true],
])
->post('/i/auth/checkpoint', ['code' => '000000'])
->assertRedirect('/');
// The forced logout must have cleared the 2FA session flag.
expect(session()->has('2fa.session.active'))->toBeFalse();
expect(session()->has('2fa.attempts'))->toBeFalse();
// Already at MAX_2FA_ATTEMPTS - 1 failures; the next wrong code trips the limit.
$attempts = PendingLoginService::MAX_2FA_ATTEMPTS - 1;
$this->withSession([
PendingLoginService::SESSION_KEY => [
'user_id' => $user->id,
'email' => $user->email,
'remember' => false,
'step' => PendingLoginService::STEP_2FA,
'attempts' => $attempts,
'expires_at' => now()->addSeconds(PendingLoginService::TTL_SECONDS)->getTimestamp(),
],
])
->post('/login/2fa', ['code' => '000000'])
->assertRedirect(route('login'));
// The pending login must have been discarded, forcing a fresh sign-in.
expect(session()->has(PendingLoginService::SESSION_KEY))->toBeFalse();
// And the user is not authenticated.
$this->assertGuest();
});

@ -1,6 +1,7 @@
<?php
use App\Models\User;
use App\Services\PendingLoginService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
uses(LazilyRefreshDatabase::class);
@ -9,29 +10,52 @@ uses(LazilyRefreshDatabase::class);
|--------------------------------------------------------------------------
| Two-Factor Authentication
|--------------------------------------------------------------------------
|
| 2FA uses a pending-login model: valid credentials for a 2FA-enabled user do
| NOT authenticate the session. Instead a pending login (auth.pending) is
| created and the user is sent to the challenge at /login?step=2fa, where a
| code is submitted to POST /login/2fa.
|
*/
it('redirects when 2fa is enabled and session is unverified', function () {
it('redirects a 2fa user to the challenge step after valid credentials', function () {
$user = User::factory()->create([
'2fa_enabled' => true,
'2fa_secret' => 'TESTSECRETKEY123',
]);
$user->refresh();
$this->actingAs($user)
->get('/settings/home')
->assertRedirect('/i/auth/checkpoint');
$this->post('/login', [
'email' => $user->email,
'password' => 'password',
])->assertRedirect(route('login', ['step' => PendingLoginService::STEP_2FA]));
// Credentials verified but the session is NOT yet authenticated.
$this->assertGuest();
// A pending 2FA login was recorded.
expect(session(PendingLoginService::SESSION_KEY.'.step'))
->toBe(PendingLoginService::STEP_2FA);
});
it('renders the 2fa checkpoint page for authenticated user', function () {
it('renders the 2fa challenge page for a pending login', function () {
$user = User::factory()->create([
'2fa_enabled' => true,
'2fa_secret' => 'TESTSECRETKEY123',
]);
$user->refresh();
$this->actingAs($user)
->get('/i/auth/checkpoint')
$this->withSession([
PendingLoginService::SESSION_KEY => [
'user_id' => $user->id,
'email' => $user->email,
'remember' => false,
'step' => PendingLoginService::STEP_2FA,
'attempts' => 0,
'expires_at' => now()->addSeconds(PendingLoginService::TTL_SECONDS)->getTimestamp(),
],
])
->get(route('login', ['step' => PendingLoginService::STEP_2FA]))
->assertOk();
});

@ -90,7 +90,10 @@ it('adds X-Frame-Options header via FrameGuard', function () {
->assertHeader('X-Frame-Options', 'SAMEORIGIN');
});
it('handles TwoFactorAuth middleware for users with 2fa', function () {
it('does not gate an already-authenticated 2fa user per-request', function () {
// 2FA is enforced at login time (pending-login model), not by a
// per-request middleware. An already-authenticated 2FA user browses
// normally -- no checkpoint redirect.
$user = User::factory()->create([
'2fa_enabled' => true,
'2fa_secret' => 'TESTSECRET123456',
@ -99,10 +102,10 @@ it('handles TwoFactorAuth middleware for users with 2fa', function () {
$this->actingAs($user)
->get('/settings/home')
->assertRedirect('/i/auth/checkpoint');
->assertOk();
});
it('passes TwoFactorAuth middleware for users without 2fa', function () {
it('allows an authenticated user without 2fa to browse', function () {
$user = User::factory()->create([
'2fa_enabled' => false,
]);

Loading…
Cancel
Save