true, 'auth-ui.features.two_factor_required' => false, 'fortify.features' => [Features::twoFactorAuthentication()], 'fortify-options.two-factor-authentication' => ['confirm' => true], 'auth-ui.providers.github' => [ 'label' => 'GitHub', 'icon' => 'i-simple-icons-github', 'enabled' => true, ], ]); }); function enableTwoFactorForTest(User $user): array { app(EnableTwoFactorAuthentication::class)($user); $user->forceFill(['two_factor_confirmed_at' => now()])->save(); $user->refresh(); $secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret); return [ 'code' => app(Google2FA::class)->getCurrentOtp($secret), 'recovery_codes' => $user->recoveryCodes(), ]; } it('keeps the profile available while two-factor settings are disabled', function () { config(['auth-ui.features.two_factor' => false]); $this->actingAs(User::factory()->create()) ->get('/profile') ->assertInertia(fn (Assert $page) => $page ->component('Profile/Show') ->where('twoFactor.available', false) ->where('twoFactor.enabled', false) ->where('twoFactor.pending', false)); }); it('redirects the former security page to the profile', function () { $this->actingAs(User::factory()->create()) ->get('/security') ->assertRedirect('/profile'); }); it('bypasses stored two-factor configuration when the feature is disabled', function () { $user = User::factory()->create([ 'email' => 'test@example.com', 'password' => 'password123', ]); enableTwoFactorForTest($user); config(['auth-ui.features.two_factor' => false]); $this->post('/login', [ 'login' => 'test@example.com', 'password' => 'password123', ])->assertRedirect('/dashboard'); $this->assertAuthenticatedAs($user); }); it('redirects authenticated users to enrollment when two-factor setup is mandatory', function () { config(['auth-ui.features.two_factor_required' => true]); $user = User::factory()->create(); $this->actingAs($user) ->get('/dashboard') ->assertRedirect('/profile') ->assertSessionHas('error', 'Two-factor authentication is required before you can continue.'); $this->actingAs($user) ->get('/profile') ->assertOk(); $this->actingAs($user) ->post('/logout') ->assertRedirect('/'); $this->assertGuest(); }); it('keeps application access restricted while mandatory enrollment is pending', function () { config(['auth-ui.features.two_factor_required' => true]); $user = User::factory()->create(['password' => 'password123']); $this->actingAs($user) ->post('/profile/two-factor', ['password' => 'password123']) ->assertRedirect('/profile'); expect($user->fresh()->two_factor_secret)->not->toBeNull() ->and($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); $this->actingAs($user) ->get('/dashboard') ->assertRedirect('/profile'); }); it('allows normal access after mandatory enrollment is complete', function () { config(['auth-ui.features.two_factor_required' => true]); $user = User::factory()->create(); enableTwoFactorForTest($user); $this->actingAs($user) ->get('/dashboard') ->assertOk(); }); it('does not enforce the mandatory flag while the main two-factor feature is disabled', function () { config([ 'auth-ui.features.two_factor' => false, 'auth-ui.features.two_factor_required' => true, ]); $this->actingAs(User::factory()->create()) ->get('/dashboard') ->assertOk(); }); it('blocks authenticated API requests until mandatory enrollment is complete', function () { config(['auth-ui.features.two_factor_required' => true]); $user = User::factory()->create(); $this->actingAs($user) ->getJson('/api/user') ->assertForbidden() ->assertJson([ 'message' => 'Two-factor authentication setup is required.', 'setup_url' => route('profile.show'), ]); enableTwoFactorForTest($user); $this->actingAs($user->fresh()) ->getJson('/api/user') ->assertOk() ->assertJsonPath('id', $user->id); }); it('requires the current password before a password user can enroll', function () { $user = User::factory()->create(['password' => 'password123']); $this->actingAs($user) ->post('/profile/two-factor', ['password' => 'wrong-password']) ->assertSessionHasErrors('password'); expect($user->fresh()->two_factor_secret)->toBeNull(); $this->actingAs($user) ->post('/profile/two-factor', ['password' => 'password123']) ->assertRedirect('/profile'); expect($user->fresh()->two_factor_secret)->not->toBeNull() ->and($user->fresh()->two_factor_confirmed_at)->toBeNull(); $this->actingAs($user) ->get('/profile') ->assertInertia(fn (Assert $page) => $page ->component('Profile/Show') ->where('twoFactor.pending', true) ->where('twoFactor.enabled', false) ->where('twoFactor.requiresPassword', true) ->where('twoFactor.qrCodeDataUri', fn ($value) => str_starts_with($value, 'data:image/svg+xml;base64,')) ->where('twoFactor.secretKey', fn ($value) => filled($value))); }); it('confirms enrollment and returns recovery codes once', function () { $user = User::factory()->create(); app(EnableTwoFactorAuthentication::class)($user); $user->refresh(); $secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret); $code = app(Google2FA::class)->getCurrentOtp($secret); $this->actingAs($user) ->post('/profile/two-factor/confirm', ['code' => $code]) ->assertRedirect('/profile') ->assertSessionHas('recoveryCodes', fn (array $codes) => count($codes) === 8); expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue(); }); it('holds password login until a valid authenticator code is supplied', function () { $user = User::factory()->create([ 'email' => 'test@example.com', 'password' => 'password123', ]); $twoFactor = enableTwoFactorForTest($user); $loginResponse = $this->post('/login', [ 'login' => 'test@example.com', 'password' => 'password123', 'remember' => true, ]); $loginResponse->assertRedirect('/two-factor-challenge'); $this->assertGuest(); $loginResponse->assertSessionHas('login.id', $user->id); $this->get('/two-factor-challenge') ->assertInertia(fn (Assert $page) => $page->component('Auth/TwoFactorChallenge')); $this->post('/two-factor-challenge', ['code' => '000000']) ->assertSessionHasErrors('code'); $this->assertGuest(); $challengeResponse = $this->post('/two-factor-challenge', ['code' => $twoFactor['code']]); $challengeResponse->assertRedirect('/dashboard'); $this->assertAuthenticatedAs($user); $challengeResponse->assertSessionMissing('login.id'); }); it('accepts a recovery code once and replaces it after login', function () { $user = User::factory()->create([ 'email' => 'test@example.com', 'password' => 'password123', ]); $twoFactor = enableTwoFactorForTest($user); $recoveryCode = $twoFactor['recovery_codes'][0]; $this->post('/login', [ 'login' => 'test@example.com', 'password' => 'password123', ])->assertRedirect('/two-factor-challenge'); $this->post('/two-factor-challenge', ['recovery_code' => $recoveryCode]) ->assertRedirect('/dashboard'); $this->assertAuthenticatedAs($user); expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode); }); it('holds an existing social login at the same two-factor challenge', function () { $user = User::factory()->social()->create(); $user->socialAccounts()->create([ 'provider' => 'github', 'provider_id' => 'github-2fa', ]); enableTwoFactorForTest($user); Socialite::fake('github', (new SocialiteUser)->map([ 'id' => 'github-2fa', 'name' => 'Social User', 'email' => $user->email, 'nickname' => $user->username, ])); $response = $this->get('/auth/github/callback'); $response->assertRedirect('/two-factor-challenge'); $this->assertGuest(); $response->assertSessionHas('login.id', $user->id); }); it('requires a valid second factor for social-only users to disable protection', function () { $user = User::factory()->social()->create(); $twoFactor = enableTwoFactorForTest($user); $this->actingAs($user) ->delete('/profile/two-factor', ['code' => 'invalid']) ->assertSessionHasErrors('code'); expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue(); $this->actingAs($user) ->delete('/profile/two-factor', ['code' => $twoFactor['code']]) ->assertRedirect('/profile'); expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); }); it('consumes recovery codes used to authorize a sensitive settings action', function () { $user = User::factory()->social()->create(); $twoFactor = enableTwoFactorForTest($user); $recoveryCode = $twoFactor['recovery_codes'][0]; $this->actingAs($user) ->post('/profile/two-factor/recovery-codes', [ 'code' => $recoveryCode, 'regenerate' => false, ]) ->assertRedirect('/profile') ->assertSessionHas('recoveryCodes'); expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode); }); it('seeds a user matching the current user table structure', function () { $this->seed(DatabaseSeeder::class); $this->assertDatabaseHas('users', [ 'username' => 'testuser', 'first_name' => 'Test', 'last_name' => 'User', 'email' => 'test@example.com', ]); });