From ee502b9f76fe9442e32260c0c4997d0c78131d57 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:54:08 +0200 Subject: [PATCH] feat(email): implement email change request and verification process - Added functionality for users to request an email change, including generating a verification code and setting an expiration time. - Implemented methods in the User model to handle email change requests, code validation, and confirmation. - Created a new job to update the user's email in Stripe after confirmation. - Introduced rate limiting for email change requests and verification attempts to prevent abuse. - Added a new notification for email change verification. - Updated the profile component to manage email change requests and verification UI. --- app/Jobs/CleanupInstanceStuffsJob.php | 13 ++ app/Jobs/UpdateStripeCustomerEmailJob.php | 133 ++++++++++++ app/Livewire/Profile/Index.php | 189 ++++++++++++++++++ app/Models/User.php | 74 +++++++ .../Channels/TransactionalEmailChannel.php | 7 +- .../EmailChangeVerification.php | 43 ++++ bootstrap/helpers/subscriptions.php | 19 ++ config/constants.php | 4 + ...add_email_change_fields_to_users_table.php | 30 +++ .../views/components/forms/input.blade.php | 6 +- .../email-change-verification.blade.php | 11 + .../views/livewire/profile/index.blade.php | 43 +++- 12 files changed, 568 insertions(+), 4 deletions(-) create mode 100644 app/Jobs/UpdateStripeCustomerEmailJob.php create mode 100644 app/Notifications/TransactionalEmails/EmailChangeVerification.php create mode 100644 database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php create mode 100644 resources/views/emails/email-change-verification.blade.php diff --git a/app/Jobs/CleanupInstanceStuffsJob.php b/app/Jobs/CleanupInstanceStuffsJob.php index 60ae58489..011c58639 100644 --- a/app/Jobs/CleanupInstanceStuffsJob.php +++ b/app/Jobs/CleanupInstanceStuffsJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Models\TeamInvitation; +use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -30,6 +31,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho { try { $this->cleanupInvitationLink(); + $this->cleanupExpiredEmailChangeRequests(); } catch (\Throwable $e) { Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage()); } @@ -42,4 +44,15 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho $item->isValid(); } } + + private function cleanupExpiredEmailChangeRequests() + { + User::whereNotNull('email_change_code_expires_at') + ->where('email_change_code_expires_at', '<', now()) + ->update([ + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + } } diff --git a/app/Jobs/UpdateStripeCustomerEmailJob.php b/app/Jobs/UpdateStripeCustomerEmailJob.php new file mode 100644 index 000000000..2e86c14a0 --- /dev/null +++ b/app/Jobs/UpdateStripeCustomerEmailJob.php @@ -0,0 +1,133 @@ +onQueue('high'); + } + + public function handle(): void + { + try { + if (! isCloud() || ! $this->team->subscription) { + Log::info('Skipping Stripe email update - not cloud or no subscription', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + // Check if the user changing email is a team owner + $isOwner = $this->team->members() + ->wherePivot('role', 'owner') + ->where('users.id', $this->userId) + ->exists(); + + if (! $isOwner) { + Log::info('Skipping Stripe email update - user is not team owner', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + // Get current Stripe customer email to verify it matches the user's old email + $stripe_customer_id = data_get($this->team, 'subscription.stripe_customer_id'); + if (! $stripe_customer_id) { + Log::info('Skipping Stripe email update - no Stripe customer ID', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + ]); + + return; + } + + Stripe::setApiKey(config('subscription.stripe_api_key')); + + try { + $stripeCustomer = \Stripe\Customer::retrieve($stripe_customer_id); + $currentStripeEmail = $stripeCustomer->email; + + // Only update if the current Stripe email matches the user's old email + if (strtolower($currentStripeEmail) !== strtolower($this->oldEmail)) { + Log::info('Skipping Stripe email update - Stripe customer email does not match user old email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'stripe_email' => $currentStripeEmail, + 'user_old_email' => $this->oldEmail, + ]); + + return; + } + + // Update Stripe customer email + \Stripe\Customer::update($stripe_customer_id, ['email' => $this->newEmail]); + + } catch (\Exception $e) { + Log::error('Failed to retrieve or update Stripe customer', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'stripe_customer_id' => $stripe_customer_id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + + Log::info('Successfully updated Stripe customer email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + ]); + } catch (\Exception $e) { + Log::error('Failed to update Stripe customer email', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + 'error' => $e->getMessage(), + 'attempt' => $this->attempts(), + ]); + + // Re-throw to trigger retry + throw $e; + } + } + + public function failed(\Throwable $exception): void + { + Log::error('Permanently failed to update Stripe customer email after all retries', [ + 'team_id' => $this->team->id, + 'user_id' => $this->userId, + 'old_email' => $this->oldEmail, + 'new_email' => $this->newEmail, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 788802353..a6b4dbe9e 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -4,6 +4,7 @@ namespace App\Livewire\Profile; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; @@ -23,11 +24,25 @@ class Index extends Component #[Validate('required')] public string $name; + public string $new_email = ''; + + public string $email_verification_code = ''; + + public bool $show_email_change = false; + + public bool $show_verification = false; + public function mount() { $this->userId = Auth::id(); $this->name = Auth::user()->name; $this->email = Auth::user()->email; + + // Check if there's a pending email change + if (Auth::user()->hasEmailChangeRequest()) { + $this->new_email = Auth::user()->pending_email; + $this->show_verification = true; + } } public function submit() @@ -46,6 +61,180 @@ class Index extends Component } } + public function requestEmailChange() + { + try { + // For self-hosted, check if email is enabled + if (! isCloud()) { + $settings = instanceSettings(); + if (! $settings->smtp_enabled && ! $settings->resend_enabled) { + $this->dispatch('error', 'Email functionality is not configured. Please contact your administrator.'); + + return; + } + } + + $this->validate([ + 'new_email' => ['required', 'email', 'unique:users,email'], + ]); + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit by current user's email (1 request per 2 minutes) + $userEmailKey = 'email-change:user:'.Auth::id(); + if (! RateLimiter::attempt($userEmailKey, 1, function () {}, 120)) { + $seconds = RateLimiter::availableIn($userEmailKey); + $this->dispatch('error', 'Too many requests. Please wait '.$seconds.' seconds before trying again.'); + + return; + } + + // Rate limit by new email address (3 requests per hour per email) + $newEmailKey = 'email-change:email:'.md5(strtolower($this->new_email)); + if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) { + $this->dispatch('error', 'This email address has received too many verification requests. Please try again later.'); + + return; + } + + // Additional rate limit by IP address (5 requests per hour) + $ipKey = 'email-change:ip:'.request()->ip(); + if (! RateLimiter::attempt($ipKey, 5, function () {}, 3600)) { + $this->dispatch('error', 'Too many requests from your IP address. Please try again later.'); + + return; + } + } + + Auth::user()->requestEmailChange($this->new_email); + + $this->show_email_change = false; + $this->show_verification = true; + + $this->dispatch('success', 'Verification code sent to '.$this->new_email); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function verifyEmailChange() + { + try { + $this->validate([ + 'email_verification_code' => ['required', 'string', 'size:6'], + ]); + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit verification attempts (5 attempts per 10 minutes) + $verifyKey = 'email-verify:user:'.Auth::id(); + if (! RateLimiter::attempt($verifyKey, 5, function () {}, 600)) { + $seconds = RateLimiter::availableIn($verifyKey); + $minutes = ceil($seconds / 60); + $this->dispatch('error', 'Too many verification attempts. Please wait '.$minutes.' minutes before trying again.'); + + // If too many failed attempts, clear the email change request for security + if (RateLimiter::attempts($verifyKey) >= 10) { + Auth::user()->clearEmailChangeRequest(); + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_verification = false; + $this->dispatch('error', 'Email change request cancelled due to too many failed attempts. Please start over.'); + } + + return; + } + } + + if (! Auth::user()->isEmailChangeCodeValid($this->email_verification_code)) { + $this->dispatch('error', 'Invalid or expired verification code.'); + + return; + } + + if (Auth::user()->confirmEmailChange($this->email_verification_code)) { + // Clear rate limiters on successful verification (only in production) + if (! isDev()) { + $verifyKey = 'email-verify:user:'.Auth::id(); + RateLimiter::clear($verifyKey); + } + + $this->email = Auth::user()->email; + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_verification = false; + + $this->dispatch('success', 'Email address updated successfully.'); + } else { + $this->dispatch('error', 'Failed to update email address.'); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function resendVerificationCode() + { + try { + // Check if there's a pending request + if (! Auth::user()->hasEmailChangeRequest()) { + $this->dispatch('error', 'No pending email change request.'); + + return; + } + + // Check if enough time has passed (at least half of the expiry time) + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + $halfExpiryMinutes = $expiryMinutes / 2; + $codeExpiry = Auth::user()->email_change_code_expires_at; + $timeSinceCreated = $codeExpiry->subMinutes($expiryMinutes)->diffInMinutes(now()); + + if ($timeSinceCreated < $halfExpiryMinutes) { + $minutesToWait = ceil($halfExpiryMinutes - $timeSinceCreated); + $this->dispatch('error', 'Please wait '.$minutesToWait.' more minutes before requesting a new code.'); + + return; + } + + $pendingEmail = Auth::user()->pending_email; + + // Skip rate limiting in development mode + if (! isDev()) { + // Rate limit by email address + $newEmailKey = 'email-change:email:'.md5(strtolower($pendingEmail)); + if (! RateLimiter::attempt($newEmailKey, 3, function () {}, 3600)) { + $this->dispatch('error', 'This email address has received too many verification requests. Please try again later.'); + + return; + } + } + + // Generate and send new code + Auth::user()->requestEmailChange($pendingEmail); + + $this->dispatch('success', 'New verification code sent to '.$pendingEmail); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function cancelEmailChange() + { + Auth::user()->clearEmailChangeRequest(); + $this->new_email = ''; + $this->email_verification_code = ''; + $this->show_email_change = false; + $this->show_verification = false; + + $this->dispatch('success', 'Email change request cancelled.'); + } + + public function showEmailChangeForm() + { + $this->show_email_change = true; + $this->new_email = ''; + } + public function resetPassword() { try { diff --git a/app/Models/User.php b/app/Models/User.php index 3c5a220f8..48651d292 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -53,6 +53,7 @@ class User extends Authenticatable implements SendsEmail 'email_verified_at' => 'datetime', 'force_password_reset' => 'boolean', 'show_boarding' => 'boolean', + 'email_change_code_expires_at' => 'datetime', ]; protected static function boot() @@ -320,4 +321,77 @@ class User extends Authenticatable implements SendsEmail return data_get($user, 'pivot.role'); } + + public function requestEmailChange(string $newEmail): void + { + // Generate 6-digit code + $code = sprintf('%06d', mt_rand(0, 999999)); + + // Set expiration using config value + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + $expiresAt = Carbon::now()->addMinutes($expiryMinutes); + + $this->update([ + 'pending_email' => $newEmail, + 'email_change_code' => $code, + 'email_change_code_expires_at' => $expiresAt, + ]); + + // Send verification email to new address + $this->notify(new \App\Notifications\TransactionalEmails\EmailChangeVerification($this, $code, $newEmail, $expiresAt)); + } + + public function isEmailChangeCodeValid(string $code): bool + { + return $this->email_change_code === $code + && $this->email_change_code_expires_at + && Carbon::now()->lessThan($this->email_change_code_expires_at); + } + + public function confirmEmailChange(string $code): bool + { + if (! $this->isEmailChangeCodeValid($code)) { + return false; + } + + $oldEmail = $this->email; + $newEmail = $this->pending_email; + + // Update email and clear change request fields + $this->update([ + 'email' => $newEmail, + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + + // For cloud users, dispatch job to update Stripe customer email asynchronously + if (isCloud() && $this->currentTeam()->subscription) { + dispatch(new \App\Jobs\UpdateStripeCustomerEmailJob( + $this->currentTeam(), + $this->id, + $newEmail, + $oldEmail + )); + } + + return true; + } + + public function clearEmailChangeRequest(): void + { + $this->update([ + 'pending_email' => null, + 'email_change_code' => null, + 'email_change_code_expires_at' => null, + ]); + } + + public function hasEmailChangeRequest(): bool + { + return ! is_null($this->pending_email) + && ! is_null($this->email_change_code) + && $this->email_change_code_expires_at + && Carbon::now()->lessThan($this->email_change_code_expires_at); + } } diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 114d1f6ed..8ab74a60b 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -16,7 +16,12 @@ class TransactionalEmailChannel if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) { return; } - $email = $notifiable->email; + + // Check if notification has a custom recipient (for email changes) + $email = property_exists($notification, 'newEmail') && $notification->newEmail + ? $notification->newEmail + : $notifiable->email; + if (! $email) { return; } diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php new file mode 100644 index 000000000..ea8462366 --- /dev/null +++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php @@ -0,0 +1,43 @@ +onQueue('high'); + } + + public function toMail(): MailMessage + { + // Use the configured expiry minutes value + $expiryMinutes = config('constants.email_change.verification_code_expiry_minutes', 10); + + $mail = new MailMessage; + $mail->subject('Coolify: Verify Your New Email Address'); + $mail->view('emails.email-change-verification', [ + 'newEmail' => $this->newEmail, + 'verificationCode' => $this->verificationCode, + 'expiryMinutes' => $expiryMinutes, + ]); + + return $mail; + } +} diff --git a/bootstrap/helpers/subscriptions.php b/bootstrap/helpers/subscriptions.php index 510516a2f..48c3a62c3 100644 --- a/bootstrap/helpers/subscriptions.php +++ b/bootstrap/helpers/subscriptions.php @@ -89,3 +89,22 @@ function allowedPathsForInvalidAccounts() 'livewire/update', ]; } + +function updateStripeCustomerEmail(Team $team, string $newEmail): void +{ + if (! isStripe()) { + return; + } + + $stripe_customer_id = data_get($team, 'subscription.stripe_customer_id'); + if (! $stripe_customer_id) { + return; + } + + Stripe::setApiKey(config('subscription.stripe_api_key')); + + \Stripe\Customer::update( + $stripe_customer_id, + ['email' => $newEmail] + ); +} diff --git a/config/constants.php b/config/constants.php index ecb2a85c5..bbd442654 100644 --- a/config/constants.php +++ b/config/constants.php @@ -70,6 +70,10 @@ return [ ], ], + 'email_change' => [ + 'verification_code_expiry_minutes' => 10, + ], + 'sentry' => [ 'sentry_dsn' => env('SENTRY_DSN'), ], diff --git a/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php new file mode 100644 index 000000000..9cefe2c09 --- /dev/null +++ b/database/migrations/2025_08_18_104146_add_email_change_fields_to_users_table.php @@ -0,0 +1,30 @@ +string('pending_email')->nullable()->after('email'); + $table->string('email_change_code', 6)->nullable()->after('pending_email'); + $table->timestamp('email_change_code_expires_at')->nullable()->after('email_change_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['pending_email', 'email_change_code', 'email_change_code_expires_at']); + }); + } +}; diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 799a5f110..858f5ac1c 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -32,7 +32,8 @@ wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" - aria-placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif> + aria-placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) x-ref="autofocusInput" @endif> @else @@ -45,7 +46,8 @@ max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}" maxlength="{{ $attributes->get('maxlength') }}" @if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}" - placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif> + placeholder="{{ $attributes->get('placeholder') }}" + @if ($autofocus) x-ref="autofocusInput" @endif> @endif @if (!$label && $helper) diff --git a/resources/views/emails/email-change-verification.blade.php b/resources/views/emails/email-change-verification.blade.php new file mode 100644 index 000000000..6921c8bb3 --- /dev/null +++ b/resources/views/emails/email-change-verification.blade.php @@ -0,0 +1,11 @@ + +You have requested to change your email address to: {{ $newEmail }} + +Please use the following verification code to confirm this change: + +Verification Code: {{ $verificationCode }} + +This code is valid for {{ $expiryMinutes }} minutes. + +If you did not request this change, please ignore this email and your email address will remain unchanged. + \ No newline at end of file diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 0dbf09463..2888b82a8 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -9,11 +9,52 @@

General

Save -
+
+ @if (!$show_email_change && !$show_verification) + Change Email + @else + Change Email + @endif
+ +
+ @if ($show_email_change) +
+
+ + Send Verification Code + Cancel +
+
A verification code will be sent to your + new email + address.
+
+ @endif + + @if ($show_verification) +
+
+ + Verify & Update Email + Resend + Code + Cancel +
+
+ Verification code sent to {{ $new_email ?? auth()->user()->pending_email }}. + The code is valid for {{ config('constants.email_change.verification_code_expiry_minutes', 10) }} + minutes. +
+ + +
+ @endif +

Change Password