Feat: Pending Email Change Updates, Moving Mailables to core

This commit is contained in:
2026-09-25 15:37:18 +03:00
parent 01c49485be
commit 099271e0a8
10 changed files with 368 additions and 0 deletions
+11
View File
@@ -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
+19
View File
@@ -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,
) {}
}
+31
View File
@@ -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');
}
}
+39
View File
@@ -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();
}
}