diff --git a/config/core.php b/config/core.php index 77c55fa..83ca001 100644 --- a/config/core.php +++ b/config/core.php @@ -125,6 +125,17 @@ return [ 'generation_limit' => 3, '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, + ], ], ]; diff --git a/database/migrations/2026_09_25_000003_add_pending_email_change_to_users_table.php b/database/migrations/2026_09_25_000003_add_pending_email_change_to_users_table.php new file mode 100644 index 0000000..e3a741c --- /dev/null +++ b/database/migrations/2026_09_25_000003_add_pending_email_change_to_users_table.php @@ -0,0 +1,39 @@ +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', + ]); + }); + } +}; diff --git a/resources/views/auth/mail/email-change-code.blade.php b/resources/views/auth/mail/email-change-code.blade.php new file mode 100644 index 0000000..ab4d248 --- /dev/null +++ b/resources/views/auth/mail/email-change-code.blade.php @@ -0,0 +1,17 @@ +@extends('emails.layout') + +@section('content') +

Use the code below to confirm this address as your account's new email.

+ + + + + +
+ {{ $code }} +
+ +

This code expires in 10 minutes.

+ +

If you didn't request this change, you can ignore this email — nothing will change.

+@endsection diff --git a/resources/views/auth/mail/email-changed-notice.blade.php b/resources/views/auth/mail/email-changed-notice.blade.php new file mode 100644 index 0000000..9edd832 --- /dev/null +++ b/resources/views/auth/mail/email-changed-notice.blade.php @@ -0,0 +1,9 @@ +@extends('emails.layout') + +@section('content') +

Your account's login email was changed to {{ $maskedEmail }}.

+ +

From now on, login codes will be sent to the new address.

+ +

If you didn't make this change, please contact us right away.

+@endsection diff --git a/src/Auth/Events/UserEmailChanged.php b/src/Auth/Events/UserEmailChanged.php new file mode 100644 index 0000000..ec1a58b --- /dev/null +++ b/src/Auth/Events/UserEmailChanged.php @@ -0,0 +1,19 @@ +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'); + } +} diff --git a/src/Customer/Exceptions/EmailAlreadyTakenException.php b/src/Customer/Exceptions/EmailAlreadyTakenException.php new file mode 100644 index 0000000..74615fb --- /dev/null +++ b/src/Customer/Exceptions/EmailAlreadyTakenException.php @@ -0,0 +1,19 @@ +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(); + } +}