Passwortrichtlinie einstellbar, doppelte Anmelde-Mail behoben

main
nexxo 2026-07-31 20:53:19 +02:00
parent 19ed6461cf
commit 0944de7cfa
11 changed files with 322 additions and 16 deletions

View File

@ -2,8 +2,8 @@
namespace App\Actions\Fortify;
use App\Support\PasswordPolicy;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
@ -14,6 +14,6 @@ trait PasswordValidationRules
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
return [...PasswordPolicy::rules(), 'confirmed'];
}
}

View File

@ -4,10 +4,10 @@ namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\Operator;
use App\Support\PasswordPolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Spatie\Permission\PermissionRegistrar;
/**
@ -43,7 +43,7 @@ class CreateAdmin extends Command
[
'email' => 'required|email|max:255',
'name' => 'required|string|max:255',
'password' => ['required', Password::min(12)],
'password' => PasswordPolicy::rules(),
],
);

View File

@ -11,6 +11,7 @@ use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use App\Support\PasswordPolicy;
use App\Support\Settings as AppSettings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@ -57,6 +58,16 @@ class Settings extends Component
*/
public const TABS = ['installation', 'account', 'two-factor', 'team'];
/**
* Die kürzeste zulässige Passwortlänge dieser Installation.
*
* Gilt für Kunden wie für Mitarbeiter deshalb auf dem Sicherheits-Reiter
* und nicht bei „Mein Konto". Siehe App\Support\PasswordPolicy: bis zum
* 31.7.2026 stand die Zahl an drei Stellen im Code, mit drei
* verschiedenen Antworten, und war nirgends einstellbar.
*/
public int|string $passwordMinLength = PasswordPolicy::DEFAULT_LENGTH;
/**
* Which one is open, taken from and written back to the URL.
*
@ -105,8 +116,28 @@ class Settings extends Component
public ?string $invitedPassword = null;
public function savePasswordPolicy(): void
{
$this->authorize('settings.manage');
$this->validate(['passwordMinLength' => [
'required', 'integer',
'min:'.PasswordPolicy::FLOOR, 'max:'.PasswordPolicy::CEILING,
]]);
PasswordPolicy::set((int) $this->passwordMinLength);
// Zurückgelesen statt geglaubt: set() klemmt, und das Feld muss zeigen,
// was wirklich gilt — nicht, was eingetippt wurde.
$this->passwordMinLength = PasswordPolicy::minLength();
$this->dispatch('notify', message: __('admin_settings.password_policy_saved'));
}
public function mount(): void
{
$this->passwordMinLength = PasswordPolicy::minLength();
// A tab name out of the URL is a string a stranger typed. Anything
// unknown falls back to the first tab rather than rendering a settings
// page with no section on it at all. Before the early return, because

View File

@ -3,6 +3,7 @@
namespace App\Livewire\Auth;
use App\Models\User;
use App\Support\PasswordPolicy;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
@ -31,13 +32,26 @@ class ResetPassword extends Component
#[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;
/**
* Die Länge kommt aus PasswordPolicy, nicht aus einem Attribut.
*
* Ein `#[Validate]`-Attribut ist ein fester String und kann eine
* Einstellung nicht lesen genau deshalb stand hier `min:12`, während der
* Rest der Anwendung acht verlangte.
*
* @return array<string, mixed>
*/
protected function rules(): array
{
return ['password' => [...PasswordPolicy::rules(), 'confirmed']];
}
public function mount(string $token): void
{
$this->token = $token;

View File

@ -38,7 +38,6 @@ use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\WireguardHub;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Events\Login;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Carbon;
@ -106,13 +105,25 @@ class AppServiceProvider extends ServiceProvider
Gate::define('use-custom-domain', fn ($user) => app(CustomDomainAccess::class)
->allowsCustomer(Customer::forUser($user)));
// One listener for both ways in. The console signs in through
// App\Livewire\Auth\OperatorLogin and the portal through Fortify — two
// paths that share no code but both raise this event, which carries the
// guard that tells the two identities apart (R21). Registered here
// rather than left to listener discovery, so that moving the class
// cannot silently stop sign-ins being recorded.
Event::listen(Login::class, RecordSignInDevice::class);
// KEINE Registrierung von RecordSignInDevice mehr an dieser Stelle.
//
// Laravel erkennt Listener in app/Listeners an der Typangabe ihrer
// handle()-Methode selbst. Die Zeile, die hier stand, meldete denselben
// Empfänger ein ZWEITES Mal an — gemessen am 31.7.2026 mit
// Event::getRawListeners(): `RecordSignInDevice` und
// `RecordSignInDevice@handle` nebeneinander.
//
// Das kostete nicht nur eine doppelte Mail. Der zweite Durchlauf sah
// die Gerätezeile, die der erste gerade angelegt hatte, bekam aber das
// per Cookie::queue() gesetzte Token innerhalb derselben Anfrage noch
// nicht zu sehen — ihm galt also JEDE Anmeldung als neues Gerät. Der
// Betreiber meldete beides: zwei gleiche Mails, und gefühlt bei jeder
// Anmeldung eine.
//
// Die Begründung der alten Zeile — am Event horchen, nicht an den zwei
// Anmeldewegen — bleibt richtig und steht im Kopfkommentar des
// Listeners. Die automatische Erkennung trägt sie genauso.
// Festgehalten von tests/Feature/SignInMailOnceTest.php.
// Every timestamp that gets shown to somebody goes through ->local()
// first. Storage is UTC and stays UTC; this is the last step before a

View File

@ -0,0 +1,72 @@
<?php
namespace App\Support;
use Illuminate\Validation\Rules\Password;
/**
* Wie lang ein Passwort in dieser Installation sein muss an einer Stelle.
*
* Vorher gab es die Regel dreimal, mit drei verschiedenen Antworten:
* `ResetPassword` verlangte fest `min:12`, `CreateAdmin` `Password::min(12)`,
* und alles über `PasswordValidationRules` bekam `Password::default()` also
* Laravels acht. Derselbe Kunde brauchte beim Zurücksetzen zwölf Zeichen und
* bei der Registrierung acht, und einstellbar war nichts davon.
*
* Gemeldet wurde die Zwölf als „zu lang". Die eigentliche Auffälligkeit war,
* dass es die Regel dreimal gibt: drei Stellen, die dieselbe Frage
* verschieden beantworten, laufen auseinander hier waren sie es schon.
*
* Geklemmt statt abgelehnt: der Wert kommt aus einem Formular im Adminbereich,
* und eine stillschweigend gespeicherte 1 wäre schlimmer als eine sichtbar auf
* 6 geklemmte Eingabe.
*/
final class PasswordPolicy
{
public const SETTING = 'security.password_min_length';
/**
* Die kürzeste Länge, die diese Installation zulässt.
*
* Sechs ist wenig. Es ist die ausdrückliche Vorgabe des Betreibers
* (31.7.2026, „6 ist mindestens"), und die Zahl steht direkt neben dem
* Feld, an dem sie sich erhöhen lässt.
*/
public const FLOOR = 6;
/** bcrypt schneidet jenseits von 72 Byte ohnehin ab; 128 ist reichlich Luft. */
public const CEILING = 128;
public const DEFAULT_LENGTH = 6;
public static function minLength(): int
{
$stored = Settings::get(self::SETTING);
return self::clamp(is_numeric($stored) ? (int) $stored : self::DEFAULT_LENGTH);
}
public static function set(int $length): void
{
Settings::set(self::SETTING, self::clamp($length));
}
/**
* Die Regeln OHNE `confirmed`.
*
* Die Bestätigung hängt am Formular, nicht an der Richtlinie: `CreateAdmin`
* fragt auf der Kommandozeile einmal, jedes Formular zweimal. Wer sie
* braucht, hängt sie an.
*
* @return array<int, mixed>
*/
public static function rules(): array
{
return ['required', 'string', Password::min(self::minLength())];
}
private static function clamp(int $length): int
{
return max(self::FLOOR, min(self::CEILING, $length));
}
}

View File

@ -202,10 +202,15 @@ return [
// The tab bar. Kept apart from `group` — those were the headings of a
// scrolled page and read as such ("Installation" over a divider line); a
// tab is a thing you click, and it has room for fewer words.
'password_policy_title' => 'Passwortrichtlinie',
'password_policy_body' => 'Gilt für Kunden und Mitarbeiter gleichermaßen — beim Registrieren, beim Zurücksetzen und beim Ändern.',
'password_min_length' => 'Mindestlänge (Zeichen)',
'password_min_length_hint' => 'Kürzer als :floor Zeichen ist nicht möglich. Länger ist jederzeit möglich und schadet nichts.',
'password_policy_saved' => 'Passwortrichtlinie gespeichert. Sie gilt ab dem nächsten Setzen eines Passworts.',
'tab' => [
'installation' => 'Installation',
'account' => 'Mein Konto',
'two_factor' => 'Zwei-Faktor',
'two_factor' => 'Sicherheit',
'team' => 'Team',
],
];

View File

@ -201,10 +201,15 @@ return [
// The tab bar. Kept apart from `group` — those were the headings of a
// scrolled page and read as such; a tab is a thing you click, and it has
// room for fewer words.
'password_policy_title' => 'Password policy',
'password_policy_body' => 'Applies to customers and staff alike — on registration, on reset and on change.',
'password_min_length' => 'Minimum length (characters)',
'password_min_length_hint' => 'Shorter than :floor characters is not possible. Longer is always possible and costs nothing.',
'password_policy_saved' => 'Password policy saved. It applies from the next password set.',
'tab' => [
'installation' => 'Installation',
'account' => 'My account',
'two_factor' => 'Two-factor',
'two_factor' => 'Security',
'team' => 'Team',
],
];

View File

@ -476,7 +476,31 @@
anything else yet this page included. Same component either way, so
there is no second copy of the enrolment logic to keep in step. --}}
@if ($tab === 'two-factor')
<div class="animate-rise">
{{-- Die Passwortrichtlinie, und zwar HIER: sie gilt für Kunden wie für
Mitarbeiter, ist also keine Einstellung des eigenen Kontos. Der
Reiter heißt deshalb „Sicherheit" und nicht mehr „Zwei-Faktor"
der Schlüssel bleibt `two-factor`, damit bestehende Links halten. --}}
<x-ui.card class="animate-rise">
<h2 class="text-lg font-semibold text-ink">{{ __('admin_settings.password_policy_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('admin_settings.password_policy_body') }}</p>
<div class="mt-5 flex flex-wrap items-end gap-3">
<div class="w-40">
<x-ui.input name="passwordMinLength" type="number"
min="{{ \App\Support\PasswordPolicy::FLOOR }}"
max="{{ \App\Support\PasswordPolicy::CEILING }}"
wire:model="passwordMinLength"
:label="__('admin_settings.password_min_length')" />
</div>
<x-ui.button variant="primary" wire:click="savePasswordPolicy"
wire:loading.attr="disabled" wire:target="savePasswordPolicy">
{{ __('common.save') }}
</x-ui.button>
</div>
<p class="mt-2 text-xs text-muted">{{ __('admin_settings.password_min_length_hint', ['floor' => \App\Support\PasswordPolicy::FLOOR]) }}</p>
</x-ui.card>
<div class="animate-rise [animation-delay:30ms]">
@livewire('admin.two-factor-setup', ['embedded' => true])
</div>
@endif

View File

@ -0,0 +1,95 @@
<?php
use App\Livewire\Auth\ResetPassword;
use App\Models\User;
use App\Support\PasswordPolicy;
use App\Support\Settings;
use Illuminate\Support\Facades\Password as PasswordBroker;
use Livewire\Livewire;
/**
* Diese Installation hatte DREI Passwortregeln nebeneinander und keine davon
* einstellbar:
*
* App\Livewire\Auth\ResetPassword `min:12`, fest im Attribut
* App\Console\Commands\CreateAdmin Password::min(12)
* PasswordValidationRules Password::default() also Laravels 8
*
* Ein Kunde, der sein Passwort zurücksetzte, musste zwölf Zeichen nehmen;
* derselbe Kunde bei der Registrierung acht. Gemeldet wurde die Zwölf, und beim
* Nachsehen war die eigentliche Auffälligkeit, dass es die Regel dreimal gibt.
*/
beforeEach(function () {
Settings::forget(PasswordPolicy::SETTING);
});
it('starts at six characters', function () {
expect(PasswordPolicy::minLength())->toBe(6);
});
it('lets the console set the length, within a floor and a ceiling', function () {
PasswordPolicy::set(10);
expect(PasswordPolicy::minLength())->toBe(10);
// Unterhalb der Grenze wird geklemmt, nicht abgelehnt: der Wert kommt aus
// einem Formular, und eine stillschweigend gespeicherte 1 wäre schlimmer
// als eine sichtbar geklemmte 6.
PasswordPolicy::set(2);
expect(PasswordPolicy::minLength())->toBe(PasswordPolicy::FLOOR);
PasswordPolicy::set(9999);
expect(PasswordPolicy::minLength())->toBe(PasswordPolicy::CEILING);
});
it('accepts a six-character password when resetting', function () {
// Der gemeldete Fall. Vorher: `min:12`, fest verdrahtet.
$user = User::factory()->create(['email' => 'kunde@example.test']);
$token = PasswordBroker::broker()->createToken($user);
Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'abcdef')
->set('password_confirmation', 'abcdef')
->call('save')
->assertHasNoErrors();
expect($user->fresh()->password)->not->toBe($user->password);
});
it('follows the configured length instead of a number of its own', function () {
PasswordPolicy::set(10);
$user = User::factory()->create(['email' => 'kunde@example.test']);
$token = PasswordBroker::broker()->createToken($user);
Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'abcdefgh')
->set('password_confirmation', 'abcdefgh')
->call('save')
->assertHasErrors('password');
});
it('keeps the rule in one place', function () {
// Der eigentliche Fund: drei Regeln nebeneinander. Ein Wächter, damit die
// vierte nicht danebengeschrieben wird.
$offenders = [];
foreach (['app/Livewire', 'app/Actions', 'app/Console/Commands', 'app/Http'] as $dir) {
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(base_path($dir)));
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$body = file_get_contents($file->getPathname());
if (preg_match('/min:\d+\|confirmed|Password::min\(|Password::default\(\)/', $body)) {
$offenders[] = str_replace(base_path().'/', '', $file->getPathname());
}
}
}
expect($offenders)->toBe([], 'Passwortlänge gehört in App\Support\PasswordPolicy, nicht hierhin: '.implode(', ', $offenders));
});

View File

@ -0,0 +1,49 @@
<?php
use App\Mail\NewDeviceSignInMail;
use App\Models\User;
use Illuminate\Auth\Events\Login;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
/**
* Zwei identische „neues Gerät"-Mails im selben Augenblick.
*
* Gemessen, nicht vermutet `Event::getRawListeners()` führte den Listener
* zweimal:
*
* App\Listeners\RecordSignInDevice Laravel findet ihn selbst
* App\Listeners\RecordSignInDevice@handle die Zeile im AppServiceProvider
*
* Laravel 11+ erkennt Listener in `app/Listeners` an der Typangabe ihrer
* handle()-Methode. Die ausdrückliche Registrierung war zu der Zeit richtig,
* als sie geschrieben wurde, und ist seither eine zweite Anmeldung desselben
* Empfängers. Der Kopfkommentar des Listeners begründet, warum am EVENT
* gehorcht wird und nicht an den zwei Anmeldewegen diese Begründung trägt die
* automatische Erkennung genauso.
*/
it('registers the sign-in listener exactly once', function () {
$listeners = Event::getRawListeners()[Login::class] ?? [];
expect($listeners)->toHaveCount(1);
});
it('sends one new-device mail per sign-in, not two', function () {
// Der Test, der wirklich zählt: nicht wie oft etwas registriert ist,
// sondern wie oft der Kunde etwas bekommt.
Mail::fake();
$user = User::factory()->create();
// Erste Anmeldung überhaupt: die warnt bewusst nicht (siehe
// DeviceRecognition — jemandem das Gerät zu melden, mit dem er sich gerade
// anmeldet, ist keine Warnung).
Event::dispatch(new Login('web', $user, false));
Mail::assertNothingQueued();
// Zweite Anmeldung, neues Gerät: genau EINE Mail.
request()->cookies->remove('clupilot_device');
Event::dispatch(new Login('web', $user, false));
Mail::assertQueued(NewDeviceSignInMail::class, 1);
});