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(); } }