142 lines
5.3 KiB
PHP
142 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Auth;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rules\Password;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Component;
|
|
use PragmaRX\Google2FAQRCode\Google2FA;
|
|
|
|
#[Layout('layouts.auth')]
|
|
class ForgotPassword extends Component
|
|
{
|
|
/** Fixed throwaway base32 secret for the dummy TOTP verify on the no-user/no-2FA branch. */
|
|
private const DUMMY_TOTP_SECRET = 'ABCDEFGHIJKLMNOP';
|
|
|
|
/** Fixed throwaway bcrypt hash (cost 12) for the dummy password comparison on that branch. */
|
|
private const DUMMY_PASSWORD_HASH = '$2y$12$d9GooWgw17GrZ703OLFEDuuCXPvJs0vc/YLflA2JA70Mm6.I.3f.W';
|
|
|
|
public string $email = '';
|
|
|
|
public string $code = '';
|
|
|
|
public string $password = '';
|
|
|
|
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
|
|
{
|
|
return ! in_array(config('mail.default'), ['log', 'array', null], true);
|
|
}
|
|
|
|
/** Secondary path: email a reset link (only meaningful when a real mailer is set). */
|
|
public function sendResetLink(): void
|
|
{
|
|
if (! $this->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'));
|
|
}
|
|
}
|