164 lines
6.3 KiB
PHP
164 lines
6.3 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
namespace Modules\Core\Customer\Services;
|
||
|
|
|
||
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||
|
|
use Illuminate\Support\Facades\DB;
|
||
|
|
use Illuminate\Support\Facades\Event;
|
||
|
|
use Illuminate\Support\Facades\Hash;
|
||
|
|
use Illuminate\Support\Facades\Mail;
|
||
|
|
use Illuminate\Support\Facades\RateLimiter;
|
||
|
|
use Modules\Core\Auth\Events\UserEmailChanged;
|
||
|
|
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||
|
|
use Modules\Core\Auth\Mail\EmailChangeCodeMail;
|
||
|
|
use Modules\Core\Auth\Mail\EmailChangedNoticeMail;
|
||
|
|
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
|
||
|
|
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Changing an account's login email — core's login is passwordless, so
|
||
|
|
* the email IS the login, and it only ever changes once the shopper has
|
||
|
|
* proved they can receive mail at the new address (a typo can never lock
|
||
|
|
* them out of their own account). The pending change (new address, a
|
||
|
|
* hash of the code, expiry, wrong-guess count) lives on the user's own
|
||
|
|
* row (see the migration adding pending_email/pending_email_code_hash/
|
||
|
|
* pending_email_expires_at/pending_email_attempts) — the same convention
|
||
|
|
* Auth\Services\UserOtpService's otp_code/otp_expires_at/otp_attempts
|
||
|
|
* already use — rather than the session, since a code arrives by email
|
||
|
|
* and is often opened on a different device/session than the one that
|
||
|
|
* requested it; a session-scoped pending change couldn't be confirmed
|
||
|
|
* from there at all.
|
||
|
|
*
|
||
|
|
* Two independent throttles, both configured under core.auth.email_change
|
||
|
|
* (same shape/reasoning as core.auth.otp): max_attempts caps wrong
|
||
|
|
* guesses against ONE code; generation_limit/generation_decay_minutes cap
|
||
|
|
* how often a NEW code can be requested at all.
|
||
|
|
*/
|
||
|
|
class CustomerEmailChangeService
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* @throws OtpThrottledException if this account has requested too
|
||
|
|
* many codes within core.auth.email_change.generation_decay_minutes
|
||
|
|
* @throws EmailAlreadyTakenException if $newEmail already belongs to
|
||
|
|
* a different user
|
||
|
|
*/
|
||
|
|
public function request(Authenticatable $user, string $newEmail): void
|
||
|
|
{
|
||
|
|
$newEmail = strtolower(trim($newEmail));
|
||
|
|
|
||
|
|
if ($user->newQuery()->where('email', $newEmail)->whereKeyNot($user->getKey())->exists()) {
|
||
|
|
throw new EmailAlreadyTakenException;
|
||
|
|
}
|
||
|
|
|
||
|
|
$limiterKey = $this->generationLimiterKey($user);
|
||
|
|
$maxGenerations = (int) config('core.auth.email_change.generation_limit', 3);
|
||
|
|
|
||
|
|
if (RateLimiter::tooManyAttempts($limiterKey, $maxGenerations)) {
|
||
|
|
throw new OtpThrottledException(RateLimiter::availableIn($limiterKey));
|
||
|
|
}
|
||
|
|
|
||
|
|
RateLimiter::hit($limiterKey, (int) config('core.auth.email_change.generation_decay_minutes', 10) * 60);
|
||
|
|
|
||
|
|
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||
|
|
|
||
|
|
$user->forceFill([
|
||
|
|
'pending_email' => $newEmail,
|
||
|
|
'pending_email_code_hash' => Hash::make($code),
|
||
|
|
'pending_email_expires_at' => now()->addMinutes((int) config('core.auth.email_change.expiry_minutes', 10)),
|
||
|
|
'pending_email_attempts' => 0,
|
||
|
|
])->save();
|
||
|
|
|
||
|
|
Mail::to($newEmail)->send(new EmailChangeCodeMail($code));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @throws InvalidEmailChangeCodeException for a wrong, expired, or
|
||
|
|
* already-burned (too many wrong guesses) code, or when there is no
|
||
|
|
* pending change at all
|
||
|
|
* @throws EmailAlreadyTakenException if someone else has since signed
|
||
|
|
* up with the pending address, in the window between request() and
|
||
|
|
* confirm()
|
||
|
|
*/
|
||
|
|
public function confirm(Authenticatable $user, string $code): void
|
||
|
|
{
|
||
|
|
$model = $user::class;
|
||
|
|
|
||
|
|
// lockForUpdate() + a transaction make the read-check-increment-save
|
||
|
|
// below atomic across concurrent requests — same reasoning as
|
||
|
|
// Auth\Services\UserOtpService::validate(), which this mirrors.
|
||
|
|
$valid = DB::transaction(function () use ($model, $user, $code) {
|
||
|
|
/** @var Authenticatable $locked */
|
||
|
|
$locked = $model::whereKey($user->getKey())->lockForUpdate()->first();
|
||
|
|
|
||
|
|
if (! $locked->pending_email
|
||
|
|
|| ! $locked->pending_email_code_hash
|
||
|
|
|| ! $locked->pending_email_expires_at
|
||
|
|
|| now()->isAfter($locked->pending_email_expires_at)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (! Hash::check($code, $locked->pending_email_code_hash)) {
|
||
|
|
$locked->pending_email_attempts++;
|
||
|
|
|
||
|
|
if ($locked->pending_email_attempts >= (int) config('core.auth.email_change.max_attempts', 5)) {
|
||
|
|
$locked->pending_email_code_hash = null;
|
||
|
|
$locked->pending_email_expires_at = null;
|
||
|
|
$locked->pending_email_attempts = 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
$locked->save();
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
|
||
|
|
if (! $valid) {
|
||
|
|
throw new InvalidEmailChangeCodeException;
|
||
|
|
}
|
||
|
|
|
||
|
|
$user->refresh();
|
||
|
|
$newEmail = $user->pending_email;
|
||
|
|
|
||
|
|
// Someone may have signed up with this address since request() ran.
|
||
|
|
if ($user->newQuery()->where('email', $newEmail)->whereKeyNot($user->getKey())->exists()) {
|
||
|
|
$user->forceFill([
|
||
|
|
'pending_email' => null,
|
||
|
|
'pending_email_code_hash' => null,
|
||
|
|
'pending_email_expires_at' => null,
|
||
|
|
'pending_email_attempts' => 0,
|
||
|
|
])->save();
|
||
|
|
|
||
|
|
throw new EmailAlreadyTakenException;
|
||
|
|
}
|
||
|
|
|
||
|
|
$oldEmail = $user->email;
|
||
|
|
|
||
|
|
$user->forceFill([
|
||
|
|
'email' => $newEmail,
|
||
|
|
'email_verified_at' => now(),
|
||
|
|
'pending_email' => null,
|
||
|
|
'pending_email_code_hash' => null,
|
||
|
|
'pending_email_expires_at' => null,
|
||
|
|
'pending_email_attempts' => 0,
|
||
|
|
])->save();
|
||
|
|
|
||
|
|
RateLimiter::clear($this->generationLimiterKey($user));
|
||
|
|
|
||
|
|
// Lets the previous owner notice if someone else changed it from a
|
||
|
|
// hijacked session.
|
||
|
|
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($newEmail));
|
||
|
|
|
||
|
|
// The code just proved they own the new address too.
|
||
|
|
app(GuestOrderClaimer::class)->claim($user);
|
||
|
|
|
||
|
|
Event::dispatch(new UserEmailChanged($user, $oldEmail));
|
||
|
|
}
|
||
|
|
|
||
|
|
private function generationLimiterKey(Authenticatable $user): string
|
||
|
|
{
|
||
|
|
return 'email-change:'.$user->getKey();
|
||
|
|
}
|
||
|
|
}
|