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()), + ]); +}