Feat: Pending Email Change Updates, Moving Mailables to core
This commit is contained in:
@@ -125,6 +125,17 @@ return [
|
|||||||
'generation_limit' => 3,
|
'generation_limit' => 3,
|
||||||
'generation_decay_minutes' => 10,
|
'generation_decay_minutes' => 10,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
|
||||||
|
// shape/reasoning as auth.otp above, independent limits since this
|
||||||
|
// is a separate flow (changing an existing account's login email,
|
||||||
|
// not logging in).
|
||||||
|
'email_change' => [
|
||||||
|
'max_attempts' => 5,
|
||||||
|
'generation_limit' => 3,
|
||||||
|
'generation_decay_minutes' => 10,
|
||||||
|
'expiry_minutes' => 10,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backs Modules\Core\Customer\Services\CustomerEmailChangeService — the
|
||||||
|
* pending new-email change lives on the user's own row, same convention
|
||||||
|
* as the existing otp_code/otp_expires_at/otp_attempts columns (Auth\
|
||||||
|
* Services\UserOtpService), rather than the session: a change requested
|
||||||
|
* on one device/session must still be confirmable from another (a code
|
||||||
|
* arrives by email, which is often opened somewhere else entirely), and
|
||||||
|
* a request-scoped session can't survive that.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('pending_email')->nullable()->after('privacy_policy_version');
|
||||||
|
$table->string('pending_email_code_hash')->nullable()->after('pending_email');
|
||||||
|
$table->timestamp('pending_email_expires_at')->nullable()->after('pending_email_code_hash');
|
||||||
|
$table->unsignedTinyInteger('pending_email_attempts')->default(0)->after('pending_email_expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'pending_email',
|
||||||
|
'pending_email_code_hash',
|
||||||
|
'pending_email_expires_at',
|
||||||
|
'pending_email_attempts',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<p style="margin: 0 0 24px 0;">Use the code below to confirm this address as your account's new email.</p>
|
||||||
|
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0; background-color: #f7f6f5; border-radius: 8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 16px 20px; text-align: center; font-size: 28px; font-weight: bold; letter-spacing: 0.25rem;">
|
||||||
|
{{ $code }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 16px 0;">This code expires in 10 minutes.</p>
|
||||||
|
|
||||||
|
<p style="margin: 0;">If you didn't request this change, you can ignore this email — nothing will change.</p>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@extends('emails.layout')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<p style="margin: 0 0 16px 0;">Your account's login email was changed to <strong>{{ $maskedEmail }}</strong>.</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 24px 0;">From now on, login codes will be sent to the new address.</p>
|
||||||
|
|
||||||
|
<p style="margin: 0;">If you didn't make this change, please contact us right away.</p>
|
||||||
|
@endsection
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Auth\Events;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatched by Customer\Services\CustomerEmailChangeService::confirm()
|
||||||
|
* once a login-email change actually takes effect — $oldEmail is what the
|
||||||
|
* account's login used to be, already overwritten on $user by the time
|
||||||
|
* this fires.
|
||||||
|
*/
|
||||||
|
class UserEmailChanged
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly Authenticatable $user,
|
||||||
|
public readonly string $oldEmail,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Auth\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to the NEW address a shopper is trying to switch their login email
|
||||||
|
* to (Customer\Services\CustomerEmailChangeService::request()) — proves
|
||||||
|
* they can actually receive mail there before the switch takes effect.
|
||||||
|
* View overridable per-app the same way UserOtpMail's is (resources/
|
||||||
|
* views/vendor/core/auth/mail/email-change-code.blade.php).
|
||||||
|
*/
|
||||||
|
class EmailChangeCodeMail extends Mailable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $code,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(subject: 'Confirm your new email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'core::auth.mail.email-change-code');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Auth\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to the OLD address once a login-email change actually takes
|
||||||
|
* effect (Customer\Services\CustomerEmailChangeService::confirm()) — lets
|
||||||
|
* the previous owner notice if someone else changed it from a hijacked
|
||||||
|
* session. Shows the new address masked (first character + domain only),
|
||||||
|
* never the full new address — this notice's whole point is alerting the
|
||||||
|
* OLD owner, not handing them the new address outright. View overridable
|
||||||
|
* per-app the same way UserOtpMail's is (resources/views/vendor/core/
|
||||||
|
* auth/mail/email-changed-notice.blade.php).
|
||||||
|
*/
|
||||||
|
class EmailChangedNoticeMail extends Mailable
|
||||||
|
{
|
||||||
|
public readonly string $maskedEmail;
|
||||||
|
|
||||||
|
public function __construct(string $newEmail)
|
||||||
|
{
|
||||||
|
[$local, $domain] = explode('@', $newEmail, 2);
|
||||||
|
|
||||||
|
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(subject: 'Your account email was changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'core::auth.mail.email-changed-notice');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Customer\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by Modules\Core\Customer\Services\CustomerEmailChangeService when
|
||||||
|
* the requested new email already belongs to a different user — checked
|
||||||
|
* both up front (request()) and again at confirm() time, since someone
|
||||||
|
* else could sign up with that address in the window between the two.
|
||||||
|
*/
|
||||||
|
class EmailAlreadyTakenException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('That email address is already in use.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Customer\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by Modules\Core\Customer\Services\CustomerEmailChangeService::
|
||||||
|
* confirm() for a wrong, expired, or already-burned (too many wrong
|
||||||
|
* guesses) code — deliberately one exception for all three, the same way
|
||||||
|
* Auth\Services\UserOtpService::validate() collapses them into a single
|
||||||
|
* null return, so a caller can't distinguish "wrong code" from "no
|
||||||
|
* pending change at all" and use that to probe for one.
|
||||||
|
*/
|
||||||
|
class InvalidEmailChangeCodeException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('That code is invalid or has expired.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user