From 14b79557d79dd1c5c5876b6c6b1a480baedd536e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 5 Jun 2026 10:01:32 +0200 Subject: [PATCH] fix: verify email via signed link without requiring login (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When the verification email link opens in a browser where the user is not logged in (common on phones, where the link escapes the app), the `verification.verify` route's `auth` middleware redirects to login and the email is never verified. ## Fix The signed verification URL already self-secures via `id` + `hash` + signature + expiry, so the `auth` requirement was redundant. This adds a public, signed-only verify route and points the verification email at it. - `Auth/VerifyEmailController` — resolves the user by `id`, validates `hash` with `hash_equals(sha1(email))`, marks verified and fires `Verified`. Guests → `login` with a success status; the same logged-in user → `dashboard?verified=1`. - New route `GET /verify-email/{id}/{hash}` with `signed` + `throttle` (name `verification.verify.public`). Distinct URI/name so it doesn't clash with Fortify's existing auth-protected route, which stays intact. - `VerifyEmailNotification::verificationUrl()` overridden to sign the public route. Now the link verifies regardless of login state, and the user can return to the app and sign in. ## Tests - 6 new cases in `EmailVerificationTest` (logged-out verify, logged-in verify, bad hash, unsigned request, already-verified no refire). - 1 new case in `VerificationNotificationTest` asserting the email links the public signed route. - All pass; pint clean. --- .../Auth/VerifyEmailController.php | 41 ++++++++++ app/Notifications/VerifyEmailNotification.php | 19 +++++ routes/web.php | 5 ++ tests/Feature/Auth/EmailVerificationTest.php | 78 +++++++++++++++++++ .../Auth/VerificationNotificationTest.php | 12 +++ 5 files changed, 155 insertions(+) create mode 100644 app/Http/Controllers/Auth/VerifyEmailController.php 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();