feat(auth): make password rotation optional + correct the 2FA copy

The seeded-password rotation was forced before the panel was reachable. It is now a
nudge, not a wall: the prompt shows once per session and can be skipped, and a standing
warning banner keeps reminding while the default password is still in use.

- EnsureSecurityOnboarded redirects to the password page only when must_change_password
  AND the session has not set onboarding.password_skipped (set by PasswordChange::skip,
  which lands on the dashboard WITHOUT rotating — so the flag/banner persist).
- app layout shows a default-password warning banner while must_change_password is true.
- the metrics broadcast channel no longer gates on securityOnboarded() — a deliberately
  un-rotated (but authenticated) operator would otherwise lose realtime metrics;
  /broadcasting/auth still runs in the web group (guest = 403), so it stays auth-gated.
- 2FA was already optional (never enforced) — only the login copy was wrong: "2FA
  required/enforced" → "recommended/optional" (de + en).

Browser-verified (R12): login → skippable prompt → dashboard banner → free navigation
(no re-redirect); login copy correct; zero console errors. Full suite 465 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 19:47:17 +02:00
parent 83626352e2
commit 142b531d45
8 changed files with 74 additions and 11 deletions

View File

@ -7,8 +7,10 @@ use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
* After login, force the seeded-password rotation before the panel is reachable. * After login, NUDGE the operator to rotate the seeded password but do not force it. The prompt
* 2FA is optional (offered in Settings), so it is NOT forced here. * shows once per session and can be skipped (PasswordChange::skip sets `onboarding.password_skipped`);
* a persistent banner keeps warning while the default password is still in use. 2FA is optional too,
* so it is never forced here.
*/ */
class EnsureSecurityOnboarded class EnsureSecurityOnboarded
{ {
@ -16,7 +18,9 @@ class EnsureSecurityOnboarded
{ {
$user = $request->user(); $user = $request->user();
if ($user && $user->must_change_password && ! $request->routeIs('password.change', 'logout')) { if ($user && $user->must_change_password
&& ! $request->session()->get('onboarding.password_skipped')
&& ! $request->routeIs('password.change', 'logout')) {
return redirect()->route('password.change'); return redirect()->route('password.change');
} }

View File

@ -57,6 +57,18 @@ class PasswordChange extends Component
return $this->redirect(route('dashboard'), navigate: true); return $this->redirect(route('dashboard'), navigate: true);
} }
/**
* Keep the current password for now rotation is not forced. Remember the choice for this session
* so the prompt does not reappear; `must_change_password` stays true, so the persistent default-
* password banner keeps warning until the password is actually changed.
*/
public function skip()
{
session()->put('onboarding.password_skipped', true);
return $this->redirect(route('dashboard'), navigate: true);
}
public function render() public function render()
{ {
return view('livewire.auth.password-change')->title(__('auth.title_password_change')); return view('livewire.auth.password-change')->title(__('auth.title_password_change'));

View File

@ -15,7 +15,7 @@ return [
'login_submit' => 'Anmelden', 'login_submit' => 'Anmelden',
'checking' => 'Prüfe…', 'checking' => 'Prüfe…',
'secured' => 'Gesichert', 'secured' => 'Gesichert',
'security_note' => 'Verbindung über SSH · 2FA erforderlich · jede Aktion landet im Audit-Log.', 'security_note' => 'Verbindung über SSH · 2FA empfohlen · jede Aktion landet im Audit-Log.',
// ── Password change ────────────────────────────────────────────────── // ── Password change ──────────────────────────────────────────────────
'security' => 'Sicherheit', 'security' => 'Sicherheit',
@ -26,6 +26,9 @@ return [
'confirm_new_password' => 'Neues Passwort bestätigen', 'confirm_new_password' => 'Neues Passwort bestätigen',
'save_and_continue' => 'Speichern & weiter', 'save_and_continue' => 'Speichern & weiter',
'saving' => 'Speichere…', 'saving' => 'Speichere…',
'skip_for_now' => 'Vorerst überspringen',
'default_pw_warning' => 'Standard-Passwort aktiv. Aus Sicherheitsgründen solltest du es ändern.',
'default_pw_change' => 'Ändern',
// ── Two-factor (shared label) ──────────────────────────────────────── // ── Two-factor (shared label) ────────────────────────────────────────
'two_factor' => 'Zwei-Faktor', 'two_factor' => 'Zwei-Faktor',
@ -55,7 +58,7 @@ return [
'terminal_title' => 'clusev · sicherheitsmodell', 'terminal_title' => 'clusev · sicherheitsmodell',
'terminal_ssh' => 'agentenlos · exec + SFTP über phpseclib', 'terminal_ssh' => 'agentenlos · exec + SFTP über phpseclib',
'terminal_key' => 'Host-Keys via TOFU gepinnt', 'terminal_key' => 'Host-Keys via TOFU gepinnt',
'terminal_2fa' => 'erzwungen für jeden Login', 'terminal_2fa' => 'optional, aber empfohlen',
'terminal_log' => 'jede Aktion im Audit-Log', 'terminal_log' => 'jede Aktion im Audit-Log',
'terminal_core' => 'Open Core · AGPL-lizenziert', 'terminal_core' => 'Open Core · AGPL-lizenziert',
'footer_copyright' => '© :year Clusev · Fleet Control', 'footer_copyright' => '© :year Clusev · Fleet Control',

View File

@ -15,7 +15,7 @@ return [
'login_submit' => 'Sign in', 'login_submit' => 'Sign in',
'checking' => 'Checking…', 'checking' => 'Checking…',
'secured' => 'Secured', 'secured' => 'Secured',
'security_note' => 'Connection over SSH · 2FA required · every action lands in the audit log.', 'security_note' => 'Connection over SSH · 2FA recommended · every action lands in the audit log.',
// ── Password change ────────────────────────────────────────────────── // ── Password change ──────────────────────────────────────────────────
'security' => 'Security', 'security' => 'Security',
@ -26,6 +26,9 @@ return [
'confirm_new_password' => 'Confirm new password', 'confirm_new_password' => 'Confirm new password',
'save_and_continue' => 'Save & continue', 'save_and_continue' => 'Save & continue',
'saving' => 'Saving…', 'saving' => 'Saving…',
'skip_for_now' => 'Skip for now',
'default_pw_warning' => 'Default password in use. For security, you should change it.',
'default_pw_change' => 'Change',
// ── Two-factor (shared label) ──────────────────────────────────────── // ── Two-factor (shared label) ────────────────────────────────────────
'two_factor' => 'Two-factor', 'two_factor' => 'Two-factor',
@ -55,7 +58,7 @@ return [
'terminal_title' => 'clusev · security model', 'terminal_title' => 'clusev · security model',
'terminal_ssh' => 'agentless · exec + SFTP over phpseclib', 'terminal_ssh' => 'agentless · exec + SFTP over phpseclib',
'terminal_key' => 'host keys pinned via TOFU', 'terminal_key' => 'host keys pinned via TOFU',
'terminal_2fa' => 'enforced on every login', 'terminal_2fa' => 'optional, but recommended',
'terminal_log' => 'every action in the audit log', 'terminal_log' => 'every action in the audit log',
'terminal_core' => 'Open Core · AGPL-licensed', 'terminal_core' => 'Open Core · AGPL-licensed',
'footer_copyright' => '© :year Clusev · Fleet Control', 'footer_copyright' => '© :year Clusev · Fleet Control',

View File

@ -37,6 +37,16 @@
<div class="relative z-10 lg:pl-[272px]"> <div class="relative z-10 lg:pl-[272px]">
<x-topbar /> <x-topbar />
<main class="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8"> <main class="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{{-- Default-password warning: shown while the seeded password is still in use (rotation
is recommended but not forced). Disappears once the password is actually changed. --}}
@if (auth()->user()?->must_change_password)
<div class="mb-5 flex items-center gap-3 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-warning" />
<p class="min-w-0 flex-1 font-mono text-[12px] leading-relaxed text-ink-2">{{ __('auth.default_pw_warning') }}</p>
<a href="{{ route('password.change') }}" wire:navigate
class="shrink-0 rounded-md border border-warning/40 px-2.5 py-1 font-mono text-[11px] text-warning transition-colors hover:bg-warning/10">{{ __('auth.default_pw_change') }}</a>
</div>
@endif
{{ $slot }} {{ $slot }}
</main> </main>
</div> </div>

View File

@ -56,4 +56,10 @@
<span wire:loading wire:target="update">{{ __('auth.saving') }}</span> <span wire:loading wire:target="update">{{ __('auth.saving') }}</span>
</x-btn> </x-btn>
</form> </form>
{{-- Rotation is recommended, not forced the operator may keep the current password for now. --}}
<button type="button" wire:click="skip" wire:target="skip" wire:loading.attr="disabled"
class="-mt-2 w-full text-center font-mono text-[11px] text-ink-4 transition-colors hover:text-ink-2">
{{ __('auth.skip_for_now') }}
</button>
</div> </div>

View File

@ -28,7 +28,9 @@ use Illuminate\Support\Facades\Broadcast;
| |
*/ */
// Fleet-wide metrics. Only a panel user who has completed security onboarding (rotated the // Fleet-wide metrics. Any authenticated panel user may watch. The `/broadcasting/auth` endpoint runs
// seeded password + enrolled 2FA) may watch — mere authentication is NOT enough; this mirrors // in the `web` group, so PanelScheme + the session guard already require an authenticated user on the
// the EnsureSecurityOnboarded gate on panel routes (metrics are not per-user). // active host. Password rotation is OPTIONAL now (the seeded password may be kept, under a standing
Broadcast::channel('metrics', fn (User $user) => $user->securityOnboarded()); // warning), so this no longer gates on `securityOnboarded()` — otherwise an operator who deliberately
// skipped the rotation would silently lose realtime metrics on a panel they are allowed to use.
Broadcast::channel('metrics', fn (User $user) => true);

View File

@ -44,4 +44,27 @@ class OptionalOnboardingTest extends TestCase
$this->assertTrue($user->securityOnboarded()); $this->assertTrue($user->securityOnboarded());
} }
public function test_password_change_can_be_skipped_without_rotating(): void
{
$user = User::factory()->create(['must_change_password' => true]);
Livewire::actingAs($user)->test(PasswordChange::class)
->call('skip')
->assertRedirect(route('dashboard'));
// Skipping does NOT rotate — the flag (and the warning banner) stay until a real change.
$this->assertTrue($user->fresh()->must_change_password);
$this->assertTrue(session('onboarding.password_skipped'));
}
public function test_a_skipped_session_is_no_longer_redirected(): void
{
$user = User::factory()->create(['must_change_password' => true]);
$this->actingAs($user)
->withSession(['onboarding.password_skipped' => true])
->get('/')
->assertOk();
}
} }