Feature: Customer Account Services
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Auth\Events;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
/**
|
||||
* Dispatched by Modules\Core\Auth\Services\UserOtpService::validate() on a
|
||||
* successful OTP login — distinct from UserCreated (which only fires for
|
||||
* a genuinely first-time email); this fires on every successful login,
|
||||
* new user or returning one.
|
||||
*/
|
||||
class CustomerLoggedIn
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Authenticatable $user,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Auth\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown by Modules\Core\Auth\Services\UserOtpService::generateAndSend()
|
||||
* when an email has requested too many codes too quickly — caps both
|
||||
* mail-bombing one inbox and the "just request a fresh code to reset my
|
||||
* guess count" loophole a per-code attempt cap alone doesn't close.
|
||||
*/
|
||||
class OtpThrottledException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $availableInSeconds,
|
||||
) {
|
||||
parent::__construct("Too many code requests. Try again in {$availableInSeconds} second(s).");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Auth\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Modules\Core\Auth\Services\UserSessionService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The enforcement half of the session registry — see
|
||||
* Modules\Core\Auth\Services\UserSessionService's own docblock. Not
|
||||
* auto-registered anywhere (no routes/kernel wiring exist in this
|
||||
* package — see Modules\Core\Customer\Services\CustomerAccountService's
|
||||
* own docblock for why this branch stops at services); a consuming app
|
||||
* adds this to its `web` middleware group (after `auth`) to actually get
|
||||
* "logout everywhere" enforcement.
|
||||
*
|
||||
* A request with no recorded UserSession at all (see
|
||||
* UserSessionService::currentSession()'s own docblock) is let through —
|
||||
* only an EXPLICITLY revoked session is rejected.
|
||||
*/
|
||||
class EnsureSessionNotRevoked
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserSessionService $sessions,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$session = $this->sessions->currentSession();
|
||||
|
||||
if ($session && $session->isRevoked()) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
abort(401, 'Your session has been revoked. Please log in again.');
|
||||
}
|
||||
|
||||
$session?->update(['last_used_at' => now()]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Auth\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* One row per login (see Modules\Core\Auth\Services\UserOtpService::
|
||||
* validate()) — see that table's own migration docblock for why this
|
||||
* exists independent of the actual session-store driver.
|
||||
*/
|
||||
class UserSession extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'last_used_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
$model = config('auth.providers.users.model');
|
||||
|
||||
return $this->belongsTo($model);
|
||||
}
|
||||
|
||||
public function isRevoked(): bool
|
||||
{
|
||||
return $this->revoked_at !== null;
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,75 @@
|
||||
|
||||
namespace Modules\Core\Auth\Services;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Modules\Core\Auth\Events\CustomerLoggedIn;
|
||||
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||
use Modules\Core\Auth\Mail\UserOtpMail;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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::firstOrCreate(['email' => $email]);
|
||||
|
||||
@@ -19,6 +78,7 @@ class UserOtpService
|
||||
|
||||
$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));
|
||||
@@ -26,23 +86,55 @@ class UserOtpService
|
||||
return true;
|
||||
}
|
||||
|
||||
public function validate(string $email, string $code)
|
||||
/**
|
||||
* 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');
|
||||
$user = $model::where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
if (! $user || ! $user->otp_expires_at || now()->isAfter($user->otp_expires_at)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $user->otp_expires_at || $user->otp_code != $code || now()->isAfter($user->otp_expires_at)) {
|
||||
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();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$user->otp_code = null;
|
||||
$user->otp_expires_at = null;
|
||||
$user->otp_attempts = 0;
|
||||
$user->save();
|
||||
|
||||
RateLimiter::clear($this->generationLimiterKey($email));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
$this->sessions->record($user, $request);
|
||||
|
||||
Event::dispatch(new CustomerLoggedIn($user));
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function generationLimiterKey(string $email): string
|
||||
{
|
||||
return 'otp-generate:'.strtolower($email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Auth\Services;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Core\Auth\Models\UserSession;
|
||||
|
||||
/**
|
||||
* The record/revoke half of the session registry — see
|
||||
* database/migrations/2026_09_15_000001_create_user_sessions_table.php's
|
||||
* own docblock for why this exists (SESSION_DRIVER=redis in this app has
|
||||
* no "sessions" table to purge by user_id). The enforcement half is
|
||||
* Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked, which reads
|
||||
* the token this class stamps into the session payload.
|
||||
*/
|
||||
class UserSessionService
|
||||
{
|
||||
private const SESSION_TOKEN_KEY = 'user_session_token';
|
||||
|
||||
/**
|
||||
* Called once, right after Auth::login() succeeds (see
|
||||
* UserOtpService::validate()) — generates a fresh token, records it,
|
||||
* and stamps it into the CURRENT session payload so
|
||||
* EnsureSessionNotRevoked can look it up on later requests.
|
||||
*/
|
||||
public function record(Authenticatable $user, ?Request $request = null): UserSession
|
||||
{
|
||||
$token = Str::random(64);
|
||||
|
||||
$session = UserSession::create([
|
||||
'user_id' => $user->getAuthIdentifier(),
|
||||
'token' => $token,
|
||||
'user_agent' => $request?->userAgent(),
|
||||
'ip_address' => $request?->ip(),
|
||||
'last_used_at' => now(),
|
||||
]);
|
||||
|
||||
session([self::SESSION_TOKEN_KEY => $token]);
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes every OTHER active session for $user — the current one
|
||||
* (matched by the token in the CURRENT session payload) is left
|
||||
* alone, matching Laravel's own logoutOtherDevices() semantics
|
||||
* (there just isn't a password to re-verify against here — this is a
|
||||
* passwordless account, so revocation is simply "every row that
|
||||
* isn't the one making this request").
|
||||
*
|
||||
* Known, deliberately accepted gap: this requires only a currently
|
||||
* valid session, not a freshly-completed login — so anyone holding
|
||||
* an already-authenticated session (e.g. someone who sits down at an
|
||||
* account left logged in on a shared/public PC) can use this to
|
||||
* evict the real owner's OTHER sessions just as easily as the real
|
||||
* owner could use it to evict an intruder's. A stricter version would
|
||||
* require a fresh OTP re-verification (e.g. within the last few
|
||||
* minutes) before allowing this call. Left as-is for now — revisit if
|
||||
* this turns out to matter in practice, rather than building
|
||||
* abuse-resistance against a threat model nobody's confirmed is real
|
||||
* for this storefront.
|
||||
*/
|
||||
public function revokeOtherSessions(Authenticatable $user): int
|
||||
{
|
||||
$currentToken = session(self::SESSION_TOKEN_KEY);
|
||||
|
||||
return UserSession::query()
|
||||
->where('user_id', $user->getAuthIdentifier())
|
||||
->whereNull('revoked_at')
|
||||
->when($currentToken, fn ($query) => $query->where('token', '!=', $currentToken))
|
||||
->update(['revoked_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes EVERY session for $user, current one included — for a
|
||||
* "this account may be compromised" response, not a routine logout.
|
||||
*/
|
||||
public function revokeAllSessions(Authenticatable $user): int
|
||||
{
|
||||
return UserSession::query()
|
||||
->where('user_id', $user->getAuthIdentifier())
|
||||
->whereNull('revoked_at')
|
||||
->update(['revoked_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UserSession|null null if the CURRENT session has no
|
||||
* recorded token at all (e.g. a session predating this feature, or
|
||||
* one Auth::login() established outside UserOtpService) — treated
|
||||
* as valid by EnsureSessionNotRevoked rather than rejected, since
|
||||
* there's nothing to have been revoked.
|
||||
*/
|
||||
public function currentSession(): ?UserSession
|
||||
{
|
||||
$token = session(self::SESSION_TOKEN_KEY);
|
||||
|
||||
if (! $token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return UserSession::where('token', $token)->first();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user