diff --git a/app/Http/Middleware/EnsureSecurityOnboarded.php b/app/Http/Middleware/EnsureSecurityOnboarded.php index f50ba5f..0ffc3dd 100644 --- a/app/Http/Middleware/EnsureSecurityOnboarded.php +++ b/app/Http/Middleware/EnsureSecurityOnboarded.php @@ -7,8 +7,10 @@ use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; /** - * After login, force the seeded-password rotation before the panel is reachable. - * 2FA is optional (offered in Settings), so it is NOT forced here. + * After login, NUDGE the operator to rotate the seeded password — but do not force it. The prompt + * 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 { @@ -16,7 +18,9 @@ class EnsureSecurityOnboarded { $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'); } diff --git a/app/Livewire/Auth/PasswordChange.php b/app/Livewire/Auth/PasswordChange.php index c3b5198..7478a92 100644 --- a/app/Livewire/Auth/PasswordChange.php +++ b/app/Livewire/Auth/PasswordChange.php @@ -57,6 +57,18 @@ class PasswordChange extends Component 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() { return view('livewire.auth.password-change')->title(__('auth.title_password_change')); diff --git a/lang/de/auth.php b/lang/de/auth.php index 12076c7..6ac0a8d 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -15,7 +15,7 @@ return [ 'login_submit' => 'Anmelden', 'checking' => 'Prüfe…', '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 ────────────────────────────────────────────────── 'security' => 'Sicherheit', @@ -26,6 +26,9 @@ return [ 'confirm_new_password' => 'Neues Passwort bestätigen', 'save_and_continue' => 'Speichern & weiter', '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' => 'Zwei-Faktor', @@ -55,7 +58,7 @@ return [ 'terminal_title' => 'clusev · sicherheitsmodell', 'terminal_ssh' => 'agentenlos · exec + SFTP über phpseclib', '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_core' => 'Open Core · AGPL-lizenziert', 'footer_copyright' => '© :year Clusev · Fleet Control', diff --git a/lang/en/auth.php b/lang/en/auth.php index a6bb7aa..5e0772a 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -15,7 +15,7 @@ return [ 'login_submit' => 'Sign in', 'checking' => 'Checking…', '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 ────────────────────────────────────────────────── 'security' => 'Security', @@ -26,6 +26,9 @@ return [ 'confirm_new_password' => 'Confirm new password', 'save_and_continue' => 'Save & continue', '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' => 'Two-factor', @@ -55,7 +58,7 @@ return [ 'terminal_title' => 'clusev · security model', 'terminal_ssh' => 'agentless · exec + SFTP over phpseclib', '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_core' => 'Open Core · AGPL-licensed', 'footer_copyright' => '© :year Clusev · Fleet Control', diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 7a88119..d2351ce 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -37,6 +37,16 @@
+ {{-- 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) +
+ +

{{ __('auth.default_pw_warning') }}

+ {{ __('auth.default_pw_change') }} +
+ @endif {{ $slot }}
diff --git a/resources/views/livewire/auth/password-change.blade.php b/resources/views/livewire/auth/password-change.blade.php index faedbea..551f563 100644 --- a/resources/views/livewire/auth/password-change.blade.php +++ b/resources/views/livewire/auth/password-change.blade.php @@ -56,4 +56,10 @@ {{ __('auth.saving') }} + + {{-- Rotation is recommended, not forced — the operator may keep the current password for now. --}} + diff --git a/routes/channels.php b/routes/channels.php index e7203bd..84c6337 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -28,7 +28,9 @@ use Illuminate\Support\Facades\Broadcast; | */ -// Fleet-wide metrics. Only a panel user who has completed security onboarding (rotated the -// seeded password + enrolled 2FA) may watch — mere authentication is NOT enough; this mirrors -// the EnsureSecurityOnboarded gate on panel routes (metrics are not per-user). -Broadcast::channel('metrics', fn (User $user) => $user->securityOnboarded()); +// Fleet-wide metrics. Any authenticated panel user may watch. The `/broadcasting/auth` endpoint runs +// in the `web` group, so PanelScheme + the session guard already require an authenticated user on the +// active host. Password rotation is OPTIONAL now (the seeded password may be kept, under a standing +// 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); diff --git a/tests/Feature/OptionalOnboardingTest.php b/tests/Feature/OptionalOnboardingTest.php index 00918d3..1096655 100644 --- a/tests/Feature/OptionalOnboardingTest.php +++ b/tests/Feature/OptionalOnboardingTest.php @@ -44,4 +44,27 @@ class OptionalOnboardingTest extends TestCase $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(); + } }