Give people a way back in, and put the URL in English
tests / pest (push) Failing after 7m54s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

── The hole ────────────────────────────────────────────────────────────────
There was no password reset. Fortify's feature was commented out, so there
was no link on the sign-in form, no page and no route — a customer who forgot
their password was locked out of their own cloud until somebody opened a
shell. On a product whose selling point is that you can ring somebody, that
is a support call a week and an embarrassing one.

Two pages of ours under Fortify's route names (R1/R2), because with views off
Fortify registers only the POST endpoints. The mail is a Mailable in this
product's design rather than the framework's MailMessage: somebody who has
just been locked out is exactly the person a phishing mail is aimed at, and a
message that looks nothing like the rest of our post is one they cannot check
— ours carries the footer that names our domains.

Three decisions worth stating, each with a test:

The answer is identical whether the address is known or not. "No account with
that address" turns the form into a way of finding out who is a customer.

The reset kills every other session. Whoever knew the old password may still
be signed in somewhere, and a reset that leaves them there has fixed nothing.

It does NOT sign the visitor in. The link arrived by email, and a mailbox
somebody else can read would otherwise be a session somebody else gets.

── /sicherheit → /security ─────────────────────────────────────────────────
R13: paths are English. Mine was not. The old path stays as a permanent
redirect because it has already gone out in mail footers.

── One wordmark ────────────────────────────────────────────────────────────
"Sometimes it says Cloud and sometimes it does not" — correct. It was on the
footer, the placeholder and the maintenance screen, and absent from the
header, the sidebar and the sign-in plate. The product is called CluPilot
Cloud; that is the name on the invoices and in every mail, so it is now the
name everywhere. The console sidebar takes the small size so the lockup, the
ADMIN badge and the close button still share one line (R18).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus v1.3.19
nexxo 2026-07-29 16:50:29 +02:00
parent 712803edd6
commit 3a4324fb6f
20 changed files with 551 additions and 9 deletions

View File

@ -1 +1 @@
1.3.18
1.3.19

View File

@ -0,0 +1,47 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Support\Facades\Password;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* "I cannot get in any more."
*
* There was no way out of that at all: Fortify's password-reset feature was
* switched off, so there was no link, no page and no route. A customer who
* forgot their password was locked out of their own cloud until somebody
* opened a shell on a product whose selling point is that you can ring
* somebody, that is a support call a week and an embarrassing one.
*
* The answer is deliberately identical whether the address is known or not.
* "No account with that address" turns this form into a way of finding out who
* is a customer, which is the first thing anybody targeting a business does.
*/
#[Layout('layouts.portal')]
class ForgotPassword extends Component
{
#[Validate('required|email|max:255')]
public string $email = '';
public bool $sent = false;
public function send(): void
{
$this->validate();
// Throttled by Laravel's broker (once a minute per address). The
// response does not say which of the two happened: a slower answer for
// a known address would leak the same thing the message would.
Password::broker()->sendResetLink(['email' => $this->email]);
$this->sent = true;
}
public function render()
{
return view('livewire.auth.forgot-password');
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Livewire\Auth;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The other end of the link.
*
* The token is checked by Laravel's broker, not here: it holds the hash, the
* expiry and the single-use deletion, and re-implementing any of that is how a
* reset link ends up working twice.
*
* The user is NOT signed in afterwards. A reset link travels by email, and a
* mailbox somebody else can read would otherwise be a session somebody else
* gets. They type the new password once more on the sign-in page which also
* proves the reset did what they think it did.
*/
#[Layout('layouts.portal')]
class ResetPassword extends Component
{
public string $token = '';
#[Validate('required|email|max:255')]
public string $email = '';
#[Validate('required|string|min:12|confirmed')]
public string $password = '';
public string $password_confirmation = '';
public bool $done = false;
public function mount(string $token): void
{
$this->token = $token;
$this->email = (string) request()->query('email', '');
}
/**
* NOT called reset(): Livewire\Component::reset() clears properties, and
* a component that redeclares it will not load at all.
*/
public function save(): void
{
$this->validate();
$status = Password::broker()->reset(
[
'email' => $this->email,
'password' => $this->password,
'password_confirmation' => $this->password_confirmation,
'token' => $this->token,
],
function (User $user, string $password) {
$user->forceFill([
'password' => Hash::make($password),
// Every existing session dies with it. Whoever knew the old
// password may still be signed in somewhere, and a reset
// that leaves them there has fixed nothing.
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
},
);
if ($status !== Password::PASSWORD_RESET) {
$this->addError('email', __($status));
return;
}
$this->done = true;
}
public function render()
{
return view('livewire.auth.reset-password');
}
}

View File

@ -0,0 +1,47 @@
<?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;
/**
* "Set a new password."
*
* In this product's design rather than the framework's default MailMessage,
* for the same reason as the verification mail: a customer who has just been
* locked out is exactly the person a phishing mail is aimed at, and a message
* that looks nothing like the rest of our post is one they cannot check.
*
* It therefore carries the standard footer, which names our domains.
*/
class ResetPasswordMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
public function __construct(public User $user, public string $url, public int $minutes)
{
$this->mailer('cp_'.MailPurpose::SYSTEM);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('reset_password.subject'));
}
public function content(): Content
{
return new Content(view: 'mail.reset-password', with: [
'name' => $this->user->name,
'url' => $this->url,
'minutes' => $this->minutes,
]);
}
}

View File

@ -20,6 +20,23 @@ class User extends Authenticatable implements MustVerifyEmail
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable;
/**
* The reset mail in this product's design, not the framework's.
*
* Somebody who has just been locked out is exactly the person a phishing
* mail is aimed at. A message that looks nothing like the rest of our post
* is one they cannot check and ours carries the footer naming our
* domains, which is the whole point.
*/
public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void
{
\Illuminate\Support\Facades\Mail::to($this->email)->send(new \App\Mail\ResetPasswordMail(
$this,
route('password.reset', ['token' => $token, 'email' => $this->email]),
(int) config('auth.passwords.users.expire', 60),
));
}
/**
* The confirmation mail, in this product's design rather than Laravel's.
*

View File

@ -170,7 +170,12 @@ return [
// Registration is exposed via an app route (routes/web.php) with a
// registration-scoped throttle instead of Fortify's unthrottled route.
// Features::registration(),
// Features::resetPasswords(),
// Was off, which meant there was no forgot-password link, no page
// and no route: a customer who forgot their password was locked out
// of their own cloud until somebody opened a shell. The POST
// endpoints come from Fortify; the two GET pages are ours (R1/R2),
// registered in routes/web.php under Fortify's own route names.
Features::resetPasswords(),
// 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

View File

@ -62,4 +62,20 @@ return [
'phishing_note' => 'Sie sind gerade auf :host. Wir fragen Sie nie per E-Mail oder Telefon nach Ihrem Passwort.',
'phishing_link' => 'Echte Adressen erkennen',
'forgot_link' => "Passwort vergessen?",
'forgot_title' => "Passwort vergessen",
'forgot_subtitle' => "Wir schicken Ihnen einen Link, mit dem Sie ein neues vergeben.",
'forgot_send' => "Link schicken",
'forgot_sent_title' => "Schauen Sie in Ihr Postfach",
'forgot_sent_body' => "Wenn es bei uns ein Konto zu :email gibt, ist der Link unterwegs. Er gilt eine Stunde und lässt sich einmal verwenden.",
'forgot_sent_hint' => "Nichts angekommen? Sehen Sie im Spam-Ordner nach. Wir sagen aus Sicherheitsgründen nicht, ob es zu dieser Adresse ein Konto gibt.",
'back_to_login' => "Zurück zur Anmeldung",
'reset_title' => "Neues Passwort vergeben",
'reset_subtitle' => "Danach melden Sie sich damit an.",
'reset_new' => "Neues Passwort",
'reset_repeat' => "Neues Passwort wiederholen",
'reset_save' => "Passwort speichern",
'reset_done_title' => "Passwort geändert",
'reset_done_body' => "Alle anderen offenen Anmeldungen wurden beendet. Melden Sie sich jetzt mit dem neuen Passwort an — absichtlich nicht automatisch: der Link kam per E-Mail, und wer Ihr Postfach lesen kann, bekäme sonst gleich die Sitzung dazu.",
];

View File

@ -0,0 +1,13 @@
<?php
return [
'subject' => 'Neues Passwort für Ihren CluPilot-Zugang',
'preheader' => 'Der Link gilt :minutes Minuten.',
'heading' => 'Neues Passwort setzen',
'greeting' => 'Guten Tag :name,',
'intro' => 'Sie haben ein neues Passwort für Ihren CluPilot-Zugang angefordert. Über den Knopf unten vergeben Sie es.',
'action' => 'Neues Passwort setzen',
'expiry' => 'Der Link gilt :minutes und lässt sich nur einmal verwenden.',
'fallback' => 'Falls der Knopf nicht funktioniert, kopieren Sie diese Adresse in Ihren Browser:',
'not_you' => 'Sie haben das nicht angefordert? Dann ignorieren Sie diese Nachricht — ohne den Link ändert sich nichts an Ihrem Zugang. Wir fragen Sie nie per E-Mail nach Ihrem Passwort.',
];

View File

@ -62,4 +62,20 @@ return [
'phishing_note' => 'You are currently on :host. We never ask for your password by email or on the phone.',
'phishing_link' => 'How to recognise our addresses',
'forgot_link' => "Forgotten your password?",
'forgot_title' => "Forgotten password",
'forgot_subtitle' => "We will send you a link to set a new one.",
'forgot_send' => "Send the link",
'forgot_sent_title' => "Check your inbox",
'forgot_sent_body' => "If we have an account for :email, the link is on its way. It is valid for an hour and can be used once.",
'forgot_sent_hint' => "Nothing arrived? Check your spam folder. For security we do not say whether an account exists for that address.",
'back_to_login' => "Back to sign-in",
'reset_title' => "Set a new password",
'reset_subtitle' => "Then sign in with it.",
'reset_new' => "New password",
'reset_repeat' => "Repeat the new password",
'reset_save' => "Save the password",
'reset_done_title' => "Password changed",
'reset_done_body' => "Every other open session has been ended. Sign in now with the new password — deliberately not automatically: the link arrived by email, and whoever can read your mailbox would otherwise get the session with it.",
];

View File

@ -0,0 +1,13 @@
<?php
return [
'subject' => 'A new password for your CluPilot account',
'preheader' => 'The link is valid for :minutes minutes.',
'heading' => 'Set a new password',
'greeting' => 'Hello :name,',
'intro' => 'You asked for a new password for your CluPilot account. Use the button below to set one.',
'action' => 'Set a new password',
'expiry' => 'The link is valid for :minutes and can be used once.',
'fallback' => 'If the button does not work, copy this address into your browser:',
'not_you' => 'You did not ask for this? Then ignore this message — without the link nothing about your account changes. We never ask for your password by email.',
];

View File

@ -73,7 +73,7 @@
<div class="mx-auto max-w-[1120px] px-5 py-14 sm:px-6">
<div class="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
<div class="lg:col-span-2">
<x-ui.brand size="md" suffix />
<x-ui.brand size="md" />
<p class="mt-4 max-w-[38ch] text-sm leading-relaxed text-muted">
Eine eigene, isolierte Cloud für Ihr Unternehmen eingerichtet, gesichert und
überwacht von uns, mit Serverstandort in der EU.

View File

@ -22,7 +22,7 @@
>
<div class="flex shrink-0 items-center justify-between gap-2 px-2.5 pb-5">
<a href="{{ $console ? \App\Support\AdminArea::home() : route('dashboard') }}" class="flex items-center gap-2.5">
<x-ui.brand size="md" />
<x-ui.brand :size="$console ? 'sm' : 'md'" />
@if ($console)
<span class="rounded-sm bg-accent-subtle px-1.5 py-0.5 font-mono text-[9.5px] font-semibold uppercase tracking-[0.1em] text-accent-text">{{ __('admin.badge') }}</span>
@endif

View File

@ -1,6 +1,5 @@
@props([
'size' => 'md', // sm | md | lg
'suffix' => false, // append "Cloud" in the muted tone
'tone' => 'ink', // ink | white — for the dark sign-in plate
])
{{--
@ -16,6 +15,12 @@
of them, and because a two-tone wordmark makes the accent a permanent
fixture rather than something used sparingly.
"Cloud" is no longer optional either. It was on the footer, the placeholder
and the maintenance screen and absent from the header, the sidebar and the
sign-in plate "sometimes it says Cloud and sometimes it does not", which
is exactly right. The product is called CluPilot Cloud; that is the name on
the invoices and in every mail, so it is the name everywhere.
The mark and the word are the only two children of the flex row, and the
word is a single element. `gap` applies between EVERY child of a flex
container and a bare text node is a child written the other way this
@ -33,5 +38,5 @@
view with a parse error. The newline also supplies the space before
"Cloud". --}}
<span class="whitespace-nowrap {{ $words[$size] ?? $words['md'] }} font-bold tracking-[-0.025em] {{ $tone === 'white' ? 'text-white' : 'text-ink' }}">CluPilot
@if ($suffix)<span class="font-semibold {{ $tone === 'white' ? 'text-white/70' : 'text-muted' }}">Cloud</span>@endif</span>
<span class="font-semibold {{ $tone === 'white' ? 'text-white/70' : 'text-muted' }}">Cloud</span></span>
</span>

View File

@ -0,0 +1,44 @@
<div class="flex min-h-screen bg-bg">
<x-auth.brand
:headline="__('auth.brand_headline')"
:sub="__('auth.brand_sub')"
:facts="[
__('auth.fact_location_term') => __('auth.fact_location'),
__('auth.fact_backup_term') => __('auth.fact_backup'),
__('auth.fact_restore_term') => __('auth.fact_restore'),
__('auth.fact_support_term') => __('auth.fact_support'),
]" />
<main class="flex flex-1 items-center justify-center px-6 py-12">
<div class="w-full max-w-sm animate-rise">
<x-ui.brand size="md" class="mb-9 lg:hidden" />
@if ($sent)
{{-- The same page whether the address is known or not. Saying
"no account with that address" would turn this form into a
way of finding out who is a customer. --}}
<div class="grid size-11 place-items-center rounded-lg bg-accent-subtle">
<x-ui.icon name="mail" class="size-5 text-accent-text" />
</div>
<h1 class="mt-5 text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.forgot_sent_title') }}</h1>
<p class="mt-3 text-sm leading-relaxed text-muted">{{ __('auth.forgot_sent_body', ['email' => $email]) }}</p>
<p class="mt-4 text-sm leading-relaxed text-muted">{{ __('auth.forgot_sent_hint') }}</p>
<x-ui.button href="{{ route('login') }}" variant="secondary" class="mt-8 w-full">{{ __('auth.back_to_login') }}</x-ui.button>
@else
<h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.forgot_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('auth.forgot_subtitle') }}</p>
<form wire:submit="send" class="mt-8 space-y-5">
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="email" autofocus wire:model="email" />
<x-ui.button type="submit" variant="primary" size="md" class="w-full"
wire:loading.attr="disabled" wire:target="send">{{ __('auth.forgot_send') }}</x-ui.button>
</form>
<p class="mt-6 text-sm text-muted">
<a href="{{ route('login') }}" class="font-medium text-accent-text underline-offset-4 hover:underline">{{ __('auth.back_to_login') }}</a>
</p>
@endif
</div>
</main>
</div>

View File

@ -25,7 +25,10 @@
@csrf
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="email" autofocus required value="{{ old('email') }}" />
<x-ui.input name="password" type="password" :label="__('auth.password_label')" autocomplete="current-password" required />
<div class="flex flex-wrap items-center justify-between gap-2">
<x-ui.checkbox name="remember" :label="__('auth.remember')" value="1" />
<a href="{{ route('password.request') }}" class="text-sm text-muted underline-offset-4 hover:text-accent-text hover:underline">{{ __('auth.forgot_link') }}</a>
</div>
<x-ui.button type="submit" variant="primary" size="md" class="w-full">{{ __('auth.sign_in') }}</x-ui.button>
</form>

View File

@ -0,0 +1,41 @@
<div class="flex min-h-screen bg-bg">
<x-auth.brand
:headline="__('auth.brand_headline')"
:sub="__('auth.brand_sub')"
:facts="[
__('auth.fact_location_term') => __('auth.fact_location'),
__('auth.fact_backup_term') => __('auth.fact_backup'),
__('auth.fact_restore_term') => __('auth.fact_restore'),
__('auth.fact_support_term') => __('auth.fact_support'),
]" />
<main class="flex flex-1 items-center justify-center px-6 py-12">
<div class="w-full max-w-sm animate-rise">
<x-ui.brand size="md" class="mb-9 lg:hidden" />
@if ($done)
<div class="grid size-11 place-items-center rounded-lg bg-success-bg">
<x-ui.icon name="check" class="size-5 text-success" />
</div>
<h1 class="mt-5 text-2xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.reset_done_title') }}</h1>
{{-- Not signed in automatically: a reset link travels by email,
and a mailbox somebody else can read would otherwise be a
session somebody else gets. --}}
<p class="mt-3 text-sm leading-relaxed text-muted">{{ __('auth.reset_done_body') }}</p>
<x-ui.button href="{{ route('login') }}" variant="primary" class="mt-8 w-full">{{ __('auth.sign_in') }}</x-ui.button>
@else
<h1 class="text-3xl font-bold leading-tight tracking-tight text-ink">{{ __('auth.reset_title') }}</h1>
<p class="mt-2 text-sm text-muted">{{ __('auth.reset_subtitle') }}</p>
<form wire:submit="save" class="mt-8 space-y-5">
<x-ui.input name="email" type="email" :label="__('auth.email')" autocomplete="username" wire:model="email" />
<x-ui.input name="password" type="password" :label="__('auth.reset_new')" autocomplete="new-password" wire:model="password" />
<x-ui.input name="password_confirmation" type="password" :label="__('auth.reset_repeat')" autocomplete="new-password" wire:model="password_confirmation" />
<x-ui.button type="submit" variant="primary" size="md" class="w-full"
wire:loading.attr="disabled" wire:target="save">{{ __('auth.reset_save') }}</x-ui.button>
</form>
@endif
</div>
</main>
</div>

View File

@ -0,0 +1,39 @@
<x-mail.layout
:heading="__('reset_password.heading')"
:preheader="__('reset_password.preheader', ['minutes' => $minutes])"
:greeting="$name ? __('reset_password.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 40px 24px 40px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('reset_password.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;">{{ __('reset_password.action') }}</a>
</td></tr>
</table>
<p style="margin:14px 0 0 0;font-size:13px;line-height:20px;color:#6e6e7a;">{!! __('reset_password.expiry', ['minutes' => '<strong style="color:#43434e;font-weight:600;">'.$minutes.' Minuten</strong>']) !!}</p>
</td></tr>
{{-- The link as text as well, for the same reason as on the verification mail:
corporate mail gateways rewrite button links and some of them fetch the
target first to scan it, which on a single-use link spends it before the
recipient ever clicks. --}}
<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;">{{ __('reset_password.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>
{{-- The sentence that matters most on this particular mail. Somebody who did
NOT ask for it has just learned that a stranger knows their address, and
the right advice is "do nothing" not "contact us immediately", which is
what a phishing copy would say. --}}
<tr><td style="padding:24px 40px 0 40px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('reset_password.not_you') }}</p>
</td></tr>

View File

@ -183,7 +183,12 @@ $publicSite = function () {
// Reachable WITHOUT an account, deliberately: somebody who has just typed
// their password into a copy of our sign-in form is not signed in anywhere,
// and the page they need cannot be behind the thing they lost.
Route::get('/sicherheit', fn () => view('security'))->name('security');
Route::get('/security', fn () => view('security'))->name('security');
// The German path this shipped under for two releases. R13 says paths are
// English and this one was not — mine. Kept as a permanent redirect
// because it has already gone out in mail footers.
Route::get('/sicherheit', fn () => redirect()->route('security', status: 301));
Route::get('/robots.txt', function () {
$body = App\Support\Settings::bool('site.public', true)
@ -220,6 +225,13 @@ $portal = function () {
Route::post('/register', [\Laravel\Fortify\Http\Controllers\RegisteredUserController::class, 'store'])
->middleware('throttle:registration')
->name('register.store');
// Fortify registers the two POST endpoints; with views off it
// registers no GET routes at all, so the pages are ours under its
// names — every framework redirect and the reset mail resolve
// `password.request` and `password.reset`.
Route::get('/forgot-password', \App\Livewire\Auth\ForgotPassword::class)->name('password.request');
Route::get('/reset-password/{token}', \App\Livewire\Auth\ResetPassword::class)->name('password.reset');
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
});

View File

@ -0,0 +1,131 @@
<?php
use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\ResetPassword;
use App\Mail\ResetPasswordMail;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire;
/**
* Getting back in after forgetting the password.
*
* There was no way to. Fortify's resetPasswords feature was switched off, so
* there was no link on the sign-in form, no page and no route a customer who
* forgot their password was locked out of their own cloud until somebody
* opened a shell. On a product whose selling point is that you can ring
* somebody, that is a support call a week and an embarrassing one.
*/
it('offers the way back on the sign-in form', function () {
$this->get(route('login'))
->assertOk()
->assertSee(__('auth.forgot_link'))
->assertSee(route('password.request'), false);
});
it('sends a link in this products own design', function () {
Mail::fake();
$user = User::factory()->create(['email' => 'kunde@example.test']);
Livewire::test(ForgotPassword::class)
->set('email', $user->email)
->call('send')
->assertHasNoErrors();
// Not the framework's default MailMessage: somebody who has just been
// locked out is exactly the person a phishing mail is aimed at, and a
// message that looks nothing like the rest of our post is one they cannot
// check against the others.
Mail::assertQueued(ResetPasswordMail::class, fn ($mail) => $mail->hasTo($user->email));
});
it('answers the same whether the address is known or not', function () {
// Otherwise the form is a way of finding out who is a customer, which is
// the first thing anybody targeting a business does.
Mail::fake();
$known = Livewire::test(ForgotPassword::class)->set('email', User::factory()->create()->email)->call('send');
$unknown = Livewire::test(ForgotPassword::class)->set('email', 'niemand@example.test')->call('send');
$known->assertSet('sent', true)->assertHasNoErrors();
$unknown->assertSet('sent', true)->assertHasNoErrors();
Mail::assertQueuedCount(1);
});
it('sets the new password and kills every other session', function () {
$user = User::factory()->create(['password' => Hash::make('das-alte-passwort')]);
$before = $user->remember_token;
$token = Password::broker()->createToken($user);
Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasNoErrors()
->assertSet('done', true);
$user->refresh();
expect(Hash::check('ein-langes-neues-passwort', $user->password))->toBeTrue()
// Whoever knew the old password may still be signed in somewhere. A
// reset that leaves them there has fixed nothing.
->and($user->remember_token)->not->toBe($before);
});
it('does not sign the visitor in', function () {
// The link arrived by email. A mailbox somebody else can read would
// otherwise be a session somebody else gets.
$user = User::factory()->create();
$token = Password::broker()->createToken($user);
Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save');
expect(auth()->check())->toBeFalse();
});
it('refuses a token that is not this users', function () {
$mine = User::factory()->create();
$theirs = User::factory()->create();
Livewire::test(ResetPassword::class, ['token' => Password::broker()->createToken($theirs)])
->set('email', $mine->email)
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasErrors('email')
->assertSet('done', false);
});
it('spends the token, so a forwarded link cannot be used twice', function () {
$user = User::factory()->create();
$token = Password::broker()->createToken($user);
$reset = fn () => Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'ein-langes-neues-passwort')
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save');
$reset()->assertSet('done', true);
$reset()->assertSet('done', false)->assertHasErrors('email');
});
it('insists on a password long enough to be worth resetting to', function () {
$user = User::factory()->create();
Livewire::test(ResetPassword::class, ['token' => Password::broker()->createToken($user)])
->set('email', $user->email)
->set('password', 'kurz')
->set('password_confirmation', 'kurz')
->call('save')
->assertHasErrors('password');
});

View File

@ -56,11 +56,16 @@ it('serves the page to somebody who is not signed in', function () {
// Somebody who has just given their password to a copy of our sign-in form
// is signed in nowhere. The page they need cannot be behind the thing they
// lost.
$this->get('/sicherheit')
// The path is English (R13). /sicherheit shipped for two releases and
// still redirects, which is asserted below.
$this->get('/security')
->assertOk()
->assertSee('clupilot.com')
->assertSee('clupilot.cloud')
->assertSee(__('security.compromised_title'));
// The German path went out in mail footers before the rule was applied.
$this->get('/sicherheit')->assertRedirect('/security');
});
it('carries the domains in every mail footer', function () {