2026-07-03 02:15:31 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Modules\Core\Auth\Services;
|
|
|
|
|
|
2026-09-15 16:01:28 +03:00
|
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
|
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
use Illuminate\Support\Facades\Auth;
|
|
|
|
|
use Illuminate\Support\Facades\Event;
|
2026-07-03 02:15:31 +03:00
|
|
|
use Illuminate\Support\Facades\Mail;
|
2026-09-15 16:01:28 +03:00
|
|
|
use Illuminate\Support\Facades\RateLimiter;
|
|
|
|
|
use Modules\Core\Auth\Events\CustomerLoggedIn;
|
|
|
|
|
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
2026-07-03 02:15:31 +03:00
|
|
|
use Modules\Core\Auth\Mail\UserOtpMail;
|
|
|
|
|
|
2026-09-15 16:01:28 +03:00
|
|
|
/**
|
|
|
|
|
* The storefront's passwordless login — a shopper supplies only an email
|
|
|
|
|
* (Shopify-style), gets a 6-digit code, and validate() authenticates the
|
|
|
|
|
* `web` guard via Auth::login().
|
|
|
|
|
*
|
|
|
|
|
* That alone is enough to merge/associate any active guest cart into the
|
|
|
|
|
* now-known customer — Auth::login() fires Illuminate\Auth\Events\Login,
|
|
|
|
|
* which Lunar's own Lunar\Listeners\CartSessionAuthListener (registered
|
|
|
|
|
* unconditionally in LunarServiceProvider::boot(), no opt-in needed)
|
|
|
|
|
* already listens to, calling CartSession::associate() with
|
|
|
|
|
* config('lunar.cart.auth_policy') — 'merge' by default, 'override' if a
|
|
|
|
|
* consumer changes that config. Deliberately no cart-association call
|
|
|
|
|
* here: doing our own on top would run a SECOND merge attempt with a
|
|
|
|
|
* hardcoded policy that ignores whatever the consumer configured.
|
|
|
|
|
*
|
|
|
|
|
* generateAndSend()'s find-or-create already triggers the full
|
|
|
|
|
* Customer/User pairing cascade for a genuinely new email — see
|
|
|
|
|
* Modules\Core\Auth\Events\UserCreated's own docblock and
|
|
|
|
|
* Modules\Core\Customer\Listeners\CreateCustomerForUser.
|
|
|
|
|
*
|
|
|
|
|
* 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).
|
|
|
|
|
*
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-07-03 02:15:31 +03:00
|
|
|
class UserOtpService
|
|
|
|
|
{
|
|
|
|
|
private const EXPIRY_MINUTES = 10;
|
|
|
|
|
private const CODE_LENGTH = 6;
|
|
|
|
|
|
2026-09-15 16:01:28 +03:00
|
|
|
public function __construct(
|
|
|
|
|
private readonly UserSessionService $sessions,
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @throws OtpThrottledException if this email has requested too many
|
|
|
|
|
* codes within core.auth.otp.generation_decay_minutes
|
|
|
|
|
*/
|
2026-07-03 02:15:31 +03:00
|
|
|
public function generateAndSend(string $email): bool
|
|
|
|
|
{
|
2026-09-15 16:01:28 +03:00
|
|
|
$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);
|
|
|
|
|
|
2026-07-03 02:15:31 +03:00
|
|
|
$model = config('auth.providers.users.model');
|
|
|
|
|
$user = $model::firstOrCreate(['email' => $email]);
|
|
|
|
|
|
|
|
|
|
$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);
|
2026-09-15 16:01:28 +03:00
|
|
|
$user->otp_attempts = 0;
|
2026-07-03 02:15:31 +03:00
|
|
|
$user->save();
|
|
|
|
|
|
|
|
|
|
Mail::to($user->email)->send(new UserOtpMail($user->name ?? $user->email, $code));
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 16:01:28 +03:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-07-03 02:15:31 +03:00
|
|
|
{
|
|
|
|
|
$model = config('auth.providers.users.model');
|
|
|
|
|
$user = $model::where('email', $email)->first();
|
|
|
|
|
|
2026-09-15 16:01:28 +03:00
|
|
|
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
2026-07-03 02:15:31 +03:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 16:01:28 +03:00
|
|
|
if ($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();
|
|
|
|
|
|
2026-07-03 02:15:31 +03:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$user->otp_code = null;
|
|
|
|
|
$user->otp_expires_at = null;
|
2026-09-15 16:01:28 +03:00
|
|
|
$user->otp_attempts = 0;
|
2026-07-03 02:15:31 +03:00
|
|
|
$user->save();
|
|
|
|
|
|
2026-09-15 16:01:28 +03:00
|
|
|
RateLimiter::clear($this->generationLimiterKey($email));
|
|
|
|
|
|
|
|
|
|
Auth::login($user);
|
|
|
|
|
|
|
|
|
|
$this->sessions->record($user, $request);
|
|
|
|
|
|
|
|
|
|
Event::dispatch(new CustomerLoggedIn($user));
|
|
|
|
|
|
2026-07-03 02:15:31 +03:00
|
|
|
return $user;
|
|
|
|
|
}
|
2026-09-15 16:01:28 +03:00
|
|
|
|
|
|
|
|
private function generationLimiterKey(string $email): string
|
|
|
|
|
{
|
|
|
|
|
return 'otp-generate:'.strtolower($email);
|
|
|
|
|
}
|
2026-07-03 02:15:31 +03:00
|
|
|
}
|