fix(security): harden brute-force, rate-limiting and auth-DoS (audit follow-up)

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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-17 17:59:45 +02:00
parent 62158eecdc
commit f2cbf60c04
14 changed files with 383 additions and 45 deletions

View File

@ -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

View File

@ -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),

View File

@ -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()) {

View File

@ -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),

View File

@ -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<string, array{0:int,1:int}> 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']);

View File

@ -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 {

View File

@ -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');

View File

@ -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;
}

View File

@ -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,

View File

@ -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);

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* TOTP replay protection: the time-step of the last accepted authenticator code.
* verifyKeyNewer() rejects any code from a step <= this, so a single intercepted
* 6-digit code can't be reused within its 30s validity window.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -0,0 +1,127 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\Login;
use App\Livewire\Auth\PasswordChange;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Livewire\Livewire;
use PragmaRX\Google2FAQRCode\Google2FA;
use Tests\TestCase;
/**
* Brute-force / rate-limit hardening (security audit follow-up).
*
* Each test starts with a cleared cache so the array-store RateLimiter buckets (keyed partly
* on the fixed test IP) don't leak between tests. The "still blocked with the CORRECT password"
* assertions are the point: a working limiter must refuse even valid credentials once tripped,
* which a non-working one would let straight through.
*/
class BruteForceHardeningTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
private const PW = 'Sup3rStr0ngPass!';
private const NEW_PW = 'An0therStr0ngPass!';
public function test_totp_code_cannot_be_replayed(): void
{
$user = User::factory()->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));
}
}