generationLimiterKey($email); $maxGenerations = (int) config('core.auth.otp.generation_limit', 3); if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) { throw new OtpThrottledException(RateLimiter::availableIn($limiterKey)); } RateLimiter::hit($limiterKey, (int) config('core.auth.otp.generation_decay_minutes', 10) * 60); $model = config('auth.providers.users.model'); $user = $model::firstOrCreate(['email' => $email]); // wasRecentlyCreated is Eloquent's own "did firstOrCreate() just // INSERT, or did it find an existing row" flag — the only reliable // way to tell them apart from firstOrCreate()'s return value alone. // Without this check, a genuinely new signup never fired // UserCreated at all (this class's own docblock claimed the // Customer/User pairing cascade "already triggers" here, which was // false as written — see Modules\Core\Customer\Listeners\ // CreateCustomerForUser, which depends entirely on this event). if ($user->wasRecentlyCreated) { Event::dispatch(new UserCreated($user)); } $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); $user->otp_code = $code; $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $user->otp_attempts = 0; $user->save(); Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code)); return true; } /** * A wrong code counts against core.auth.otp.max_attempts and, once * reached, invalidates the code entirely — the shopper must request * a fresh one via generateAndSend() (itself throttled independently * — see this class's own docblock) rather than being able to keep * guessing against a still-live code for the rest of its 10-minute * expiry window. */ public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable { $model = config('auth.providers.users.model'); // lockForUpdate() + a transaction make the read-check-increment-save // below atomic across concurrent requests for the same user — without // it, two guesses fired in parallel can each read the same // pre-increment otp_attempts value and both save past // max_attempts, letting an attacker exceed the lockout by // parallelizing requests instead of sending them serially. $result = DB::transaction(function () use ($model, $email, $code) { $user = $model::where('email', $email)->lockForUpdate()->first(); if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) { return null; } if (! hash_equals((string) $user->otp_code, $code)) { $user->otp_attempts++; if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) { $user->otp_code = null; $user->otp_expires_at = null; $user->otp_attempts = 0; } $user->save(); return null; } $user->otp_code = null; $user->otp_expires_at = null; $user->otp_attempts = 0; $user->save(); return $user; }); if (! $result) { return null; } RateLimiter::clear($this->generationLimiterKey($email)); Auth::login($result); $this->sessions->record($result, $request); Event::dispatch(new UserAuthenticated($result)); return $result; } private function generationLimiterKey(string $email): string { return 'otp-generate:'.strtolower($email); } }