Require a confirmed address before an account can use anything
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 <noreply@anthropic.com>feat/granted-plans
parent
07141a96bf
commit
5b8c28595a
|
|
@ -0,0 +1,88 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where somebody waits between registering and confirming.
|
||||||
|
*
|
||||||
|
* Signed in but not through: the account exists, and nothing in the portal
|
||||||
|
* opens until the address is confirmed. The page therefore has to offer the
|
||||||
|
* only two things that can move somebody forward — send it again, or sign out
|
||||||
|
* and register with the address they meant.
|
||||||
|
*
|
||||||
|
* Fortify would own this page, but 'views' => 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() ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Mail\Concerns\SendsFromMailbox;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Mail\MailPurpose;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Confirm that this address is yours."
|
||||||
|
*
|
||||||
|
* The first thing anybody ever receives from CluPilot, which is why it is a
|
||||||
|
* Mailable in this product's design rather than the framework's default
|
||||||
|
* MailMessage with its own logo, button and footer.
|
||||||
|
*
|
||||||
|
* The link is signed and expires. Both matter: unsigned, anybody who can guess
|
||||||
|
* a user id could verify somebody else's address; unexpiring, a forwarded mail
|
||||||
|
* from last spring still works.
|
||||||
|
*/
|
||||||
|
class VerifyEmailMail extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SendsFromMailbox, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public User $user)
|
||||||
|
{
|
||||||
|
$this->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()),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,22 +2,42 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Mail\VerifyEmailMail;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password'])]
|
#[Fillable(['name', 'email', 'password'])]
|
||||||
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
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.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,15 @@ return [
|
||||||
// registration-scoped throttle instead of Fortify's unthrottled route.
|
// registration-scoped throttle instead of Fortify's unthrottled route.
|
||||||
// Features::registration(),
|
// Features::registration(),
|
||||||
// Features::resetPasswords(),
|
// 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(),
|
// Features::updateProfileInformation(),
|
||||||
// Everyone must be able to change their own password. It was off, so
|
// 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
|
// an account created with a generated password was stuck with it — and
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<div class="mx-auto max-w-md py-16">
|
||||||
|
<div class="rounded-lg border border-line bg-surface p-8 shadow-xs animate-rise">
|
||||||
|
<span class="grid size-12 place-items-center rounded-lg bg-accent-subtle text-accent-text">
|
||||||
|
<x-ui.icon name="mail" class="size-6" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 class="mt-5 text-2xl font-bold tracking-tight text-ink">{{ __('verify_email.notice_title') }}</h1>
|
||||||
|
<p class="mt-2 text-sm text-body">{{ __('verify_email.notice_body', ['email' => $email]) }}</p>
|
||||||
|
<p class="mt-3 text-sm text-muted">{{ __('verify_email.notice_hint') }}</p>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap gap-3">
|
||||||
|
<x-ui.button variant="primary" wire:click="resend" wire:loading.attr="disabled" wire:target="resend">
|
||||||
|
<x-ui.icon name="refresh" class="size-4" />
|
||||||
|
{{ __('verify_email.notice_resend') }}
|
||||||
|
</x-ui.button>
|
||||||
|
<x-ui.button variant="secondary" wire:click="signOut" wire:loading.attr="disabled" wire:target="signOut">
|
||||||
|
{{ __('verify_email.notice_signout') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-5 border-t border-line pt-4 text-xs text-muted">{{ __('verify_email.notice_wrong_address') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<x-mail.layout
|
||||||
|
:heading="__('verify_email.heading')"
|
||||||
|
:preheader="__('verify_email.preheader', ['minutes' => $minutes])"
|
||||||
|
:greeting="$name ? __('verify_email.greeting', ['name' => $name]) : null"
|
||||||
|
>
|
||||||
|
|
||||||
|
<tr><td style="padding:0 40px 24px 40px;">
|
||||||
|
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('verify_email.intro') }}</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<tr><td style="padding:0 40px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||||
|
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||||
|
<a href="{{ $url }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('verify_email.action') }}</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:14px 0 0 0;font-size:13px;line-height:20px;color:#6e6e7a;">{!! __('verify_email.expiry', ['minutes' => '<strong style="color:#43434e;font-weight:600;">'.$minutes.' Minuten</strong>']) !!}</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
{{-- 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. --}}
|
||||||
|
<tr><td style="padding:28px 40px 0 40px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||||
|
<tr><td style="padding:14px 16px;">
|
||||||
|
<p style="margin:0 0 6px 0;font-size:12px;line-height:16px;color:#6e6e7a;">{{ __('verify_email.fallback') }}</p>
|
||||||
|
<p style="margin:0;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:18px;color:#b8500a;word-break:break-all;">{{ $url }}</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<tr><td style="padding:24px 40px 32px 40px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||||
|
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||||
|
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('verify_email.not_you') }}</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
</x-mail.layout>
|
||||||
|
|
@ -197,9 +197,22 @@ Route::middleware('guest')->group(function () {
|
||||||
Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter'])
|
Route::get('/impersonate/enter/{customer}/{operator}', [ImpersonationController::class, 'enter'])
|
||||||
->name('impersonate.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
|
// Customer portal — each sidebar tab is a full-page class-based Livewire
|
||||||
// component (R1/R2); paths are English (R13).
|
// 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('/dashboard', Dashboard::class)->name('dashboard');
|
||||||
Route::get('/cloud', Cloud::class)->name('cloud');
|
Route::get('/cloud', Cloud::class)->name('cloud');
|
||||||
Route::get('/users', Users::class)->name('users');
|
Route::get('/users', Users::class)->name('users');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Mail\VerifyEmailMail;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nobody gets into the portal on an address they have not proved is theirs.
|
||||||
|
*
|
||||||
|
* Anybody can type a stranger's address into a registration form. Until it is
|
||||||
|
* confirmed, an account is not known to belong to the person holding it — and
|
||||||
|
* everything behind the portal, the servers and the backups and the invoices,
|
||||||
|
* is worth as much to the person who typed it as to the person who owns it.
|
||||||
|
*/
|
||||||
|
it('does not let an unconfirmed account into the portal', function () {
|
||||||
|
$user = User::factory()->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()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue