diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 35110cc..927d227 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -86,16 +86,14 @@ class Index extends Component #[On('twoFactorDisabled')] public function disableTwoFactor(): void { - // Reset the whole second factor: clearing 2FA must also drop the backup codes - // and security keys, so re-enrolling later starts clean (old keys/codes never - // silently become valid again). + // Remove the TOTP factor only — security keys are managed on their own card. If no + // factor remains afterwards, the backup codes are dropped too. Auth::user()->forceFill([ 'two_factor_secret' => null, 'two_factor_confirmed_at' => null, - 'two_factor_recovery_codes' => null, ])->save(); - Auth::user()->webauthnCredentials()->delete(); + Auth::user()->resetIfNoFactor(); } private function audit(string $action, string $target): void diff --git a/app/Livewire/Settings/WebauthnKeys.php b/app/Livewire/Settings/WebauthnKeys.php index 5edf1cf..b606cb0 100644 --- a/app/Livewire/Settings/WebauthnKeys.php +++ b/app/Livewire/Settings/WebauthnKeys.php @@ -17,7 +17,7 @@ class WebauthnKeys extends Component /** JSON creation options for navigator.credentials.create (the JS posts the result to register). */ public function options(WebauthnService $webauthn): array { - abort_unless($webauthn->available() && Auth::user()->hasTwoFactorEnabled(), 404); + abort_unless($webauthn->available(), 404); // Validate the label BEFORE the browser ceremony, so an invalid name never // leaves an orphaned credential on the authenticator. $this->validate(); @@ -27,9 +27,8 @@ class WebauthnKeys extends Component public function register(array $attestation, WebauthnService $webauthn): void { - // WebAuthn must be available; no existing-factor guard here — registering a key - // may itself be the FIRST factor enrollment (the guard in options() already - // vetted the ceremony start; only require availability at submission time). + // WebAuthn must be available (domain + HTTPS). No existing-factor guard — a key may + // be the FIRST/only factor. abort_unless($webauthn->available(), 404); $this->validate(); @@ -95,13 +94,14 @@ class WebauthnKeys extends Component ]); $this->dispatch('notify', message: __('auth.webauthn_removed')); + Auth::user()->resetIfNoFactor(); } } public function render() { return view('livewire.settings.webauthn-keys', [ - 'available' => app(WebauthnService::class)->available() && Auth::user()->hasTwoFactorEnabled(), + 'available' => app(WebauthnService::class)->available(), 'keys' => Auth::user()->webauthnCredentials()->latest()->get(), ]); } diff --git a/lang/de/settings.php b/lang/de/settings.php index 234b60e..355994f 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -34,15 +34,17 @@ return [ 'twofa_hint_on' => 'Der Login erfordert einen TOTP-Code.', 'twofa_hint_off' => 'Empfohlen — schützt den Login mit einem zweiten Faktor.', 'twofa_setup' => 'Einrichten', + 'twofa_recommended' => '2FA ist optional, aber empfohlen — sichere dein Konto mit einem Authenticator oder Security-Key.', + 'twofa_remove_totp' => 'Authenticator entfernen', // Notifications 'profile_saved' => 'Profil gespeichert.', 'password_changed' => 'Passwort geändert.', // Disable-2FA confirmation modal - 'disable_2fa_heading' => '2FA deaktivieren', - 'disable_2fa_body' => 'Die Zwei-Faktor-Authentifizierung wird entfernt. Dein Konto ist dann nur noch per Passwort geschützt.', - 'disable_2fa_notify' => '2FA deaktiviert.', + 'disable_2fa_heading' => 'Authenticator entfernen', + 'disable_2fa_body' => 'Den Authenticator (TOTP) als Faktor entfernen? Security-Keys bleiben bestehen.', + 'disable_2fa_notify' => 'Authenticator entfernt.', // Page title 'title' => 'Einstellungen — Clusev', diff --git a/lang/en/settings.php b/lang/en/settings.php index 98e8e11..8937ce5 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -34,15 +34,17 @@ return [ 'twofa_hint_on' => 'Login requires a TOTP code.', 'twofa_hint_off' => 'Recommended — protects login with a second factor.', 'twofa_setup' => 'Set up', + 'twofa_recommended' => '2FA is optional but recommended — secure your account with an authenticator or a security key.', + 'twofa_remove_totp' => 'Remove authenticator', // Notifications 'profile_saved' => 'Profile saved.', 'password_changed' => 'Password changed.', // Disable-2FA confirmation modal - 'disable_2fa_heading' => 'Disable 2FA', - 'disable_2fa_body' => 'Two-factor authentication will be removed. Your account will then be protected by password only.', - 'disable_2fa_notify' => '2FA disabled.', + 'disable_2fa_heading' => 'Remove authenticator', + 'disable_2fa_body' => 'Remove the authenticator (TOTP) as a factor? Security keys are kept.', + 'disable_2fa_notify' => 'Authenticator removed.', // Page title 'title' => 'Settings — Clusev', diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index cd081af..4929591 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -100,24 +100,33 @@ + @unless ($twoFactorEnabled) +
+ +

{{ __('settings.twofa_recommended') }}

+
+ @endunless +
- +
-

{{ $twoFactorEnabled ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}

-

{{ $twoFactorEnabled ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}

+

{{ $hasTotp ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}

+

{{ $hasTotp ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}

- @if ($twoFactorEnabled) -
+
+ @if ($twoFactorEnabled) {{ __('auth.recovery_manage') }} - {{ __('common.disable') }} -
- @else - - {{ __('settings.twofa_setup') }} - - @endif + @endif + @if ($hasTotp) + {{ __('settings.twofa_remove_totp') }} + @else + + {{ __('settings.twofa_setup') }} + + @endif +
diff --git a/tests/Feature/SettingsFactorManagementTest.php b/tests/Feature/SettingsFactorManagementTest.php new file mode 100644 index 0000000..7a0fd16 --- /dev/null +++ b/tests/Feature/SettingsFactorManagementTest.php @@ -0,0 +1,80 @@ +create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); + $user->replaceRecoveryCodes(); + + Livewire::actingAs($user)->test(Index::class)->call('disableTwoFactor'); + + $fresh = $user->fresh(); + $this->assertFalse($fresh->hasTotp()); + $this->assertFalse($fresh->hasRecoveryCodes(), 'last factor removed → codes cleared'); + } + + public function test_removing_totp_keeps_codes_when_a_key_remains(): void + { + $user = User::factory()->create(['two_factor_secret' => 'S', 'two_factor_confirmed_at' => now()]); + WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); + $user->replaceRecoveryCodes(); + + Livewire::actingAs($user->fresh())->test(Index::class)->call('disableTwoFactor'); + + $fresh = $user->fresh(); + $this->assertFalse($fresh->hasTotp()); + $this->assertTrue($fresh->hasWebauthnCredentials()); + $this->assertTrue($fresh->hasRecoveryCodes(), 'key remains → codes kept'); + } + + public function test_a_key_can_be_the_first_factor_without_existing_2fa(): void + { + $user = User::factory()->create(); + + $svc = Mockery::mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturnTrue(); + $svc->shouldReceive('registrationOptions')->andReturn(['ok' => true]); + app()->instance(WebauthnService::class, $svc); + + Livewire::actingAs($user)->test(WebauthnKeys::class) + ->set('newName', 'YubiKey') + ->call('options', $svc) + ->assertReturned(['ok' => true]); + } + + public function test_removing_the_last_key_clears_codes(): void + { + $user = User::factory()->create(); + $cred = WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); + $user->replaceRecoveryCodes(); + + $svc = Mockery::mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturnTrue(); + app()->instance(WebauthnService::class, $svc); + + Livewire::actingAs($user->fresh())->test(WebauthnKeys::class)->call('remove', $cred->id, $svc); + + $this->assertFalse($user->fresh()->hasRecoveryCodes()); + } +}