From 0944de7cfaa096929a1fd7f790d5a1ba2873161c Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 20:53:19 +0200 Subject: [PATCH] Passwortrichtlinie einstellbar, doppelte Anmelde-Mail behoben --- .../Fortify/PasswordValidationRules.php | 4 +- app/Console/Commands/CreateAdmin.php | 4 +- app/Livewire/Admin/Settings.php | 31 ++++++ app/Livewire/Auth/ResetPassword.php | 16 +++- app/Providers/AppServiceProvider.php | 27 ++++-- app/Support/PasswordPolicy.php | 72 ++++++++++++++ lang/de/admin_settings.php | 7 +- lang/en/admin_settings.php | 7 +- .../views/livewire/admin/settings.blade.php | 26 ++++- tests/Feature/PasswordPolicyTest.php | 95 +++++++++++++++++++ tests/Feature/SignInMailOnceTest.php | 49 ++++++++++ 11 files changed, 322 insertions(+), 16 deletions(-) create mode 100644 app/Support/PasswordPolicy.php create mode 100644 tests/Feature/PasswordPolicyTest.php create mode 100644 tests/Feature/SignInMailOnceTest.php diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php index 3678865..4da0096 100644 --- a/app/Actions/Fortify/PasswordValidationRules.php +++ b/app/Actions/Fortify/PasswordValidationRules.php @@ -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']; } } diff --git a/app/Console/Commands/CreateAdmin.php b/app/Console/Commands/CreateAdmin.php index 5ed80d2..27a0b1a 100644 --- a/app/Console/Commands/CreateAdmin.php +++ b/app/Console/Commands/CreateAdmin.php @@ -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(), ], ); diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 0a369d5..ad97162 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -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 diff --git a/app/Livewire/Auth/ResetPassword.php b/app/Livewire/Auth/ResetPassword.php index 84ba1cc..c541ee6 100644 --- a/app/Livewire/Auth/ResetPassword.php +++ b/app/Livewire/Auth/ResetPassword.php @@ -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 + */ + protected function rules(): array + { + return ['password' => [...PasswordPolicy::rules(), 'confirmed']]; + } + public function mount(string $token): void { $this->token = $token; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5bdc8fb..2b69938 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 diff --git a/app/Support/PasswordPolicy.php b/app/Support/PasswordPolicy.php new file mode 100644 index 0000000..ec430b6 --- /dev/null +++ b/app/Support/PasswordPolicy.php @@ -0,0 +1,72 @@ + + */ + 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)); + } +} diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index f86ecc1..913249d 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -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', ], ]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 4828386..4f27b28 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -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', ], ]; diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index b2f55d8..25a102a 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -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') -
+ {{-- 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. --}} + +

{{ __('admin_settings.password_policy_title') }}

+

{{ __('admin_settings.password_policy_body') }}

+ +
+
+ +
+ + {{ __('common.save') }} + +
+

{{ __('admin_settings.password_min_length_hint', ['floor' => \App\Support\PasswordPolicy::FLOOR]) }}

+
+ +
@livewire('admin.two-factor-setup', ['embedded' => true])
@endif diff --git a/tests/Feature/PasswordPolicyTest.php b/tests/Feature/PasswordPolicyTest.php new file mode 100644 index 0000000..c68d6a1 --- /dev/null +++ b/tests/Feature/PasswordPolicyTest.php @@ -0,0 +1,95 @@ +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)); +}); diff --git a/tests/Feature/SignInMailOnceTest.php b/tests/Feature/SignInMailOnceTest.php new file mode 100644 index 0000000..abc8116 --- /dev/null +++ b/tests/Feature/SignInMailOnceTest.php @@ -0,0 +1,49 @@ +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); +});