Skip to content

Add support for secure email change with conflict resolution and verification flow #106

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions app/Http/Controllers/Auth/VerifyEmailController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use App\Jobs\VerifyUserEmail;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;

Expand All @@ -14,16 +14,14 @@ class VerifyEmailController extends Controller
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
$user = $request->user();

if ($user->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}

if ($request->user()->markEmailAsVerified()) {
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
$user = $request->user();

event(new Verified($user));
}
dispatch(new VerifyUserEmail($user));

return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
Expand Down
69 changes: 69 additions & 0 deletions app/Jobs/VerifyUserEmail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace App\Jobs;

use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\DB;

readonly class VerifyUserEmail
{
public function __construct(
private User $user
) {}

public function handle(): void
{
if (! $this->user instanceof MustVerifyEmail) {
return;
}

DB::transaction(function (): void {
$this->purgeConflicts();
$this->applyVerification();
});

event(new Verified($this->user));
}

/**
* Remove conflicting unverified users and reservations.
*/
private function purgeConflicts(): void
{
$email = $this->user->unverified_email ?? $this->user->email;

User::query()
->where('email', $email)
->whereNot('id', $this->user->id)
->whereNull('email_verified_at')
->delete();

User::query()
->where('unverified_email', $email)
->whereNot('id', $this->user->id)
->whereNull('email_verified_at')
->update(['unverified_email' => null]);
}

/**
* Apply email change or mark as verified.
*/
private function applyVerification(): void
{
if ($this->user->unverified_email) {
$this->user->forceFill([
'email' => $this->user->unverified_email,
'unverified_email' => null,
'email_verified_at' => now(),
])->save();

return;
}

$this->user->forceFill([
'email_verified_at' => now(),
])->save();
}
}
5 changes: 3 additions & 2 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;

class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
Expand All @@ -21,6 +21,7 @@ class User extends Authenticatable
protected $fillable = [
'name',
'email',
'unverified_email',
'password',
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public function up(): void
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('unverified_email')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
Expand Down
16 changes: 10 additions & 6 deletions resources/views/livewire/settings/profile.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@

new class extends Component {
public string $name = '';
public string $email = '';
public string $unverified_email = '';

/**
* Mount the component.
*/
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
$this->unverified_email = Auth::user()->unverified_email ?? Auth::user()->email;
}

/**
Expand All @@ -29,19 +29,23 @@ public function updateProfileInformation(): void
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],

'email' => [
'unverified_email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($user->id)
Rule::unique(User::class, 'email')->ignore($user->id)
],
]);

if ($validated['unverified_email'] === Auth::user()->email) {
unset($validated['unverified_email']);
}

$user->fill($validated);

if ($user->isDirty('email')) {
if ($user->isDirty('unverified_email')) {
$user->email_verified_at = null;
}

Expand Down Expand Up @@ -77,7 +81,7 @@ public function resendVerificationNotification(): void
<flux:input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" />

<div>
<flux:input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" />
<flux:input wire:model="unverified_email" :label="__('Email')" type="email" required autocomplete="email" />

@if (auth()->user() instanceof \Illuminate\Contracts\Auth\MustVerifyEmail &&! auth()->user()->hasVerifiedEmail())
<div>
Expand Down
49 changes: 49 additions & 0 deletions tests/Feature/Auth/EmailVerificationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,53 @@ public function test_email_is_not_verified_with_invalid_hash(): void

$this->assertFalse($user->fresh()->hasVerifiedEmail());
}

public function test_unverified_email_resets_when_another_user_verifies_their_registration_email(): void
{
$unverifiedUser = User::factory()->unverified()->create([
'unverified_email' => '[email protected]',
]);

$user = User::factory()->unverified()->create([
'email' => $unverifiedUser->unverified_email,
]);

Event::fake();

$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);

$response = $this->actingAs($user)->get($verificationUrl);

$this->assertTrue($user->fresh()->hasVerifiedEmail());
$this->assertNull($user->fresh()->unverified_email);
$this->assertNull($unverifiedUser->fresh()->unverified_email);
}

public function test_unverified_user_deleted_when_another_user_verifies_their_new_email(): void
{
$unverifiedUser = User::factory()->unverified()->create();

$user = User::factory()->unverified()->create([
'unverified_email' => $unverifiedUser->email,
]);

Event::fake();

$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);

$response = $this->actingAs($user)->get($verificationUrl);

$this->assertTrue($user->fresh()->hasVerifiedEmail());
$this->assertNull($user->fresh()->unverified_email);
$this->assertEquals($user->fresh()->email, $unverifiedUser->email);
$this->assertNull($unverifiedUser->fresh());
}
}
6 changes: 3 additions & 3 deletions tests/Feature/Settings/ProfileUpdateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ public function test_profile_information_can_be_updated(): void

$response = Volt::test('settings.profile')
->set('name', 'Test User')
->set('email', '[email protected]')
->set('unverified_email', '[email protected]')
->call('updateProfileInformation');

$response->assertHasNoErrors();

$user->refresh();

$this->assertEquals('Test User', $user->name);
$this->assertEquals('[email protected]', $user->email);
$this->assertEquals('[email protected]', $user->unverified_email);
$this->assertNull($user->email_verified_at);
}

Expand All @@ -46,7 +46,7 @@ public function test_email_verification_status_is_unchanged_when_email_address_i

$response = Volt::test('settings.profile')
->set('name', 'Test User')
->set('email', $user->email)
->set('unverified_email', $user->email)
->call('updateProfileInformation');

$response->assertHasNoErrors();
Expand Down