From 87329c3c29443e275d5fb8257ae389dec613bec2 Mon Sep 17 00:00:00 2001 From: Sourov Biswas Date: Sun, 20 Sep 2026 17:13:44 +0600 Subject: [PATCH] Require a verified email address before the app opens MustVerifyEmail was commented out on the User model, so Laravel never sent a verification mail and hasVerifiedEmail() was always true. That is why the `verified` middleware was dead weight and got removed: the contract behind it was never implemented. Implementing it is the half that was missing. Everything else was already here and already tested -- Fortify's three verification routes, the verify-email page, VerifyEmailResponse, an unverified() factory state, EmailVerificationTest and VerificationNotificationTest -- so this turns the flow on rather than building it. The middleware goes back on, and this time it bites. Registration signs the account in, sends the mail and holds it at the prompt until the link is clicked. Accepting an invitation is gated too, which it was not before. An invitation is addressed to an email; joining an organization on the strength of one should require proving the address is yours, or anyone could type a colleague's address at registration and walk in. The profile page stays reachable without verification, so an address typed wrong can still be corrected. /dashboard is new, and is needed now. config('fortify.home') is the fixed string '/dashboard', which Fortify redirects to on its own when an already verified user opens the verification prompt or asks for another mail. Every real dashboard lives under an organization, so that path was a 404 waiting for the first person to click a stale link. It now forwards to the current organization, or to onboarding. .env.example pointed at MAIL_MAILER=log on port 2525. Nothing listens on 2525 here and mailpit is on 1025, so a fresh checkout would have buried the verification link in a log file at the exact moment the link became mandatory. Verified end to end against Herd and mailpit: register, mail arrives over SMTP, the link in it lands on onboarding. Co-Authored-By: Claude Opus 5 --- .env.example | 4 +- .../DashboardRedirectController.php | 26 ++++++ app/Models/User.php | 4 +- routes/settings.php | 2 +- routes/web.php | 17 +++- tests/Browser/Auth/AuthenticationTest.php | 22 +++++ tests/Feature/Auth/VerifiedAccessTest.php | 80 +++++++++++++++++++ 7 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/DashboardRedirectController.php create mode 100644 tests/Feature/Auth/VerifiedAccessTest.php diff --git a/.env.example b/.env.example index a6ae433..e0b54a9 100644 --- a/.env.example +++ b/.env.example @@ -51,10 +51,10 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=log +MAIL_MAILER=smtp MAIL_SCHEME=null MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 +MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" diff --git a/app/Http/Controllers/DashboardRedirectController.php b/app/Http/Controllers/DashboardRedirectController.php new file mode 100644 index 0000000..fcfefd4 --- /dev/null +++ b/app/Http/Controllers/DashboardRedirectController.php @@ -0,0 +1,26 @@ +redirectPathForCurrentOrganization($request, '/dashboard')); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 2e11bb6..5c6ec4a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,9 +2,9 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Concerns\HasOrganizations; use Database\Factories\UserFactory; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Collection; @@ -36,7 +36,7 @@ */ #[Fillable(['name', 'email', 'password', 'current_organization_id'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] -class User extends Authenticatable implements PasskeyUser +class User extends Authenticatable implements MustVerifyEmail, PasskeyUser { /** @use HasFactory */ use HasFactory, HasOrganizations, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; diff --git a/routes/settings.php b/routes/settings.php index f252aac..afb2c32 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -16,7 +16,7 @@ Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update'); }); -Route::middleware(['auth'])->group(function () { +Route::middleware(['auth', 'verified'])->group(function () { Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::get('settings/security', [SecurityController::class, 'edit']) diff --git a/routes/web.php b/routes/web.php index e793ad1..4368a06 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('home'); Route::get('onboarding', OnboardingController::class) - ->middleware(['auth']) + ->middleware(['auth', 'verified']) ->name('onboarding'); +// Every real dashboard is under an organization. This is the bare path +// Fortify redirects to from config('fortify.home'). +Route::get('dashboard', DashboardRedirectController::class) + ->middleware(['auth', 'verified']) + ->name('dashboard.redirect'); + Route::prefix('{current_organization}') - ->middleware(['auth', EnsureOrganizationMembership::class]) + ->middleware(['auth', 'verified', EnsureOrganizationMembership::class]) ->scopeBindings() ->group(function () { Route::get('dashboard', DashboardController::class)->name('dashboard'); @@ -45,10 +52,12 @@ }); Route::get('invitations', [OrganizationInvitationController::class, 'index']) - ->middleware(['auth']) + ->middleware(['auth', 'verified']) ->name('invitations.index'); -Route::middleware(['auth'])->group(function () { +// Accepting an invitation joins an account to an organization that was +// invited by email, so the address has to be proven before it is used. +Route::middleware(['auth', 'verified'])->group(function () { Route::post('invitations/{invitation}/accept', [OrganizationInvitationController::class, 'accept'])->name('invitations.accept'); Route::delete('invitations/{invitation}', [OrganizationInvitationController::class, 'decline'])->name('invitations.decline'); }); diff --git a/tests/Browser/Auth/AuthenticationTest.php b/tests/Browser/Auth/AuthenticationTest.php index acdd60c..71d3cae 100644 --- a/tests/Browser/Auth/AuthenticationTest.php +++ b/tests/Browser/Auth/AuthenticationTest.php @@ -2,6 +2,7 @@ use App\Models\Organization; use App\Models\User; +use Illuminate\Support\Facades\URL; test('a user signs in through the login form and lands on the dashboard of their organization', function () { $organization = Organization::factory()->create(['name' => 'Toys Online Group']); @@ -34,3 +35,24 @@ $this->assertGuest(); }); + +test('an unverified user is held at the verification prompt until they verify', function () { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user); + + visit(route('dashboard', $user->currentOrganization)) + ->assertPathIs('/email/verify') + ->assertSee('Resend verification email') + ->assertNoJavaScriptErrors(); + + // The link the verification mail carries. + visit(URL::temporarySignedRoute('verification.verify', now()->addHour(), [ + 'id' => $user->id, + 'hash' => sha1($user->email), + ])) + ->assertPathIs("/{$user->currentOrganization->slug}/dashboard") + ->assertNoJavaScriptErrors(); + + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); +}); diff --git a/tests/Feature/Auth/VerifiedAccessTest.php b/tests/Feature/Auth/VerifiedAccessTest.php new file mode 100644 index 0000000..fe0833a --- /dev/null +++ b/tests/Feature/Auth/VerifiedAccessTest.php @@ -0,0 +1,80 @@ +unverified()->create(); + + $this + ->actingAs($user) + ->get($route($user)) + ->assertRedirect(route('verification.notice')); +})->with([ + // The user's own organization, so this is the verification check + // answering and not the membership check behind it. + 'dashboard' => [fn (User $user) => route('dashboard', $user->currentOrganization)], + 'reports' => [fn (User $user) => route('reports.index', $user->currentOrganization)], + 'onboarding' => [fn () => route('onboarding')], + 'invitations' => [fn () => route('invitations.index')], + 'appearance settings' => [fn () => route('appearance.edit')], + 'organization list' => [fn () => route('organizations.index')], + 'bare dashboard path' => [fn () => '/dashboard'], +]); + +test('an unverified user can still reach the profile page to correct their address', function () { + $user = User::factory()->unverified()->create(); + + $this + ->actingAs($user) + ->get(route('profile.edit')) + ->assertOk(); +}); + +test('a verified user reaches their own dashboard', function () { + $user = User::factory()->create(); + + $this + ->actingAs($user) + ->get(route('dashboard', $user->currentOrganization)) + ->assertOk(); +}); + +test('registering sends the verification mail and leaves the account unverified', function () { + Notification::fake(); + + $this->post(route('register.store'), [ + 'name' => 'Rita Hasler', + 'email' => 'rita@example.com', + 'password' => 'password-that-is-long-enough', + 'password_confirmation' => 'password-that-is-long-enough', + ]); + + $user = User::whereEmail('rita@example.com')->sole(); + + expect($user->hasVerifiedEmail())->toBeFalse(); + + Notification::assertSentTo( + $user, + VerifyEmail::class, + ); +}); + +test('the bare dashboard path lands on the current organization', function () { + $user = User::factory()->create(); + + $this + ->actingAs($user) + ->get('/dashboard') + ->assertRedirect("/{$user->currentOrganization->slug}/dashboard"); +}); + +test('the bare dashboard path sends a user with no organization to onboarding', function () { + $user = User::factory()->withoutOrganization()->create(); + + $this + ->actingAs($user) + ->get('/dashboard') + ->assertRedirect(route('onboarding', absolute: false)); +});