From cb8b906aa86b93bd4ab33e7225b2efded501a84d Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 16:55:01 +0200 Subject: [PATCH] feat(auth): forgot-password reset via 2FA or backup code "Passwort vergessen?" on login -> a reset view that proves identity with a TOTP or one-time backup code (no email needed), then sets a new password under the min(12) mixedCase numbers policy. Rate-limited, generic messages (no account enumeration), audited. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Auth/ForgotPassword.php | 81 +++++++++++++++++++ .../livewire/auth/forgot-password.blade.php | 45 +++++++++++ resources/views/livewire/auth/login.blade.php | 1 + routes/web.php | 1 + tests/Feature/ForgotPasswordTest.php | 76 +++++++++++++++++ 5 files changed, 204 insertions(+) create mode 100644 app/Livewire/Auth/ForgotPassword.php create mode 100644 resources/views/livewire/auth/forgot-password.blade.php create mode 100644 tests/Feature/ForgotPasswordTest.php diff --git a/app/Livewire/Auth/ForgotPassword.php b/app/Livewire/Auth/ForgotPassword.php new file mode 100644 index 0000000..20f9fca --- /dev/null +++ b/app/Livewire/Auth/ForgotPassword.php @@ -0,0 +1,81 @@ +validate([ + 'email' => ['required', 'email'], + 'code' => ['required', 'string'], + 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], + ]); + + $key = 'forgot:'.md5(strtolower($this->email).'|'.request()->ip()); + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + + $user = User::where('email', $this->email)->first(); + $clean = preg_replace('/\s+/', '', $this->code); + + $ok = $user + && $user->hasTwoFactorEnabled() + && ((new Google2FA)->verifyKey($user->two_factor_secret, $clean) || $user->useRecoveryCode($this->code)); + + if (! $ok) { + RateLimiter::hit($key, 60); + // Generic message — never reveal whether the email exists. + throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]); + } + + RateLimiter::clear($key); + $user->forceFill(['password' => Hash::make($this->password), 'must_change_password' => false])->save(); + + AuditEvent::create([ + 'user_id' => $user->id, + 'actor' => $user->name ?? 'system', + 'action' => 'password.reset', + 'target' => $user->email, + 'ip' => request()->ip(), + ]); + + session()->flash('status', __('auth.reset_done')); + + return $this->redirect(route('login'), navigate: true); + } + + public function render() + { + return view('livewire.auth.forgot-password')->title(__('auth.title_forgot')); + } +} diff --git a/resources/views/livewire/auth/forgot-password.blade.php b/resources/views/livewire/auth/forgot-password.blade.php new file mode 100644 index 0000000..789c32e --- /dev/null +++ b/resources/views/livewire/auth/forgot-password.blade.php @@ -0,0 +1,45 @@ +@php + $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'; +@endphp +
+
+

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

+

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

+

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

+
+ +
+
+ + + @error('email')

{{ $message }}

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

{{ $message }}

@enderror +

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

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

{{ $message }}

@enderror +
+
+ + +
+ + {{ __('auth.forgot_submit') }} + {{ __('auth.saving') }} + +
+ + @if ($this->mailEnabled()) + {{ __('auth.forgot_send_email') }} + @endif + + {{ __('auth.back_to_login') }} +
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index ac8a847..4f89cc1 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -24,6 +24,7 @@ @error('password')

{{ $message }}

@enderror + {{ __('auth.forgot_link') }}