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(); } }