Files
coolify/app/Livewire/Profile/Index.php
Andras Bacsai ee502b9f76 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.
2025-08-18 14:54:08 +02:00

273 lines
9.4 KiB
PHP

<?php
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;
class Index extends Component
{
public int $userId;
public string $email;
public string $current_password;
public string $new_password;
public string $new_password_confirmation;
#[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()
{
try {
$this->validate([
'name' => 'required',
]);
Auth::user()->update([
'name' => $this->name,
]);
$this->dispatch('success', 'Profile updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
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 {
$this->validate([
'current_password' => ['required'],
'new_password' => ['required', Password::defaults(), 'confirmed'],
]);
if (! Hash::check($this->current_password, auth()->user()->password)) {
$this->dispatch('error', 'Current password is incorrect.');
return;
}
if ($this->new_password !== $this->new_password_confirmation) {
$this->dispatch('error', 'The two new passwords does not match.');
return;
}
auth()->user()->update([
'password' => Hash::make($this->new_password),
]);
$this->dispatch('success', 'Password updated.');
$this->current_password = '';
$this->new_password = '';
$this->new_password_confirmation = '';
$this->dispatch('reloadWindow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.profile.index');
}
}