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
parent
83626352e2
commit
142b531d45
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue