From d44f6930bbf58d7f5298215c03a1aa0593e21a01 Mon Sep 17 00:00:00 2001 From: boban Date: Mon, 15 Jun 2026 18:53:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20SMTP-aware=20forgot-password=20?= =?UTF-8?q?=E2=80=94=2015-min=20e-mail=20link=20+=202FA=20fallback;=20reco?= =?UTF-8?q?very=20note=20out=20of=20the=20public=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 18 ++ app/Livewire/Auth/ForgotPassword.php | 3 + config/auth.php | 2 +- lang/de/auth.php | 5 +- lang/de/settings.php | 3 + lang/en/auth.php | 5 +- lang/en/settings.php | 3 + .../livewire/auth/forgot-password.blade.php | 108 +++++++----- .../livewire/settings/security.blade.php | 5 + tests/Feature/ForgotPasswordSmtpAwareTest.php | 155 ++++++++++++++++++ 10 files changed, 259 insertions(+), 48 deletions(-) create mode 100644 tests/Feature/ForgotPasswordSmtpAwareTest.php diff --git a/README.md b/README.md index a1a1754..c1e7c9e 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,24 @@ docker compose -f docker-compose.prod.yml exec -u app app php artisan clusev:ins --- +## Account recovery / Wiederherstellung + +Forgot the admin password? The panel offers two self-service paths on the **forgot-password** +screen: an **e-mail reset link** (15 minutes valid) when SMTP is configured, and an inline +**2FA-proof reset** (e-mail + TOTP or a backup code + new password) as a fallback. + +Locked out with **no 2FA and no SMTP**? As a last resort, SSH to the host and reset the admin +from the container: + +```bash +docker compose -f docker-compose.prod.yml exec app php artisan clusev:reset-admin +``` + +This recovery command is documented in-panel under **Settings → Security** (admin-only) — it is +deliberately not advertised on the public, pre-login forgot-password screen. + +--- + ## License AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free. diff --git a/app/Livewire/Auth/ForgotPassword.php b/app/Livewire/Auth/ForgotPassword.php index 559491a..e221166 100644 --- a/app/Livewire/Auth/ForgotPassword.php +++ b/app/Livewire/Auth/ForgotPassword.php @@ -23,6 +23,9 @@ class ForgotPassword extends Component public string $password_confirmation = ''; + /** Reveals the secondary inline 2FA-proof form when a mailer is configured. */ + public bool $showCodeReset = false; + /** True when a real mailer is configured (then the email-link option is offered too). */ public function mailEnabled(): bool { diff --git a/config/auth.php b/config/auth.php index d7568ff..98d36ec 100644 --- a/config/auth.php +++ b/config/auth.php @@ -96,7 +96,7 @@ return [ 'users' => [ 'provider' => 'users', 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), - 'expire' => 60, + 'expire' => 15, 'throttle' => 60, ], ], diff --git a/lang/de/auth.php b/lang/de/auth.php index 514fa5e..3ed8cb1 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -96,15 +96,16 @@ return [ 'forgot_link' => 'Passwort vergessen?', 'forgot_heading' => 'Passwort zurücksetzen', 'forgot_subtitle' => 'Setze dein Passwort mit deinem 2FA-Code oder einem Backup-Code zurück.', + 'forgot_subtitle_mail' => 'Wir senden dir einen Reset-Link (15 Minuten gültig).', 'forgot_code' => '2FA-Code oder Backup-Code', 'forgot_code_hint' => '6-stelliger Authenticator-Code oder einer deiner Backup-Codes.', 'forgot_submit' => 'Passwort zurücksetzen', - 'forgot_send_email' => 'Reset-Link per E-Mail senden', + 'forgot_send_email' => 'Reset-Link senden', + 'forgot_use_code_toggle' => 'Kein E-Mail-Zugang? Mit 2FA-Code zurücksetzen', 'reset_invalid' => 'E-Mail oder Code ungültig.', 'reset_done' => 'Passwort geändert. Bitte neu anmelden.', 'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.', 'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.', - 'reset_no_2fa_note' => 'Ohne 2FA ist die Wiederherstellung nur per E-Mail-Link (falls SMTP konfiguriert) oder über den Befehl clusev:reset-admin möglich.', // ── WebAuthn / Security-Keys ───────────────────────────────────────── 'webauthn_login' => 'Mit Security-Key anmelden', diff --git a/lang/de/settings.php b/lang/de/settings.php index 76454a0..503d1a2 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -40,6 +40,9 @@ return [ 'twofa_recommended' => '2FA ist optional, aber empfohlen — sichere dein Konto mit einem Authenticator oder Security-Key.', 'twofa_remove_totp' => 'Authenticator entfernen', + // Account recovery (last-resort) note — admin-only, post-login + 'recovery_note' => 'Konto-Wiederherstellung (letzter Ausweg): per SSH auf den Host einloggen und `clusev:reset-admin` ausführen.', + // Notifications 'profile_saved' => 'Profil gespeichert.', 'password_changed' => 'Passwort geändert.', diff --git a/lang/en/auth.php b/lang/en/auth.php index 0f35ca9..11f363d 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -96,15 +96,16 @@ return [ 'forgot_link' => 'Forgot password?', 'forgot_heading' => 'Reset password', 'forgot_subtitle' => 'Reset your password with your 2FA code or a backup code.', + 'forgot_subtitle_mail' => 'We will send you a reset link (valid for 15 minutes).', 'forgot_code' => '2FA code or backup code', 'forgot_code_hint' => 'Your 6-digit authenticator code or one of your backup codes.', 'forgot_submit' => 'Reset password', - 'forgot_send_email' => 'Email me a reset link', + 'forgot_send_email' => 'Send reset link', + 'forgot_use_code_toggle' => 'No email access? Reset with a 2FA code', 'reset_invalid' => 'Invalid email or code.', 'reset_done' => 'Password changed. Please sign in again.', 'reset_link_sent' => 'If the email exists, a reset link has been sent.', 'reset_token_invalid' => 'Reset link is invalid or expired.', - 'reset_no_2fa_note' => 'Without 2FA, recovery is only possible via the email link (if SMTP is configured) or the clusev:reset-admin command.', // ── WebAuthn / security keys ───────────────────────────────────────── 'webauthn_login' => 'Sign in with a security key', diff --git a/lang/en/settings.php b/lang/en/settings.php index 56124a3..f46e2ce 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -40,6 +40,9 @@ return [ 'twofa_recommended' => '2FA is optional but recommended — secure your account with an authenticator or a security key.', 'twofa_remove_totp' => 'Remove authenticator', + // Account recovery (last-resort) note — admin-only, post-login + 'recovery_note' => 'Account recovery (last resort): sign in to the host over SSH and run `clusev:reset-admin`.', + // Notifications 'profile_saved' => 'Profile saved.', 'password_changed' => 'Password changed.', diff --git a/resources/views/livewire/auth/forgot-password.blade.php b/resources/views/livewire/auth/forgot-password.blade.php index 02bb928..b94e983 100644 --- a/resources/views/livewire/auth/forgot-password.blade.php +++ b/resources/views/livewire/auth/forgot-password.blade.php @@ -2,60 +2,82 @@ $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; $field = 'h-11 w-full rounded-md border border-line bg-inset px-3 text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline'; + $mail = $this->mailEnabled(); + // With SMTP the inline 2FA form is the secondary path (revealed via toggle); without it, it is the only path. + $showCode = $mail ? $showCodeReset : true; @endphp

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

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

-

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

+

{{ $mail ? __('auth.forgot_subtitle_mail') : __('auth.forgot_subtitle') }}

-
-
- - - @error('email')

{{ $message }}

@enderror -
-
- - - @error('code')

{{ $message }}

@enderror -

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

-
-
- -
- - + @if ($mail) + {{-- SMTP configured: e-mail reset link is the default path. --}} + +
+ + + @error('email')

{{ $message }}

@enderror
- @error('password')

{{ $message }}

@enderror -
-
- -
- - -
-
- - {{ __('auth.forgot_submit') }} - {{ __('auth.saving') }} - - + + {{ __('auth.forgot_send_email') }} + {{ __('auth.saving') }} + + - @if ($this->mailEnabled()) - {{ __('auth.forgot_send_email') }} + @unless ($showCode) + + @endunless @endif -

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

+ @if ($showCode) + {{-- Inline 2FA-proof reset (email + TOTP/backup code + new password). --}} +
+
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('code')

{{ $message }}

@enderror +

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

+
+
+ +
+ + +
+ @error('password')

{{ $message }}

@enderror +
+
+ +
+ + +
+
+ + {{ __('auth.forgot_submit') }} + {{ __('auth.saving') }} + +
+ @endif {{ __('auth.back_to_login') }}
diff --git a/resources/views/livewire/settings/security.blade.php b/resources/views/livewire/settings/security.blade.php index 71906a7..faff0a2 100644 --- a/resources/views/livewire/settings/security.blade.php +++ b/resources/views/livewire/settings/security.blade.php @@ -31,4 +31,9 @@ + +

+ + {{ __('settings.recovery_note') }} +

diff --git a/tests/Feature/ForgotPasswordSmtpAwareTest.php b/tests/Feature/ForgotPasswordSmtpAwareTest.php new file mode 100644 index 0000000..acdff18 --- /dev/null +++ b/tests/Feature/ForgotPasswordSmtpAwareTest.php @@ -0,0 +1,155 @@ +assertSame(15, config('auth.passwords.users.expire')); + } + + // ── SMTP configured: e-mail link is the default ────────────────────── + public function test_smtp_on_shows_send_link_button_and_hides_code_form_by_default(): void + { + config(['mail.default' => 'smtp']); + + Livewire::test(ForgotPassword::class) + ->assertSee(__('auth.forgot_send_email')) + ->assertSee(__('auth.forgot_use_code_toggle')) + // the inline 2FA-proof form (new-password field) is hidden by default + ->assertDontSee(__('auth.new_password')); + } + + public function test_smtp_on_subtitle_advertises_the_email_link(): void + { + config(['mail.default' => 'smtp']); + + Livewire::test(ForgotPassword::class) + ->assertSee(__('auth.forgot_subtitle_mail')); + } + + public function test_smtp_on_send_link_dispatches_reset_notification(): void + { + config(['mail.default' => 'smtp']); + Notification::fake(); + $user = User::factory()->create(['email' => 'admin@clusev.local']); + + Livewire::test(ForgotPassword::class) + ->set('email', 'admin@clusev.local') + ->call('sendResetLink') + ->assertHasNoErrors(); + + Notification::assertSentTo($user, ResetPasswordNotification::class); + } + + public function test_toggle_reveals_the_2fa_code_form(): void + { + config(['mail.default' => 'smtp']); + + Livewire::test(ForgotPassword::class) + ->assertDontSee(__('auth.new_password')) + ->set('showCodeReset', true) + ->assertSee(__('auth.new_password')) + ->assertSee(__('auth.forgot_code')); + } + + public function test_2fa_reset_still_works_with_smtp_on_via_toggle(): void + { + config(['mail.default' => 'smtp']); + $user = User::factory()->create([ + 'email' => 'admin@clusev.local', + 'two_factor_secret' => (new Google2FA)->generateSecretKey(), + 'two_factor_confirmed_at' => now(), + ]); + $otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); + + Livewire::test(ForgotPassword::class) + ->set('showCodeReset', true) + ->set('email', $user->email)->set('code', $otp) + ->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123') + ->call('resetPassword')->assertHasNoErrors(); + + $this->assertTrue(Hash::check('NewPassw0rd123', $user->fresh()->password)); + } + + // ── No SMTP: inline 2FA-proof reset only ───────────────────────────── + public function test_smtp_off_shows_inline_2fa_form_and_no_send_link(): void + { + config(['mail.default' => 'log']); + + Livewire::test(ForgotPassword::class) + ->assertSee(__('auth.new_password')) + ->assertSee(__('auth.forgot_code')) + ->assertDontSee(__('auth.forgot_send_email')); + } + + public function test_smtp_off_subtitle_keeps_the_2fa_copy(): void + { + config(['mail.default' => 'log']); + + Livewire::test(ForgotPassword::class) + ->assertSee(__('auth.forgot_subtitle')); + } + + public function test_smtp_off_resets_with_a_valid_totp(): void + { + config(['mail.default' => 'log']); + $user = User::factory()->create([ + 'email' => 'admin@clusev.local', + 'two_factor_secret' => (new Google2FA)->generateSecretKey(), + 'two_factor_confirmed_at' => now(), + ]); + $otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); + + Livewire::test(ForgotPassword::class) + ->set('email', $user->email)->set('code', $otp) + ->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123') + ->call('resetPassword')->assertHasNoErrors(); + + $this->assertTrue(Hash::check('NewPassw0rd123', $user->fresh()->password)); + } + + // ── clusev:reset-admin recovery note placement ─────────────────────── + public function test_public_forgot_view_does_not_advertise_reset_admin_command(): void + { + config(['mail.default' => 'log']); + + Livewire::test(ForgotPassword::class) + ->assertDontSee('clusev:reset-admin'); + + config(['mail.default' => 'smtp']); + + Livewire::test(ForgotPassword::class) + ->assertDontSee('clusev:reset-admin'); + } + + public function test_settings_security_shows_the_recovery_note_with_the_command(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user)->test(Security::class) + ->assertSee(__('settings.recovery_note')) + ->assertSee('clusev:reset-admin'); + } +}