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 $e) { // The client response stays identical regardless — but surface the failure to ops // (report() → logs), otherwise a broken queue/token store silently "succeeds" for // every address and a broken reset flow goes unnoticed. report($e); } // 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(6)], ]); // Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset // path is a second TOTP/backup brute-force surface (email + code, no password), so a // distributed (multi-IP) attempt must be capped per account the same way the challenge // is. Both auto-expire — never a permanent lockout (clusev:reset-admin stays open). $email = strtolower($this->email); $key = 'forgot:'.md5($email.'|'.request()->ip()); $acctKey = 'forgot-acct:'.md5($email); foreach ([[$key, 5], [$acctKey, 20]] as [$k, $max]) { if (RateLimiter::tooManyAttempts($k, $max)) { throw ValidationException::withMessages([ 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]), ]); } } $user = User::where('email', $this->email)->first(); // Resolve 2FA status with a *uniform* lookup pattern: always exactly one // webauthn-existence query — for a TOTP user that wouldn't otherwise need it, and for a // missing account — so the presence/absence of that query can't tell accounts apart. $hasWebauthn = $user ? $user->hasWebauthnCredentials() : WebauthnCredential::whereKey(0)->exists(); $hasTotp = (bool) $user?->hasTotp(); $has2fa = $hasTotp || $hasWebauthn; if ($user && $has2fa) { // Keep the TOTP HMAC uniform: a webauthn-only account has no TOTP secret, so // verifyTotp() returns without hashing — spend an equivalent throwaway HMAC first so // this branch costs the same as a TOTP account would. if (! $hasTotp) { $this->dummyTotpVerify(); } $ok = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code); } else { // No account, or one without 2FA: run the same HMAC + locked recovery lookup a real // wrong-code attempt does, so the verification path is not a timing/query oracle that // distinguishes a known address. $this->dummyTotpVerify(); $this->dummyRecoveryLookup(); $ok = false; } if (! $ok) { RateLimiter::hit($key, 60); // 5 / minute (email+IP) RateLimiter::hit($acctKey, 900); // 20 / 15 minutes (per account, IP-independent) // Generic message — never reveal whether the email exists. throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]); } RateLimiter::clear($key); RateLimiter::clear($acctKey); // 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); } /** * Stand-in for User::verifyTotp() — one throwaway Google2FA HMAC against a fixed dummy secret, * so a request with no (or no TOTP) account spends the same crypto as a real wrong-code * attempt. It must NOT do heavier work than the real path (a cost-12 bcrypt here would make * this slower, re-opening the oracle in reverse and amplifying CPU DoS). Result is discarded. */ private function dummyTotpVerify(): void { try { (new Google2FA)->verifyKey(self::DUMMY_TOTP_SECRET, preg_replace('/\s+/', '', $this->code) ?? ''); } catch (\Throwable) { // ignore — verifyTotp swallows the same failure } } /** * Stand-in for User::useRecoveryCode() — a locked SELECT in a transaction + an in_array scan, * matching the dominant DB cost of the recovery path without touching a real row (id 0 never * exists, so nothing is locked). Exceptions are deliberately NOT caught: a DB failure must * surface here exactly as it would in the real path, otherwise an outage becomes an * error-response oracle (generic for an unknown address, 500 for a real account). */ private function dummyRecoveryLookup(): void { DB::transaction(function (): void { $row = User::whereKey(0)->lockForUpdate()->first(); in_array($this->code, $row?->recoveryCodes() ?? array_fill(0, 8, ''), true); }); } public function render() { return view('livewire.auth.forgot-password')->title(__('auth.title_forgot')); } }