mailEnabled()) { return; } $this->validate(['email' => ['required', 'email']]); // The notification is queued (see User::sendPasswordResetNotification), so this only ever // pushes a job — no synchronous SMTP. Swallow any transport/queue error so that neither // latency nor an exception can distinguish a known address from an unknown one. try { \Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]); } catch (\Throwable) { // Intentionally ignored — the response is identical regardless (auth.reset_link_sent). } // Generic — never reveal whether the email exists. $this->dispatch('notify', message: __('auth.reset_link_sent')); } /** Reset the password by proving 2FA possession (TOTP or a one-time backup code). */ public function resetPassword() { $this->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(); if ($user && $user->hasTwoFactorEnabled()) { $ok = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code); } else { // No account, or one without 2FA: run equivalent dummy crypto so this branch costs // about as much as a real wrong-code attempt — the verification path must not be a // timing oracle that distinguishes a known address. $this->burnVerificationTime(); $ok = false; } if (! $ok) { RateLimiter::hit($key, 60); // Generic message — never reveal whether the email exists. throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]); } RateLimiter::clear($key); // Rotate remember_token too, so a stolen remember-me cookie can't survive the reset. $user->forceFill([ 'password' => Hash::make($this->password), 'must_change_password' => false, 'remember_token' => Str::random(60), ])->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); } /** * Constant dummy verification for the no-account / no-2FA branch: mirrors the crypto work a * real wrong-code attempt does (a Google2FA HMAC verify + a bcrypt comparison) so response * time can't be used to enumerate accounts. The results are deliberately discarded. */ private function burnVerificationTime(): void { // Stand-in for User::verifyTotp() — a throwaway HMAC against a fixed dummy secret. try { (new Google2FA)->verifyKey(self::DUMMY_TOTP_SECRET, preg_replace('/\s+/', '', $this->code) ?? ''); } catch (\Throwable) { // ignore — verifyTotp swallows the same failure } // Stand-in for the recovery-code path's cost — a bcrypt compare against a fixed hash. Hash::check($this->code, self::DUMMY_PASSWORD_HASH); } public function render() { return view('livewire.auth.forgot-password')->title(__('auth.title_forgot')); } }