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.
This commit is contained in:
Andras Bacsai
2025-08-18 14:54:08 +02:00
parent 5cfe6464aa
commit ee502b9f76
12 changed files with 568 additions and 4 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\TeamInvitation; use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUnique;
@@ -30,6 +31,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
{ {
try { try {
$this->cleanupInvitationLink(); $this->cleanupInvitationLink();
$this->cleanupExpiredEmailChangeRequests();
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage()); Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
} }
@@ -42,4 +44,15 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
$item->isValid(); $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,
]);
}
} }

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Jobs;
use App\Models\Team;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
class UpdateStripeCustomerEmailJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $backoff = [10, 30, 60];
public function __construct(
private Team $team,
private int $userId,
private string $newEmail,
private string $oldEmail
) {
$this->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(),
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Livewire\Profile;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -23,11 +24,25 @@ class Index extends Component
#[Validate('required')] #[Validate('required')]
public string $name; 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() public function mount()
{ {
$this->userId = Auth::id(); $this->userId = Auth::id();
$this->name = Auth::user()->name; $this->name = Auth::user()->name;
$this->email = Auth::user()->email; $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() 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() public function resetPassword()
{ {
try { try {

View File

@@ -53,6 +53,7 @@ class User extends Authenticatable implements SendsEmail
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'force_password_reset' => 'boolean', 'force_password_reset' => 'boolean',
'show_boarding' => 'boolean', 'show_boarding' => 'boolean',
'email_change_code_expires_at' => 'datetime',
]; ];
protected static function boot() protected static function boot()
@@ -320,4 +321,77 @@ class User extends Authenticatable implements SendsEmail
return data_get($user, 'pivot.role'); 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);
}
} }

View File

@@ -16,7 +16,12 @@ class TransactionalEmailChannel
if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) { if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) {
return; 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) { if (! $email) {
return; return;
} }

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Notifications\TransactionalEmails;
use App\Models\User;
use App\Notifications\Channels\TransactionalEmailChannel;
use App\Notifications\CustomEmailNotification;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
class EmailChangeVerification extends CustomEmailNotification
{
public function via(): array
{
return [TransactionalEmailChannel::class];
}
public function __construct(
public User $user,
public string $verificationCode,
public string $newEmail,
public Carbon $expiresAt,
public bool $isTransactionalEmail = true
) {
$this->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;
}
}

View File

@@ -89,3 +89,22 @@ function allowedPathsForInvalidAccounts()
'livewire/update', '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]
);
}

View File

@@ -70,6 +70,10 @@ return [
], ],
], ],
'email_change' => [
'verification_code_expiry_minutes' => 10,
],
'sentry' => [ 'sentry' => [
'sentry_dsn' => env('SENTRY_DSN'), 'sentry_dsn' => env('SENTRY_DSN'),
], ],

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -32,7 +32,8 @@
wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled" wire:dirty.class="dark:focus:ring-warning dark:ring-warning" wire:loading.attr="disabled"
type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}" type="{{ $type }}" @readonly($readonly) @disabled($disabled) id="{{ $id }}"
name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" 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>
</div> </div>
@else @else
@@ -45,7 +46,8 @@
max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}" max="{{ $attributes->get('max') }}" minlength="{{ $attributes->get('minlength') }}"
maxlength="{{ $attributes->get('maxlength') }}" maxlength="{{ $attributes->get('maxlength') }}"
@if ($id !== 'null') id={{ $id }} @endif name="{{ $name }}" @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 @endif
@if (!$label && $helper) @if (!$label && $helper)
<x-helper :helper="$helper" /> <x-helper :helper="$helper" />

View File

@@ -0,0 +1,11 @@
<x-emails.layout>
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.
</x-emails.layout>

View File

@@ -9,11 +9,52 @@
<h2>General</h2> <h2>General</h2>
<x-forms.button type="submit" label="Save">Save</x-forms.button> <x-forms.button type="submit" label="Save">Save</x-forms.button>
</div> </div>
<div class="flex flex-col gap-2 lg:flex-row"> <div class="flex flex-col gap-2 lg:flex-row items-end">
<x-forms.input id="name" label="Name" required /> <x-forms.input id="name" label="Name" required />
<x-forms.input id="email" label="Email" readonly /> <x-forms.input id="email" label="Email" readonly />
@if (!$show_email_change && !$show_verification)
<x-forms.button wire:click="showEmailChangeForm" type="button">Change Email</x-forms.button>
@else
<x-forms.button wire:click="showEmailChangeForm" type="button" disabled>Change Email</x-forms.button>
@endif
</div> </div>
</form> </form>
<div class="flex flex-col pt-4">
@if ($show_email_change)
<form wire:submit='requestEmailChange'>
<div class="flex gap-2 items-end">
<x-forms.input id="new_email" label="New Email Address" required type="email" />
<x-forms.button type="submit">Send Verification Code</x-forms.button>
<x-forms.button wire:click="$set('show_email_change', false)" type="button"
isError>Cancel</x-forms.button>
</div>
<div class="text-xs font-bold dark:text-warning pt-2">A verification code will be sent to your
new email
address.</div>
</form>
@endif
@if ($show_verification)
<form wire:submit='verifyEmailChange'>
<div class="flex gap-2 items-end">
<x-forms.input id="email_verification_code" label="Verification Code (6 digits)" required
maxlength="6" />
<x-forms.button type="submit">Verify & Update Email</x-forms.button>
<x-forms.button wire:click="resendVerificationCode" type="button" isWarning>Resend
Code</x-forms.button>
<x-forms.button wire:click="cancelEmailChange" type="button" isError>Cancel</x-forms.button>
</div>
<div class="text-xs font-bold dark:text-warning pt-2">
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.
</div>
</form>
@endif
</div>
<form wire:submit='resetPassword' class="flex flex-col pt-4"> <form wire:submit='resetPassword' class="flex flex-col pt-4">
<div class="flex items-center gap-2 pb-2"> <div class="flex items-center gap-2 pb-2">
<h2>Change Password</h2> <h2>Change Password</h2>