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
parent
aa43947dcf
commit
00337e038c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue