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