diff --git a/app/Http/Controllers/Auth/VerifyEmailController.php b/app/Http/Controllers/Auth/VerifyEmailController.php new file mode 100644 index 00000000..12a3e7a7 --- /dev/null +++ b/app/Http/Controllers/Auth/VerifyEmailController.php @@ -0,0 +1,41 @@ +find($id); + + if (! $user || ! hash_equals($hash, sha1($user->getEmailForVerification()))) { + abort(403); + } + + if (! $user->hasVerifiedEmail() && $user->markEmailAsVerified()) { + event(new Verified($user)); + } + + if ($request->user()?->is($user)) { + return redirect()->intended(route('dashboard').'?verified=1'); + } + + return redirect()->route('login')->with( + 'status', + __('Your email has been verified. You can now sign in.'), + ); + } +} diff --git a/app/Notifications/VerifyEmailNotification.php b/app/Notifications/VerifyEmailNotification.php index 08e0cd50..cb4e65ac 100644 --- a/app/Notifications/VerifyEmailNotification.php +++ b/app/Notifications/VerifyEmailNotification.php @@ -4,6 +4,9 @@ namespace App\Notifications; use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\URL; class VerifyEmailNotification extends VerifyEmail { @@ -18,4 +21,20 @@ class VerifyEmailNotification extends VerifyEmail 'verificationUrl' => $verificationUrl, ]); } + + /** + * Build a signed verification URL that does not require an authenticated session, + * so the link verifies the email even when opened in a logged-out browser. + */ + protected function verificationUrl($notifiable): string + { + return URL::temporarySignedRoute( + 'verification.verify.public', + Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)), + [ + 'id' => $notifiable->getKey(), + 'hash' => sha1($notifiable->getEmailForVerification()), + ], + ); + } } diff --git a/routes/web.php b/routes/web.php index 27641c48..ac367dc5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('user-leads.verify'); Route::get('waitlist/thank-you/{lead}', [UserLeadController::class, 'thankYou'])->name('waitlist.thank-you'); +Route::get('verify-email/{id}/{hash}', VerifyEmailController::class) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify.public'); + Route::get('privacy', function () { return Inertia::render('privacy'); })->name('privacy'); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 81139ef6..e29be976 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -91,6 +91,84 @@ test('already verified user visiting verification link is redirected without fir Event::assertNotDispatched(Verified::class); }); +test('email can be verified via public link when not logged in', function () { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify.public', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $this->get($verificationUrl) + ->assertRedirect(route('login')) + ->assertSessionHas('status'); + + Event::assertDispatched(Verified::class); + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); +}); + +test('email can be verified via public link when logged in', function () { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify.public', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $this->actingAs($user)->get($verificationUrl) + ->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + + Event::assertDispatched(Verified::class); + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); +}); + +test('public verification link rejects invalid hash', function () { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify.public', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->get($verificationUrl)->assertForbidden(); + + expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); +}); + +test('public verification link rejects unsigned requests', function () { + $user = User::factory()->unverified()->create(); + + $this->get(route('verification.verify.public', [ + 'id' => $user->id, + 'hash' => sha1($user->email), + ]))->assertForbidden(); + + expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); +}); + +test('already verified user visiting public link does not refire event', function () { + $user = User::factory()->create(['email_verified_at' => now()]); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify.public', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $this->get($verificationUrl)->assertRedirect(route('login')); + + Event::assertNotDispatched(Verified::class); +}); + test('unverified user is redirected to verification notice from protected routes', function (string $route) { $user = User::factory()->unverified()->onboarded()->create(); diff --git a/tests/Feature/Auth/VerificationNotificationTest.php b/tests/Feature/Auth/VerificationNotificationTest.php index b664e372..8128f9c5 100644 --- a/tests/Feature/Auth/VerificationNotificationTest.php +++ b/tests/Feature/Auth/VerificationNotificationTest.php @@ -18,6 +18,18 @@ test('sends verification notification', function () { Notification::assertSentTo($user, VerifyEmailNotification::class); }); +test('verification email links to the public signed route', function () { + $user = User::factory()->create([ + 'email_verified_at' => null, + ]); + + $mail = (new VerifyEmailNotification)->toMail($user); + + expect($mail->viewData['verificationUrl']) + ->toContain('/verify-email/'.$user->id.'/'.sha1($user->email)) + ->toContain('signature='); +}); + test('does not send verification notification if email is verified', function () { Notification::fake();