Passwortrichtlinie einstellbar, doppelte Anmelde-Mail behoben
parent
19ed6461cf
commit
0944de7cfa
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue