Files
crewli/api/tests/Feature/Auth/MfaLoginFlowTest.php
bert.hausmans f1a8591d17 fix: critical MFA bypass — old auth tokens survive MFA challenge
SECURITY: A user with MFA enabled could bypass the MFA challenge by
using a pre-existing auth cookie from a previous session.

Vulnerability chain:
1. Auth::attempt() in LoginController created a Laravel session
   (unnecessary side effect — only credential validation was needed)
2. When MFA was required, the response did NOT revoke existing
   Sanctum tokens or expire the auth cookie
3. If the MFA session expired, the user could navigate directly to
   any page and the old auth cookie would authenticate them

Fixes:
- Replace Auth::attempt() with Hash::check() — no session created
- Revoke ALL existing Sanctum tokens when MFA is required, so old
  sessions cannot bypass the challenge
- Expire the auth cookie in the MFA-required response via
  forgetAuthCookie(), ensuring the browser discards stale tokens
- Auth is now ONLY issued after successful MFA verification in
  MfaVerifyController

New security tests (11 added):
- MFA login returns no auth token or user data
- MFA login expires the auth cookie
- MFA login revokes all existing tokens
- Old token returns 401 after MFA login
- MFA session token cannot be used as Bearer token
- MFA session consumed after successful verify (no replay)
- MFA session survives failed verify (user can retry)
- Auth cookie only issued on successful MFA verify
- MFA session expires after TTL (10 minutes)
- Email codes consumed after use (no replay)
- Trusted device expires after 30 days

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:49:51 +02:00

566 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Enums\MfaMethod;
use App\Models\MfaEmailCode;
use App\Models\TrustedDevice;
use App\Models\User;
use App\Services\MfaService;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class MfaLoginFlowTest extends TestCase
{
use RefreshDatabase;
private Google2FA $google2fa;
protected function setUp(): void
{
parent::setUp();
$this->google2fa = new Google2FA();
}
public function test_login_without_mfa_returns_token_directly(): void
{
$user = User::factory()->create();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertOk()
->assertJsonStructure([
'success',
'data' => ['user' => ['id', 'email']],
])
->assertJsonMissing(['mfa_required' => true]);
}
public function test_login_with_mfa_returns_mfa_required_response(): void
{
$user = $this->createUserWithTotp();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertOk()
->assertJson(['mfa_required' => true])
->assertJsonStructure([
'mfa_session_token',
'methods',
'preferred_method',
'expires_in',
]);
$this->assertContains('totp', $response->json('methods'));
$this->assertContains('backup_code', $response->json('methods'));
}
public function test_login_with_trusted_device_skips_mfa(): void
{
$user = $this->createUserWithTotp();
$fingerprint = 'trusted-device-fingerprint';
// Trust the device
app(MfaService::class)->trustDevice($user, $fingerprint, '127.0.0.1', 'Test Device');
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['X-Device-Fingerprint' => $fingerprint]);
$response->assertOk()
->assertJsonStructure([
'data' => ['user' => ['id', 'email']],
])
->assertJsonMissing(['mfa_required' => true]);
}
public function test_mfa_verify_totp_issues_token(): void
{
$user = $this->createUserWithTotp();
$secret = decrypt($user->mfa_secret);
// Login to get MFA session
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
$validCode = $this->google2fa->getCurrentOtp($secret);
$response = $this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $validCode,
'method' => 'totp',
]);
$response->assertOk()
->assertJsonStructure([
'data' => ['user' => ['id', 'email']],
]);
}
public function test_mfa_verify_email_code_issues_token(): void
{
$user = User::factory()->create([
'mfa_enabled' => true,
'mfa_method' => MfaMethod::EMAIL->value,
'mfa_confirmed_at' => now(),
]);
// Login to get MFA session
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
// Get the email code that was auto-sent
$emailCode = MfaEmailCode::where('user_id', $user->id)
->where('used', false)
->first();
$response = $this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $emailCode->code,
'method' => 'email',
]);
$response->assertOk()
->assertJsonStructure([
'data' => ['user' => ['id', 'email']],
]);
}
public function test_mfa_verify_backup_code_issues_token(): void
{
$user = $this->createUserWithTotp();
// Get a backup code
$backupCode = \App\Models\MfaBackupCode::where('user_id', $user->id)
->where('used', false)
->first();
// We need to know the plain code, so regenerate
$plainCodes = app(MfaService::class)->regenerateBackupCodes($user);
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
$response = $this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $plainCodes[0],
'method' => 'backup_code',
]);
$response->assertOk()
->assertJsonStructure([
'data' => ['user' => ['id', 'email']],
]);
}
public function test_mfa_verify_with_trust_device_creates_trusted_device(): void
{
$user = $this->createUserWithTotp();
$secret = decrypt($user->mfa_secret);
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
$validCode = $this->google2fa->getCurrentOtp($secret);
$response = $this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $validCode,
'method' => 'totp',
'trust_device' => true,
'device_fingerprint' => 'my-device-fp',
'device_name' => 'Test Browser',
]);
$response->assertOk();
$this->assertDatabaseHas('trusted_devices', [
'user_id' => $user->id,
'device_name' => 'Test Browser',
]);
}
public function test_mfa_verify_expired_session_fails(): void
{
$response = $this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => 'expired-token',
'code' => '123456',
'method' => 'totp',
]);
$response->assertStatus(422)
->assertJson(['message' => 'MFA-sessie verlopen. Log opnieuw in.']);
}
public function test_mfa_verify_wrong_code_fails(): void
{
$user = $this->createUserWithTotp();
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
$response = $this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => '000000',
'method' => 'totp',
]);
$response->assertStatus(422)
->assertJson(['message' => 'Ongeldige verificatiecode.']);
}
public function test_login_mfa_required_but_not_setup_flags_setup_required(): void
{
$this->seed(RoleSeeder::class);
$user = User::factory()->create();
$user->assignRole('super_admin');
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertOk()
->assertJson(['mfa_setup_required' => true])
->assertJsonStructure([
'data' => ['user' => ['id', 'email']],
]);
}
// ─── Security tests: no auth before MFA ───
public function test_login_with_mfa_does_not_return_auth_token(): void
{
$user = $this->createUserWithTotp();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertOk()
->assertJson(['mfa_required' => true])
->assertJsonMissing(['data' => ['user' => []]])
->assertJsonStructure(['mfa_session_token', 'methods']);
// No new Sanctum tokens should have been created
$this->assertDatabaseCount('personal_access_tokens', 0);
}
public function test_login_with_mfa_expires_auth_cookie(): void
{
$user = $this->createUserWithTotp();
$response = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$response->assertOk()
->assertJson(['mfa_required' => true]);
// The response must include a Set-Cookie that expires the auth cookie
$cookies = $response->headers->getCookies();
$authCookie = collect($cookies)->first(fn ($c) => str_starts_with($c->getName(), 'crewli_'));
$this->assertNotNull($authCookie, 'Response must include an auth cookie header');
$this->assertTrue(
$authCookie->getExpiresTime() < time(),
'Auth cookie must be expired (forget cookie)',
);
}
public function test_login_with_mfa_revokes_existing_tokens(): void
{
$user = $this->createUserWithTotp();
// Create a pre-existing token (simulate previous login)
$oldToken = $user->createToken('old-session')->plainTextToken;
$this->assertDatabaseCount('personal_access_tokens', 1);
// Login triggers MFA → should revoke old tokens
$this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
])->assertJson(['mfa_required' => true]);
$this->assertDatabaseCount('personal_access_tokens', 0);
}
public function test_old_token_cannot_access_api_after_mfa_login(): void
{
$user = $this->createUserWithTotp();
// Create a pre-existing token
$oldToken = $user->createToken('old-session')->plainTextToken;
$this->assertDatabaseCount('personal_access_tokens', 1);
// Login triggers MFA → revokes tokens
$this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
])->assertJson(['mfa_required' => true]);
// Token record deleted from database
$this->assertDatabaseCount('personal_access_tokens', 0);
// Reset the auth guard so it re-queries the database for the token
app('auth')->forgetGuards();
// Old token must no longer work
$this->withHeader('Authorization', 'Bearer ' . $oldToken)
->getJson('/api/v1/auth/me')
->assertUnauthorized();
}
public function test_mfa_session_token_cannot_be_used_as_auth_token(): void
{
$user = $this->createUserWithTotp();
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$mfaSessionToken = $loginResponse->json('mfa_session_token');
// Try to use the MFA session token as a Bearer token
$this->withHeader('Authorization', 'Bearer ' . $mfaSessionToken)
->getJson('/api/v1/auth/me')
->assertUnauthorized();
}
public function test_mfa_session_consumed_after_successful_verify(): void
{
$user = $this->createUserWithTotp();
$secret = decrypt($user->mfa_secret);
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
$validCode = $this->google2fa->getCurrentOtp($secret);
// First verify succeeds
$this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $validCode,
'method' => 'totp',
])->assertOk();
// Second verify with same session token fails
$this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $validCode,
'method' => 'totp',
])->assertStatus(422)
->assertJson(['message' => 'MFA-sessie verlopen. Log opnieuw in.']);
}
public function test_mfa_session_not_consumed_by_failed_verify(): void
{
$user = $this->createUserWithTotp();
$secret = decrypt($user->mfa_secret);
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
// Wrong code — session should survive
$this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => '000000',
'method' => 'totp',
])->assertStatus(422);
// Correct code — session should still be valid
$validCode = $this->google2fa->getCurrentOtp($secret);
$this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $validCode,
'method' => 'totp',
])->assertOk();
}
public function test_mfa_verify_returns_auth_cookie_only_on_success(): void
{
$user = $this->createUserWithTotp();
$secret = decrypt($user->mfa_secret);
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
// Failed verify — no auth cookie
$failResponse = $this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => '000000',
'method' => 'totp',
]);
$failResponse->assertStatus(422);
$failCookies = collect($failResponse->headers->getCookies());
$failAuthCookie = $failCookies->first(fn ($c) => str_starts_with($c->getName(), 'crewli_') && $c->getExpiresTime() > time());
$this->assertNull($failAuthCookie, 'Failed verify must not set an auth cookie');
// Successful verify — auth cookie present
$validCode = $this->google2fa->getCurrentOtp($secret);
$successResponse = $this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $validCode,
'method' => 'totp',
]);
$successResponse->assertOk();
$successCookies = collect($successResponse->headers->getCookies());
$successAuthCookie = $successCookies->first(fn ($c) => str_starts_with($c->getName(), 'crewli_') && $c->getExpiresTime() > time());
$this->assertNotNull($successAuthCookie, 'Successful verify must set an auth cookie');
}
public function test_mfa_session_expires_after_ttl(): void
{
$user = $this->createUserWithTotp();
$secret = decrypt($user->mfa_secret);
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
// Travel past the 10-minute TTL
$this->travel(11)->minutes();
$validCode = $this->google2fa->getCurrentOtp($secret);
$this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $validCode,
'method' => 'totp',
])->assertStatus(422)
->assertJson(['message' => 'MFA-sessie verlopen. Log opnieuw in.']);
}
public function test_email_code_consumed_after_use(): void
{
$user = User::factory()->create([
'mfa_enabled' => true,
'mfa_method' => MfaMethod::EMAIL->value,
'mfa_confirmed_at' => now(),
]);
$loginResponse = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken = $loginResponse->json('mfa_session_token');
$emailCode = MfaEmailCode::where('user_id', $user->id)
->where('used', false)
->first();
// First use succeeds
$this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken,
'code' => $emailCode->code,
'method' => 'email',
])->assertOk();
// Get a new MFA session for the replay test
$loginResponse2 = $this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
]);
$sessionToken2 = $loginResponse2->json('mfa_session_token');
// Same code cannot be reused
$this->postJson('/api/v1/auth/mfa/verify', [
'mfa_session_token' => $sessionToken2,
'code' => $emailCode->code,
'method' => 'email',
])->assertStatus(422);
}
public function test_trusted_device_expired_after_30_days_requires_mfa(): void
{
$user = $this->createUserWithTotp();
$fingerprint = 'test-device-fp';
app(MfaService::class)->trustDevice($user, $fingerprint, '127.0.0.1', 'Test');
// Within 30 days — no MFA
$this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['X-Device-Fingerprint' => $fingerprint])
->assertOk()
->assertJsonMissing(['mfa_required' => true]);
// After 31 days — MFA required
$this->travel(31)->days();
$this->postJson('/api/v1/auth/login', [
'email' => $user->email,
'password' => 'password',
], ['X-Device-Fingerprint' => $fingerprint])
->assertOk()
->assertJson(['mfa_required' => true]);
}
private function createUserWithTotp(): User
{
$user = User::factory()->create();
$mfaService = app(MfaService::class);
$setupResult = $mfaService->setupTotp($user);
$secret = $setupResult['secret'];
$validCode = $this->google2fa->getCurrentOtp($secret);
$mfaService->confirmTotp($user, $validCode);
return $user->refresh();
}
}