From 5b8c28595af0b6fc47c8edea3fbac24f13febafd Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 23:43:20 +0200 Subject: [PATCH] Require a confirmed address before an account can use anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration opened an account that could sign in immediately on an address nobody had proved was theirs — and anybody can type a stranger's into a registration form. Both halves were commented out: the Fortify feature and MustVerifyEmail on the model. `verified` sits on the whole portal group rather than on the pages that spend money. An unconfirmed address is not a billing problem to be caught at checkout; it is an account that may not belong to the person holding it, and the servers, the users and the backups behind it are worth as much to whoever typed the address as to whoever owns it. The mail is a Mailable in this product's design rather than Laravel's notification, which would arrive with the framework's logo, button and footer as the first thing anybody ever receives from CluPilot. It also has to leave through the SYSTEM mailbox like every other account mail, which the notification path does not do on its own. The link is signed and expires, and the address is part of what is signed — so a link issued to a mistyped address stops working the moment the address is corrected, rather than confirming an address nobody can read. Tests cover the tampered link, the expired one, the corrected address, and one account trying to confirm another. The waiting page is ours (Fortify has views off) and offers the only two things that move somebody forward: send it again, or sign out and register with the address they meant. The resend is throttled, because otherwise it is a button that makes the server send mail to an arbitrary address as fast as it can be clicked. Somebody already confirmed is redirected away from it — the `verified` middleware only guards the other direction. Co-Authored-By: Claude Opus 5 --- app/Livewire/Auth/VerifyEmail.php | 88 ++++++++++++ app/Mail/VerifyEmailMail.php | 60 +++++++++ app/Models/User.php | 24 +++- config/fortify.php | 10 +- lang/de/verify_email.php | 26 ++++ lang/en/verify_email.php | 24 ++++ .../livewire/auth/verify-email.blade.php | 23 ++++ resources/views/mail/verify-email.blade.php | 40 ++++++ routes/web.php | 15 ++- tests/Feature/DoubleOptInTest.php | 125 ++++++++++++++++++ 10 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 app/Livewire/Auth/VerifyEmail.php create mode 100644 app/Mail/VerifyEmailMail.php create mode 100644 lang/de/verify_email.php create mode 100644 lang/en/verify_email.php create mode 100644 resources/views/livewire/auth/verify-email.blade.php create mode 100644 resources/views/mail/verify-email.blade.php create mode 100644 tests/Feature/DoubleOptInTest.php diff --git a/app/Livewire/Auth/VerifyEmail.php b/app/Livewire/Auth/VerifyEmail.php new file mode 100644 index 0000000..ad621e4 --- /dev/null +++ b/app/Livewire/Auth/VerifyEmail.php @@ -0,0 +1,88 @@ + false: the app renders its own + * screens (R1/R2) and Fortify keeps only the POST actions. + */ +#[Layout('layouts.portal')] +class VerifyEmail extends Component +{ + /** + * Six a minute is generous for a person and useless for anything else. + * Without it this is a button that makes the server send mail to an + * arbitrary address as fast as it can be clicked. + */ + private const PER_MINUTE = 6; + + /** + * Somebody already through has no step left, and telling them they do is + * how a confirmed account goes looking for a mail it does not need. The + * `verified` middleware only guards the other direction — it keeps the + * unconfirmed out of the portal and has nothing to say about this page. + */ + public function mount() + { + if (Auth::user()?->hasVerifiedEmail()) { + return $this->redirectRoute('dashboard', navigate: false); + } + + return null; + } + + public function resend(): void + { + $user = Auth::user(); + + // Already through — a stale tab, or the link opened in another window. + // Sending another confirmation for an address that no longer needs one + // is how somebody ends up chasing a mail they do not need. + if ($user === null || $user->hasVerifiedEmail()) { + $this->redirectRoute('dashboard', navigate: true); + + return; + } + + $key = 'verify-resend:'.$user->getKey(); + + if (RateLimiter::tooManyAttempts($key, self::PER_MINUTE)) { + $this->dispatch('notify', message: __('verify_email.notice_hint')); + + return; + } + + RateLimiter::hit($key, 60); + $user->sendEmailVerificationNotification(); + + $this->dispatch('notify', message: __('verify_email.notice_sent')); + } + + public function signOut() + { + Auth::guard('web')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + return $this->redirectRoute('login', navigate: false); + } + + public function render() + { + return view('livewire.auth.verify-email', [ + 'email' => Auth::user()?->getEmailForVerification() ?? '', + ]); + } +} diff --git a/app/Mail/VerifyEmailMail.php b/app/Mail/VerifyEmailMail.php new file mode 100644 index 0000000..04fd718 --- /dev/null +++ b/app/Mail/VerifyEmailMail.php @@ -0,0 +1,60 @@ +mailer('cp_'.MailPurpose::SYSTEM); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('verify_email.subject')); + } + + public function content(): Content + { + $minutes = (int) config('auth.verification.expire', 60); + + return new Content(view: 'mail.verify-email', with: [ + 'name' => $this->user->name, + 'minutes' => $minutes, + // Built here rather than stored: a signed URL carries its own + // expiry, so there is no token to keep, none to leak from the + // database, and none to forget to invalidate. + 'url' => URL::temporarySignedRoute('verification.verify', now()->addMinutes($minutes), [ + 'id' => $this->user->getKey(), + // The address is part of what is signed, so a link stops + // working the moment somebody changes the address it was + // issued for. + 'hash' => sha1($this->user->getEmailForVerification()), + ]), + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 72a40f9..a4d99bc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,22 +2,42 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Mail\VerifyEmailMail; 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\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\Mail; use Laravel\Fortify\TwoFactorAuthenticatable; #[Fillable(['name', 'email', 'password'])] #[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])] -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory */ use HasFactory, Notifiable, TwoFactorAuthenticatable; + /** + * The confirmation mail, in this product's design rather than Laravel's. + * + * Overridden instead of going through VerifyEmail::toMailUsing(), which + * hands back a MailMessage — the framework's markdown layout, with its + * own logo, its own button and its own footer. This is the first thing + * anybody ever receives from CluPilot; it should not arrive looking like a + * framework's default. + * + * It also has to go out through the SYSTEM mailbox like every other + * account mail (App\Services\Mail\MailPurpose), which the notification + * path does not do on its own. + */ + public function sendEmailVerificationNotification(): void + { + Mail::to($this->getEmailForVerification())->queue(new VerifyEmailMail($this)); + } + /** * Get the attributes that should be cast. * diff --git a/config/fortify.php b/config/fortify.php index b13ee9c..9e971b0 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -171,7 +171,15 @@ return [ // registration-scoped throttle instead of Fortify's unthrottled route. // Features::registration(), // Features::resetPasswords(), - // Features::emailVerification(), + // Double opt-in. An account whose address was never confirmed is an + // account that cannot be billed, cannot be told its server is down, + // and may not be its owner's address at all — anybody can type + // somebody else's into a registration form. + // + // The notice page is ours (routes/web.php): 'views' => false below, so + // Fortify registers the verify and resend actions and nothing that + // returns HTML. + Features::emailVerification(), // Features::updateProfileInformation(), // Everyone must be able to change their own password. It was off, so // an account created with a generated password was stuck with it — and diff --git a/lang/de/verify_email.php b/lang/de/verify_email.php new file mode 100644 index 0000000..34179b8 --- /dev/null +++ b/lang/de/verify_email.php @@ -0,0 +1,26 @@ + 'Bitte bestätigen Sie Ihre E-Mail-Adresse', + 'heading' => 'Bestätigen Sie Ihre Adresse', + 'preheader' => 'Ein Klick fehlt noch — der Link gilt :minutes Minuten.', + 'greeting' => 'Guten Tag :name,', + 'intro' => 'willkommen bei CluPilot. Ein Klick fehlt noch: Bestätigen Sie, dass diese Adresse Ihnen gehört. Erst danach steht Ihr Zugang bereit, und erst danach schicken wir Rechnungen und Betriebsmeldungen dorthin.', + 'action' => 'E-Mail-Adresse bestätigen', + 'expiry' => 'Der Link gilt :minutes und lässt sich einmal verwenden.', + 'fallback' => 'Falls der Knopf nicht funktioniert, diese Adresse in den Browser kopieren:', + // Ausdrücklich: ohne Bestätigung passiert nichts weiter. Wer hier + // fälschlich eine fremde Adresse eingetragen hat, soll den Empfänger nicht + // zu einer Handlung zwingen. + 'not_you' => 'Sie haben sich nicht angemeldet? Dann ignorieren Sie diese Nachricht. Ohne Bestätigung wird das Konto nicht freigeschaltet, und wir schreiben Ihnen nicht wieder.', + + // Die Seite, auf der jemand landet, der sich angemeldet hat aber noch nicht + // bestätigt ist. + 'notice_title' => 'Noch ein Schritt', + 'notice_body' => 'Wir haben eine Bestätigung an :email geschickt. Öffnen Sie den Link darin, dann geht es weiter.', + 'notice_hint' => 'Nichts angekommen? Sehen Sie im Spam-Ordner nach — oder schicken Sie die Nachricht erneut.', + 'notice_resend' => 'Erneut schicken', + 'notice_sent' => 'Bestätigung erneut verschickt.', + 'notice_signout' => 'Abmelden', + 'notice_wrong_address' => 'Adresse falsch eingetragen? Melden Sie sich ab und registrieren Sie sich erneut.', +]; diff --git a/lang/en/verify_email.php b/lang/en/verify_email.php new file mode 100644 index 0000000..f915f95 --- /dev/null +++ b/lang/en/verify_email.php @@ -0,0 +1,24 @@ + 'Please confirm your email address', + 'heading' => 'Confirm your address', + 'preheader' => 'One click left — the link is good for :minutes minutes.', + 'greeting' => 'Hello :name,', + 'intro' => 'welcome to CluPilot. One click is left: confirm that this address is yours. Your access opens after that, and only then do invoices and operational notices go here.', + 'action' => 'Confirm email address', + 'expiry' => 'The link is good for :minutes and works once.', + 'fallback' => 'If the button does not work, copy this address into your browser:', + // Said plainly: without confirmation nothing further happens. Somebody who + // typed a stranger's address must not force that stranger to act. + 'not_you' => 'Did not sign up? Ignore this message. Without confirmation the account is not opened, and we will not write again.', + + // The page somebody lands on when signed in but not yet confirmed. + 'notice_title' => 'One step left', + 'notice_body' => 'We sent a confirmation to :email. Open the link in it and you are through.', + 'notice_hint' => 'Nothing arrived? Check the spam folder — or send it again.', + 'notice_resend' => 'Send again', + 'notice_sent' => 'Confirmation sent again.', + 'notice_signout' => 'Sign out', + 'notice_wrong_address' => 'Typed the wrong address? Sign out and register again.', +]; diff --git a/resources/views/livewire/auth/verify-email.blade.php b/resources/views/livewire/auth/verify-email.blade.php new file mode 100644 index 0000000..5fee4e4 --- /dev/null +++ b/resources/views/livewire/auth/verify-email.blade.php @@ -0,0 +1,23 @@ +
+
+ + + + +

{{ __('verify_email.notice_title') }}

+

{{ __('verify_email.notice_body', ['email' => $email]) }}

+

{{ __('verify_email.notice_hint') }}

+ +
+ + + {{ __('verify_email.notice_resend') }} + + + {{ __('verify_email.notice_signout') }} + +
+ +

{{ __('verify_email.notice_wrong_address') }}

+
+
diff --git a/resources/views/mail/verify-email.blade.php b/resources/views/mail/verify-email.blade.php new file mode 100644 index 0000000..d0350b1 --- /dev/null +++ b/resources/views/mail/verify-email.blade.php @@ -0,0 +1,40 @@ + + + +

{{ __('verify_email.intro') }}

+ + + + + +
+ {{ __('verify_email.action') }} +
+

{!! __('verify_email.expiry', ['minutes' => ''.$minutes.' Minuten']) !!}

+ + +{{-- The link as text as well. Corporate mail gateways rewrite button links and + some of them fetch the target first to scan it — on a single-use link that + burns the confirmation before the recipient ever sees it. --}} + + + +
+

{{ __('verify_email.fallback') }}

+

{{ $url }}

+
+ + + + + +
+

{{ __('verify_email.not_you') }}

+
+ + +
diff --git a/routes/web.php b/routes/web.php index 298fbfc..8d2bf54 100644 --- a/routes/web.php +++ b/routes/web.php @@ -197,9 +197,22 @@ Route::middleware('guest')->group(function () { Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter']) ->name('impersonate.enter'); +// Signed in, but the address has not been confirmed yet. Fortify would own +// this page, but 'views' => false — it keeps the verify and resend actions and +// the app renders its own screens (R1/R2). +Route::get('/email/verify', \App\Livewire\Auth\VerifyEmail::class) + ->middleware('auth') + ->name('verification.notice'); + // Customer portal — each sidebar tab is a full-page class-based Livewire // component (R1/R2); paths are English (R13). -Route::middleware(['auth', 'customer.active'])->group(function () { +// +// `verified` sits on the whole group rather than on the pages that spend money. +// An unconfirmed address is not a billing problem to be caught at checkout: it +// is an account that may not belong to the person holding it, and everything +// behind here — the servers, the users, the backups — is worth as much to +// somebody who typed a stranger's address as it is to its owner. +Route::middleware(['auth', 'verified', 'customer.active'])->group(function () { Route::get('/dashboard', Dashboard::class)->name('dashboard'); Route::get('/cloud', Cloud::class)->name('cloud'); Route::get('/users', Users::class)->name('users'); diff --git a/tests/Feature/DoubleOptInTest.php b/tests/Feature/DoubleOptInTest.php new file mode 100644 index 0000000..df3c09f --- /dev/null +++ b/tests/Feature/DoubleOptInTest.php @@ -0,0 +1,125 @@ +unverified()->create(); + + $this->actingAs($user) + ->get(route('dashboard')) + ->assertRedirect(route('verification.notice')); +}); + +it('lets a confirmed account through', function () { + $this->actingAs(User::factory()->create()) + ->get(route('dashboard')) + ->assertOk(); +}); + +it('sends the confirmation in this product’s design, not the framework’s', function () { + // Laravel's own notification would arrive with its logo, its button and + // its footer — as the first thing anybody ever receives from CluPilot. + Mail::fake(); + + User::factory()->unverified()->create()->sendEmailVerificationNotification(); + + Mail::assertQueued(VerifyEmailMail::class); +}); + +it('confirms the address when the signed link is opened', function () { + $user = User::factory()->unverified()->create(); + + $this->actingAs($user)->get(verificationUrlFor($user))->assertRedirect(); + + expect($user->refresh()->hasVerifiedEmail())->toBeTrue(); +}); + +it('refuses a link whose signature has been tampered with', function () { + $user = User::factory()->unverified()->create(); + + $tampered = verificationUrlFor($user).'x'; + + $this->actingAs($user)->get($tampered)->assertForbidden(); + + expect($user->refresh()->hasVerifiedEmail())->toBeFalse(); +}); + +it('refuses a link that has expired', function () { + $user = User::factory()->unverified()->create(); + $url = verificationUrlFor($user, 60); + + $this->travel(61)->minutes(); + + $this->actingAs($user)->get($url)->assertForbidden(); + + expect($user->refresh()->hasVerifiedEmail())->toBeFalse(); +}); + +it('stops working once the address it was issued for has changed', function () { + // The address is part of what is signed. Without that, a link mailed to an + // address somebody has since corrected would still confirm the account — + // confirming an address nobody can read. + $user = User::factory()->unverified()->create(['email' => 'typo@example.com']); + $url = verificationUrlFor($user); + + $user->update(['email' => 'correct@example.com']); + + $this->actingAs($user)->get($url)->assertForbidden(); + + expect($user->refresh()->hasVerifiedEmail())->toBeFalse(); +}); + +it('will not let one account confirm another', function () { + $mine = User::factory()->unverified()->create(); + $theirs = User::factory()->unverified()->create(); + + $this->actingAs($mine)->get(verificationUrlFor($theirs))->assertForbidden(); + + expect($theirs->refresh()->hasVerifiedEmail())->toBeFalse(); +}); + +it('throttles the resend button', function () { + // Without it this is a button that makes the server send mail to an + // arbitrary address as fast as it can be clicked. + Mail::fake(); + + $user = User::factory()->unverified()->create(); + + $component = Livewire\Livewire::actingAs($user)->test(App\Livewire\Auth\VerifyEmail::class); + + foreach (range(1, 6) as $ignored) { + $component->call('resend'); + } + + Mail::assertQueuedCount(6); + + $component->call('resend'); + + Mail::assertQueuedCount(6); +}); + +it('sends nobody back to the waiting room once they are through', function () { + $this->actingAs(User::factory()->create()) + ->get(route('verification.notice')) + ->assertRedirect(route('dashboard')); +}); + +/** The signed link the mail carries, built the same way VerifyEmailMail builds it. */ +function verificationUrlFor(User $user, int $minutes = 60): string +{ + return URL::temporarySignedRoute('verification.verify', now()->addMinutes($minutes), [ + 'id' => $user->getKey(), + 'hash' => sha1($user->getEmailForVerification()), + ]); +}