'hashed' cast * on App\Models\User hashes it automatically on assignment, same as * password), not the code itself — compared via Hash::check(). * * Two independent throttles, both configured under core.auth.otp — see * config/core.php's own comment for why they're separate: max_attempts * caps wrong guesses against ONE code; generation_limit caps how often a * NEW code can be requested for the same email at all (closes both the * "regenerate to reset my guess count" loophole and mail-bombing one * inbox). Both apply identically whether or not a User row exists yet. * * validate() also records a UserSessionService entry for the new login — * see that class's own docblock for the "logout everywhere" registry * this feeds (Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked * is the enforcement half; a consuming app must add it to its own * middleware stack). $request is optional purely so this service stays * callable from a context with no HTTP request at all (a console * command, a test) — user-agent/ip are simply not recorded when omitted. */ class UserOtpService { private const EXPIRY_MINUTES = 10; private const CODE_LENGTH = 6; public function __construct( private readonly UserSessionService $sessions, ) {} /** * @throws OtpThrottledException if this email has requested too many * codes within core.auth.otp.generation_decay_minutes */ public function generateAndSend(string $email): bool { $limiterKey = $this->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::where('email', $email)->first(); $code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT); if ($user) { $user->otp_code_hash = $code; $user->otp_expires_at = now()->addMinutes(self::EXPIRY_MINUTES); $user->otp_attempts = 0; $user->save(); } else { // No row yet — deliberately not created here. See this // class's own docblock for why: creating one on every // generateAndSend() call let anyone mint real User/Customer // rows for an email nobody proved they owned. // // Hashed even in the cache (not just on the DB-backed path) // — a code sitting in Cache::get()-able storage is the same // exposure as a plaintext DB column if anything can read it. Cache::put($this->pendingKey($email), [ 'code_hash' => Hash::make($code), 'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp, 'attempts' => 0, ], now()->addMinutes(self::EXPIRY_MINUTES)); } Mail::to($email)->send(new UserOtpMail($user->name ?? $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. Applies identically to the cache-backed (no User row * yet) and DB-backed (existing User row) paths. */ public function validate(string $email, string $code, ?Request $request = null): ?Authenticatable { $model = config('auth.providers.users.model'); $existing = $model::where('email', $email)->exists(); $result = $existing ? $this->validateExisting($model, $email, $code) : $this->validatePending($model, $email, $code); if (! $result) { return null; } RateLimiter::clear($this->generationLimiterKey($email)); Auth::login($result); $this->sessions->record($result, $request); Event::dispatch(new UserAuthenticated($result)); return $result; } /** * lockForUpdate() + a transaction make the read-check-increment-save * 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. */ private function validateExisting(string $model, string $email, string $code): ?Authenticatable { return DB::transaction(function () use ($model, $email, $code) { $user = $model::where('email', $email)->lockForUpdate()->first(); if (! $user || ! $user->otp_code_hash || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) { return null; } if (! Hash::check($code, $user->otp_code_hash)) { $user->otp_attempts++; if ($user->otp_attempts >= (int) config('core.auth.otp.max_attempts', 5)) { $user->otp_code_hash = null; $user->otp_expires_at = null; $user->otp_attempts = 0; } $user->save(); return null; } $user->otp_code_hash = null; $user->otp_expires_at = null; $user->otp_attempts = 0; $user->save(); return $user; }); } /** * No User row exists yet, so there's nothing to lockForUpdate() — * Cache::lock() is the equivalent guard against two parallel guesses * against the same pending signup both reading the same pre-increment * attempts count. The User (and, via UserCreated, its paired Customer) * is only ever created here, once the code has actually been proven * correct — never from generateAndSend(). */ private function validatePending(string $model, string $email, string $code): ?Authenticatable { $key = $this->pendingKey($email); return Cache::lock("{$key}:lock", 10)->block(5, function () use ($model, $email, $code, $key) { $pending = Cache::get($key); if (! $pending || now()->timestamp > $pending['expires_at']) { return null; } if (! Hash::check($code, $pending['code_hash'])) { $pending['attempts']++; if ($pending['attempts'] >= (int) config('core.auth.otp.max_attempts', 5)) { Cache::forget($key); } else { Cache::put($key, $pending, now()->addMinutes(self::EXPIRY_MINUTES)); } return null; } Cache::forget($key); $user = $model::firstOrCreate(['email' => $email]); // wasRecentlyCreated is Eloquent's own "did firstOrCreate() // just INSERT, or did it find an existing row" flag. Always // true here in practice (validatePending() only runs when no // row existed moments ago), but checked anyway rather than // assumed, in case of an extremely unlikely race with a // signup completed through some other path in between. if ($user->wasRecentlyCreated) { Event::dispatch(new UserCreated($user)); } return $user; }); } private function generationLimiterKey(string $email): string { return 'otp-generate:'.strtolower($email); } private function pendingKey(string $email): string { return 'otp-pending:'.strtolower($email); } }