feat(2fa): Settings manages TOTP + keys independently; key can be only factor; empfohlen hint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 21:21:28 +02:00
parent aa43947dcf
commit 00337e038c
6 changed files with 119 additions and 28 deletions

View File

@ -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

View File

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

View File

@ -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',

View File

@ -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',

View File

@ -100,24 +100,33 @@
</x-panel>
<x-panel :title="__('settings.twofa_title')" :subtitle="__('settings.twofa_subtitle')">
@unless ($twoFactorEnabled)
<div class="mb-4 flex items-start gap-2.5 rounded-md border border-accent/25 bg-accent/10 px-4 py-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<p class="text-sm text-ink-2">{{ __('settings.twofa_recommended') }}</p>
</div>
@endunless
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<x-status-dot :status="$twoFactorEnabled ? 'online' : 'offline'" />
<x-status-dot :status="$hasTotp ? 'online' : 'offline'" />
<div>
<p class="text-sm text-ink">{{ $twoFactorEnabled ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $twoFactorEnabled ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}</p>
<p class="text-sm text-ink">{{ $hasTotp ? __('settings.twofa_status_on') : __('settings.twofa_status_off') }}</p>
<p class="font-mono text-[11px] text-ink-3">{{ $hasTotp ? __('settings.twofa_hint_on') : __('settings.twofa_hint_off') }}</p>
</div>
</div>
@if ($twoFactorEnabled)
<div class="flex items-center gap-2">
<div class="flex items-center gap-2">
@if ($twoFactorEnabled)
<x-btn variant="secondary" wire:click="$dispatch('openModal', { component: 'modals.recovery-codes' })">{{ __('auth.recovery_manage') }}</x-btn>
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('common.disable') }}</x-btn>
</div>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}
</x-btn>
@endif
@endif
@if ($hasTotp)
<x-btn variant="danger-soft" wire:click="confirmDisableTwoFactor">{{ __('settings.twofa_remove_totp') }}</x-btn>
@else
<x-btn variant="accent" :href="route('two-factor.setup')" wire:navigate>
<x-icon name="shield" class="h-3.5 w-3.5" /> {{ __('settings.twofa_setup') }}
</x-btn>
@endif
</div>
</div>
</x-panel>

View File

@ -0,0 +1,80 @@
<?php
namespace Tests\Feature;
use App\Livewire\Settings\Index;
use App\Livewire\Settings\WebauthnKeys;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class SettingsFactorManagementTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_removing_totp_when_it_is_the_only_factor_clears_codes(): void
{
$user = User::factory()->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());
}
}