From f2cbf60c04de23ee2e6c77bbc8da3af97176e150 Mon Sep 17 00:00:00 2001 From: boban Date: Wed, 17 Jun 2026 17:59:45 +0200 Subject: [PATCH] fix(security): harden brute-force, rate-limiting and auth-DoS (audit follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial audit of the auth surface confirmed several exploitable gaps. Every fix auto-expires and never permanently locks the control plane (bare-IP recovery host and the clusev:reset-admin CLI stay open). Login: - per-IP (20/min) + per-account (30/15min) buckets on top of the existing email+IP 5/min, so a distributed multi-IP brute-force of the single admin account is capped instead of scaling linearly with IPs. ip/acct counters decay on their own (a valid login doesn't reset a flood's budget). - constant-time: the unknown-email branch now runs one bcrypt against a fixed DUMMY_HASH (evaluated before the `! $user ||` short-circuit), closing the account-enumeration timing oracle. 2FA: - TOTP replay protection — verifyTotp uses verifyKeyNewer with a persisted last-used step (two_factor_last_used_step migration), so each 30s code is single-use. Passes 0 (never null) as the old step, since null makes verifyKeyNewer return bare `true` not the step. - challenge + 2FA-proof reset gain an IP-independent per-account backstop, capping the distributed TOTP/backup brute-force surface. Defense-in-depth / DoS: - global throttle on /livewire/update (180/min per user-id-or-IP) via setUpdateRoute, keeping 'web' + the persistent EnsureSecurityOnboarded gate intact. - reauth throttle (5/min per user) on password change/profile; SMTP test-send capped (3/10min per user) so the panel can't relay spam. - SshClient gets a short, separate connect timeout (~5s) so a dead/tarpit host can't hold a worker across several sequential 15s reads (the observed 8.3s /livewire/update DoS). - profile password policy raised to 12 chars + mixed case + numbers (was 10). Tests: BruteForceHardeningTest covers TOTP replay, per-email + per-IP login caps, identical unknown/known rejection, and reauth throttling. Full suite 187 green; Codex clean; verified in a real browser (login/polls/Livewire updates unaffected by the throttle). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++ app/Livewire/Auth/ForgotPassword.php | 22 ++- app/Livewire/Auth/Login.php | 44 ++++-- app/Livewire/Auth/PasswordChange.php | 35 ++++- app/Livewire/Auth/TwoFactorChallenge.php | 67 ++++++--- app/Livewire/Settings/Email.php | 11 ++ app/Livewire/Settings/Profile.php | 28 +++- app/Models/User.php | 21 ++- app/Providers/AppServiceProvider.php | 17 +++ app/Support/Ssh/SshClient.php | 8 +- ...o_factor_last_used_step_to_users_table.php | 27 ++++ lang/de/mail.php | 1 + lang/en/mail.php | 1 + tests/Feature/BruteForceHardeningTest.php | 127 ++++++++++++++++++ 14 files changed, 383 insertions(+), 45 deletions(-) create mode 100644 database/migrations/2026_06_17_000001_add_two_factor_last_used_step_to_users_table.php create mode 100644 tests/Feature/BruteForceHardeningTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5291770..e1ec37d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,25 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui spiegelt jetzt exakt die Kosten der echten Recovery-Code-Prüfung (gesperrtes `SELECT` in einer Transaktion statt eines schwereren bcrypt-Vergleichs), und Transportfehler beim Reset-Link werden geloggt statt stillschweigend verschluckt. +- **Brute-Force-/Rate-Limiting-Härtung (Audit-Nachgang).** Alle neuen Limits laufen automatisch ab + und sperren die Control-Plane nie dauerhaft (Bare-IP-Recovery + `clusev:reset-admin` bleiben offen): + - **Login** zusätzlich pro IP (20/Min) und pro Konto (30/15 Min) begrenzt — ein verteilter + Mehr-IP-Angriff auf das eine Admin-Konto wird gedeckelt statt linear zu skalieren; und der + Login-Zweig für unbekannte E-Mails führt jetzt denselben bcrypt aus (Konstantzeit) und schließt + so das Konten-Enumerations-Timing-Orakel. + - **TOTP-Replay-Schutz:** jeder 6-stellige Code ist genau einmal gültig (zuletzt akzeptierter + Zeit-Schritt wird persistiert), ein abgefangener Code lässt sich nicht im 30-Sek-Fenster + wiederverwenden. + - **2FA-Challenge & 2FA-Beweis-Reset** zusätzlich pro Konto begrenzt (IP-unabhängiger Backstop), + sodass die TOTP-/Backup-Code-Brute-Force-Fläche auch verteilt gedeckelt ist. + - **Globaler `/livewire/update`-Throttle** (180/Min pro Identität) als Flut-Deckel über den + Per-Aktion-Limitern. + - **Re-Auth gedrosselt** (Passwortänderung/-Profil, 5/Min pro Nutzer) gegen Passwort-Brute-Force + über eine gekaperte Session; **SMTP-Testversand** auf 3/10 Min pro Nutzer begrenzt (kein + Spam-Relay). + - **DoS-Schutz:** SSH bekommt einen kurzen, separaten Connect-Timeout (~5 s), damit ein toter/ + langsamer Host einen Worker nicht über mehrere sequentielle Reads × 15 s blockiert; die + Profil-Passwort-Policy auf 12 Zeichen + Groß/Klein + Ziffern angehoben (war 10). ## [0.9.2] - 2026-06-15 diff --git a/app/Livewire/Auth/ForgotPassword.php b/app/Livewire/Auth/ForgotPassword.php index d4728a7..6240aaa 100644 --- a/app/Livewire/Auth/ForgotPassword.php +++ b/app/Livewire/Auth/ForgotPassword.php @@ -72,11 +72,19 @@ class ForgotPassword extends Component 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], ]); - $key = 'forgot:'.md5(strtolower($this->email).'|'.request()->ip()); - if (RateLimiter::tooManyAttempts($key, 5)) { - throw ValidationException::withMessages([ - 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), - ]); + // Tight per-(email+IP) bucket PLUS an IP-independent per-account backstop: this reset + // path is a second TOTP/backup brute-force surface (email + code, no password), so a + // distributed (multi-IP) attempt must be capped per account the same way the challenge + // is. Both auto-expire — never a permanent lockout (clusev:reset-admin stays open). + $email = strtolower($this->email); + $key = 'forgot:'.md5($email.'|'.request()->ip()); + $acctKey = 'forgot-acct:'.md5($email); + foreach ([[$key, 5], [$acctKey, 20]] as [$k, $max]) { + if (RateLimiter::tooManyAttempts($k, $max)) { + throw ValidationException::withMessages([ + 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]), + ]); + } } $user = User::where('email', $this->email)->first(); @@ -108,12 +116,14 @@ class ForgotPassword extends Component } if (! $ok) { - RateLimiter::hit($key, 60); + RateLimiter::hit($key, 60); // 5 / minute (email+IP) + RateLimiter::hit($acctKey, 900); // 20 / 15 minutes (per account, IP-independent) // Generic message — never reveal whether the email exists. throw ValidationException::withMessages(['code' => __('auth.reset_invalid')]); } RateLimiter::clear($key); + RateLimiter::clear($acctKey); // Rotate remember_token too, so a stolen remember-me cookie can't survive the reset. $user->forceFill([ 'password' => Hash::make($this->password), diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php index 7bccea0..3e63c3f 100644 --- a/app/Livewire/Auth/Login.php +++ b/app/Livewire/Auth/Login.php @@ -14,6 +14,14 @@ use Livewire\Component; #[Layout('layouts.auth')] class Login extends Component { + /** + * A fixed, valid cost-12 bcrypt hash. When the e-mail is unknown we still run one + * Hash::check against this so the failure branch costs the same bcrypt as a known + * account — closing the response-timing oracle that would otherwise reveal which + * addresses exist (same class as the password-reset constant-time fix). + */ + private const DUMMY_HASH = '$2y$12$6gKO7fsTd5ZoshlNsI7JpeuIU60K30r3K0mdPdIryNc2IQy8XwI.u'; + #[Validate('required|email')] public string $email = ''; @@ -26,22 +34,42 @@ class Login extends Component { $this->validate(); - $key = 'login:'.md5(strtolower($this->email).'|'.request()->ip()); + // Three auto-expiring counters, none of which can permanently lock the control plane + // (all decay in minutes; the host CLI `clusev:reset-admin` is always available): + // - pair (email+IP): the tight 5/min bucket (unchanged). + // - ip : caps one source hammering many accounts AND the bcrypt CPU it burns. + // - acct : an IP-independent backstop so a DISTRIBUTED (multi-IP) brute-force of the + // single admin account is still capped instead of scaling linearly with IPs. + $email = strtolower($this->email); + $pairKey = 'login:'.md5($email.'|'.request()->ip()); + $ipKey = 'login-ip:'.md5(request()->ip()); + $acctKey = 'login-acct:'.md5($email); - if (RateLimiter::tooManyAttempts($key, 5)) { - throw ValidationException::withMessages([ - 'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), - ]); + foreach ([[$pairKey, 5], [$ipKey, 20], [$acctKey, 30]] as [$k, $max]) { + if (RateLimiter::tooManyAttempts($k, $max)) { + throw ValidationException::withMessages([ + 'email' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($k)]), + ]); + } } $user = User::where('email', $this->email)->first(); - if (! $user || ! Hash::check($this->password, $user->password)) { - RateLimiter::hit($key, 60); + // Evaluate the bcrypt FIRST (never short-circuit it): an unknown e-mail must still spend + // one Hash::check against DUMMY_HASH, otherwise `! $user ||` would skip the hash and the + // faster no-account response would re-open the enumeration timing oracle. + $validPassword = Hash::check($this->password, $user?->password ?? self::DUMMY_HASH); + + if (! $user || ! $validPassword) { + RateLimiter::hit($pairKey, 60); // 5 / minute + RateLimiter::hit($ipKey, 60); // 20 / minute + RateLimiter::hit($acctKey, 900); // 30 / 15 minutes throw ValidationException::withMessages(['email' => __('auth.invalid_credentials')]); } - RateLimiter::clear($key); + // Only the tight pair bucket is cleared on success; the broader ip/acct counters + // decay on their own so one valid login can't reset a flood's budget. + RateLimiter::clear($pairKey); // 2FA gate: hold the login until the TOTP code is verified. if ($user->hasTwoFactorEnabled()) { diff --git a/app/Livewire/Auth/PasswordChange.php b/app/Livewire/Auth/PasswordChange.php index 93ea1f4..c3b5198 100644 --- a/app/Livewire/Auth/PasswordChange.php +++ b/app/Livewire/Auth/PasswordChange.php @@ -4,7 +4,9 @@ namespace App\Livewire\Auth; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Layout; use Livewire\Component; @@ -19,13 +21,32 @@ class PasswordChange extends Component public function update() { - $this->validate([ - 'current' => ['required', 'current_password'], - 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], - ], attributes: [ - 'current' => __('auth.attr_current_password'), - 'password' => __('auth.attr_new_password'), - ]); + // Throttle the current-password re-auth so a hijacked session can't brute-force the + // password. Keyed on the authenticated user id (never IP) and auto-expiring, so it can + // only slow the user's own session briefly — never a cross-user or control-plane lockout. + $key = 'reauth:'.Auth::id(); + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'current' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + + try { + $this->validate([ + 'current' => ['required', 'current_password'], + 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], + ], attributes: [ + 'current' => __('auth.attr_current_password'), + 'password' => __('auth.attr_new_password'), + ]); + } catch (ValidationException $e) { + if (array_key_exists('current', $e->errors())) { + RateLimiter::hit($key, 60); + } + throw $e; + } + + RateLimiter::clear($key); Auth::user()->forceFill([ 'password' => Hash::make($this->password), diff --git a/app/Livewire/Auth/TwoFactorChallenge.php b/app/Livewire/Auth/TwoFactorChallenge.php index 797cdd9..c6feb8d 100644 --- a/app/Livewire/Auth/TwoFactorChallenge.php +++ b/app/Livewire/Auth/TwoFactorChallenge.php @@ -45,17 +45,54 @@ class TwoFactorChallenge extends Component return (bool) $this->pendingUser()?->hasTotp(); } + /** + * Two auto-expiring 2FA brute-force buckets, keyed on the SERVER-side pending user id + * (not client-controlled) — a tight per-(user+IP) one plus an IP-independent per-user + * backstop, so a distributed (multi-IP) attempt to brute-force the 6-digit TOTP is still + * capped. Both decay in minutes (never a permanent lockout; clusev:reset-admin stays open). + * + * @return array key => [maxAttempts, decaySeconds] + */ + private function rateLimitBuckets(): array + { + $uid = (string) session('2fa.user'); + + return [ + 'two-factor:'.md5($uid.'|'.request()->ip()) => [5, 60], + 'two-factor-acct:'.md5($uid) => [20, 900], + ]; + } + + private function assertNotRateLimited(): void + { + foreach ($this->rateLimitBuckets() as $key => [$max]) { + if (RateLimiter::tooManyAttempts($key, $max)) { + throw ValidationException::withMessages([ + 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + } + } + + private function hitRateLimit(): void + { + foreach ($this->rateLimitBuckets() as $key => [, $decay]) { + RateLimiter::hit($key, $decay); + } + } + + private function clearRateLimit(): void + { + foreach (array_keys($this->rateLimitBuckets()) as $key) { + RateLimiter::clear($key); + } + } + public function verify() { $this->validate(); - $key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip()); - - if (RateLimiter::tooManyAttempts($key, 5)) { - throw ValidationException::withMessages([ - 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), - ]); - } + $this->assertNotRateLimited(); $user = User::find(session('2fa.user')); @@ -69,11 +106,11 @@ class TwoFactorChallenge extends Component $valid = $user->verifyTotp($this->code) || $user->useRecoveryCode($this->code); if (! $valid) { - RateLimiter::hit($key, 60); + $this->hitRateLimit(); throw ValidationException::withMessages(['code' => __('auth.invalid_code')]); } - RateLimiter::clear($key); + $this->clearRateLimit(); $remember = (bool) session('2fa.remember'); session()->forget(['2fa.user', '2fa.remember']); @@ -106,22 +143,16 @@ class TwoFactorChallenge extends Component /** Complete the login with a verified security-key assertion (alternative to the TOTP/backup code). */ public function verifyWebauthn(array $assertion, WebauthnService $webauthn) { - $key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip()); - - if (RateLimiter::tooManyAttempts($key, 5)) { - throw ValidationException::withMessages([ - 'code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), - ]); - } + $this->assertNotRateLimited(); $user = User::find(session('2fa.user')); if (! $user || ! $user->hasTwoFactorEnabled() || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) { - RateLimiter::hit($key, 60); + $this->hitRateLimit(); throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]); } - RateLimiter::clear($key); + $this->clearRateLimit(); $remember = (bool) session('2fa.remember'); session()->forget(['2fa.user', '2fa.remember']); diff --git a/app/Livewire/Settings/Email.php b/app/Livewire/Settings/Email.php index b0539af..5ed6226 100644 --- a/app/Livewire/Settings/Email.php +++ b/app/Livewire/Settings/Email.php @@ -7,6 +7,7 @@ use App\Models\Setting; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; use Livewire\Component; use Throwable; @@ -109,6 +110,16 @@ class Email extends Component return; } + // Throttle test-sends so the panel can't be turned into a spam/mail-bomb relay. + // Per-user + auto-expiring: only ever limits the operator's own test button briefly. + $key = 'mail-test:'.Auth::id(); + if (RateLimiter::tooManyAttempts($key, 3)) { + $this->dispatch('notify', message: __('mail.notify_test_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); + + return; + } + RateLimiter::hit($key, 600); // 3 test e-mails / 10 minutes per user + $to = Auth::user()->email; try { diff --git a/app/Livewire/Settings/Profile.php b/app/Livewire/Settings/Profile.php index d61cc4d..44b5b33 100644 --- a/app/Livewire/Settings/Profile.php +++ b/app/Livewire/Settings/Profile.php @@ -4,8 +4,10 @@ namespace App\Livewire\Settings; use App\Models\AuditEvent; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\ValidationException; use Livewire\Component; class Profile extends Component @@ -40,10 +42,28 @@ class Profile extends Component public function updatePassword(): void { - $this->validate([ - 'current_password' => ['required', 'current_password'], - 'password' => ['required', 'confirmed', Password::min(10)], - ]); + // Throttle the current-password re-auth (hijacked-session brute-force protection). + // User-id keyed + auto-expiring: only ever slows the user's own session briefly. + $key = 'reauth:'.Auth::id(); + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages([ + 'current_password' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]), + ]); + } + + try { + $this->validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()], + ]); + } catch (ValidationException $e) { + if (array_key_exists('current_password', $e->errors())) { + RateLimiter::hit($key, 60); + } + throw $e; + } + + RateLimiter::clear($key); Auth::user()->update(['password' => $this->password, 'must_change_password' => false]); $this->reset('current_password', 'password', 'password_confirmation'); diff --git a/app/Models/User.php b/app/Models/User.php index 07e9630..be79caf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -103,7 +103,26 @@ class User extends Authenticatable } try { - return (new Google2FA)->verifyKey($this->two_factor_secret, preg_replace('/\s+/', '', $code) ?? ''); + // Replay protection: verifyKeyNewer rejects any code from a time-step <= the last + // accepted one and returns the matched step on success. Persisting it makes each + // 30s code single-use, so an intercepted/replayed code can't be used twice. + // + // Pass 0 (never null) as the old timestamp: google2fa returns the bare bool `true` + // — not the matched step — when oldTimestamp is null, which would break the + // comparison on the very first use. With 0 it always returns the integer step. + $step = (new Google2FA)->verifyKeyNewer( + $this->two_factor_secret, + preg_replace('/\s+/', '', $code) ?? '', + (int) ($this->two_factor_last_used_step ?? 0), + ); + + if ($step === false) { + return false; + } + + $this->forceFill(['two_factor_last_used_step' => $step])->save(); + + return true; } catch (Google2FAException) { return false; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6202f93..48c7eb3 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,7 +5,11 @@ namespace App\Providers; use App\Http\Middleware\EnsureSecurityOnboarded; use App\Models\Setting; use App\Services\DeploymentService; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; use Throwable; @@ -41,6 +45,19 @@ class AppServiceProvider extends ServiceProvider EnsureSecurityOnboarded::class, ]); + // Defense-in-depth: a coarse per-identity cap on the Livewire action endpoint + // (/livewire/update carries every component request, including the auth actions). The + // per-action limiters in the components are the precise gate; this is the blunt cap so a + // single source (authed user id, else guest IP) can't flood workers regardless of which + // action it targets. 180/min sits far above normal polling + interaction yet well below a + // flood; it auto-expires, so — like every limiter here — it can never permanently lock the + // control plane (the bare-IP recovery host and clusev:reset-admin are unaffected). + RateLimiter::for('livewire-update', fn (Request $request) => Limit::perMinute(180) + ->by($request->user()?->id ?: $request->ip())); + + Livewire::setUpdateRoute(fn ($handle) => Route::post('/livewire/update', $handle) + ->middleware(['web', 'throttle:livewire-update'])); + config([ 'broadcasting.connections.reverb.options.host' => 'reverb', 'broadcasting.connections.reverb.options.port' => 8080, diff --git a/app/Support/Ssh/SshClient.php b/app/Support/Ssh/SshClient.php index 9d6312b..9c4677d 100644 --- a/app/Support/Ssh/SshClient.php +++ b/app/Support/Ssh/SshClient.php @@ -19,11 +19,17 @@ class SshClient public function __construct( private readonly CredentialVault $vault, private readonly int $timeout = 15, + private readonly int $connectTimeout = 5, ) {} public function connect(Server $server): self { - $ssh = new SSH2($server->ip, $server->ssh_port ?: 22); + // Short, SEPARATE connect timeout so an unreachable / black-holing / tarpit host fails + // fast (~5s) instead of holding the FPM worker for the full exec timeout. The + // server-detail page issues several SSH reads in a row, so a dead host must not stack + // timeout × N and exhaust workers (DoS). The exec/read timeout stays at 15s for + // legitimately slow commands. + $ssh = new SSH2($server->ip, $server->ssh_port ?: 22, $this->connectTimeout); $ssh->setTimeout($this->timeout); $this->verifyHostKey($ssh, $server); diff --git a/database/migrations/2026_06_17_000001_add_two_factor_last_used_step_to_users_table.php b/database/migrations/2026_06_17_000001_add_two_factor_last_used_step_to_users_table.php new file mode 100644 index 0000000..d42d886 --- /dev/null +++ b/database/migrations/2026_06_17_000001_add_two_factor_last_used_step_to_users_table.php @@ -0,0 +1,27 @@ +unsignedBigInteger('two_factor_last_used_step')->nullable()->after('two_factor_secret'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('two_factor_last_used_step'); + }); + } +}; diff --git a/lang/de/mail.php b/lang/de/mail.php index d34d66c..8d77a4b 100644 --- a/lang/de/mail.php +++ b/lang/de/mail.php @@ -35,5 +35,6 @@ return [ 'notify_saved' => 'SMTP-Einstellungen gespeichert.', 'notify_test_ok' => 'Testmail an :email gesendet.', 'notify_test_failed' => 'Testmail fehlgeschlagen: :error', + 'notify_test_throttled' => 'Zu viele Testmails – bitte in :seconds Sekunden erneut.', 'not_configured' => 'SMTP ist nicht konfiguriert.', ]; diff --git a/lang/en/mail.php b/lang/en/mail.php index 2dba56f..fefec1a 100644 --- a/lang/en/mail.php +++ b/lang/en/mail.php @@ -35,5 +35,6 @@ return [ 'notify_saved' => 'SMTP settings saved.', 'notify_test_ok' => 'Test mail sent to :email.', 'notify_test_failed' => 'Test mail failed: :error', + 'notify_test_throttled' => 'Too many test e-mails — try again in :seconds seconds.', 'not_configured' => 'SMTP is not configured.', ]; diff --git a/tests/Feature/BruteForceHardeningTest.php b/tests/Feature/BruteForceHardeningTest.php new file mode 100644 index 0000000..56dc805 --- /dev/null +++ b/tests/Feature/BruteForceHardeningTest.php @@ -0,0 +1,127 @@ +create([ + 'two_factor_secret' => (new Google2FA)->generateSecretKey(), + 'two_factor_confirmed_at' => now(), + ]); + + $otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret); + + $this->assertTrue($user->verifyTotp($otp), 'first use of a fresh code must succeed'); + $this->assertFalse($user->fresh()->verifyTotp($otp), 'the same code must be rejected on replay'); + } + + public function test_per_account_login_throttle_blocks_even_a_correct_password(): void + { + User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]); + + for ($i = 0; $i < 5; $i++) { + Livewire::test(Login::class) + ->set('email', 'admin@clusev.local')->set('password', 'wrong')->call('authenticate'); + } + + // 6th attempt with the RIGHT password must still be refused (per-(email+IP) cap = 5). + Livewire::test(Login::class) + ->set('email', 'admin@clusev.local')->set('password', self::PW)->call('authenticate') + ->assertHasErrors('email'); + + $this->assertGuest(); + } + + public function test_per_ip_login_throttle_blocks_a_flood_across_distinct_emails(): void + { + User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]); + + // 20 failures spread over distinct emails — the per-(email+IP) bucket never trips (1 each), + // but the per-IP bucket (20/min) does. + for ($i = 0; $i < 20; $i++) { + Livewire::test(Login::class) + ->set('email', "flood{$i}@clusev.local")->set('password', 'wrong')->call('authenticate'); + } + + // A correct login for the real admin from the same IP is now blocked by the per-IP cap. + Livewire::test(Login::class) + ->set('email', 'admin@clusev.local')->set('password', self::PW)->call('authenticate') + ->assertHasErrors('email'); + + $this->assertGuest(); + } + + public function test_unknown_and_known_wrong_email_are_rejected_identically(): void + { + User::factory()->create(['email' => 'admin@clusev.local', 'password' => Hash::make(self::PW)]); + + // Both a known-wrong-password and a non-existent address fail on the SAME field with the + // SAME generic auth.invalid_credentials message (by construction), and neither logs in — + // no account-existence signal in the response. (The matching bcrypt cost on the unknown + // branch, via DUMMY_HASH, closes the timing oracle; that is not asserted here because the + // test env uses BCRYPT_ROUNDS=4 while DUMMY_HASH is cost-12.) + foreach (['admin@clusev.local', 'ghost@clusev.local'] as $email) { + Livewire::test(Login::class) + ->set('email', $email)->set('password', 'wrong')->call('authenticate') + ->assertHasErrors('email'); + } + + $this->assertGuest(); + } + + public function test_password_reauth_is_throttled_against_a_hijacked_session(): void + { + $user = User::factory()->create([ + 'password' => Hash::make(self::PW), + 'must_change_password' => true, + ]); + $this->actingAs($user); + + for ($i = 0; $i < 5; $i++) { + Livewire::test(PasswordChange::class) + ->set('current', 'wrong')->set('password', self::NEW_PW)->set('password_confirmation', self::NEW_PW) + ->call('update'); + } + + // 6th try with the CORRECT current password is still refused (reauth bucket tripped). + Livewire::test(PasswordChange::class) + ->set('current', self::PW)->set('password', self::NEW_PW)->set('password_confirmation', self::NEW_PW) + ->call('update') + ->assertHasErrors('current'); + + // Password unchanged. + $this->assertTrue(Hash::check(self::PW, $user->fresh()->password)); + } +}