canonical($ip); // fold IPv4-mapped IPv6 so exemption matches the canonical form foreach (array_merge(self::LOOPBACK, $this->whitelist()) as $cidr) { if ($this->matchesCidr($ip, $cidr)) { return true; } } return false; } public function record(?string $ip, string $reason): void { if (! $this->enabled() || ! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) { return; } $canonical = $this->canonical($ip); $key = 'bruteforce:'.md5($canonical); $hits = RateLimiter::hit($key, $this->findtime() * 60); // minutes → seconds; hit() returns the count if ($hits < $this->maxretry()) { return; } // Ban + audit at most once per IP per window even if two requests cross the threshold // concurrently (the upsert is idempotent; this also de-dupes the audit row). if (! Cache::add('bruteforce:banning:'.$canonical, true, 60)) { return; } // Atomic upsert keyed on the unique ip index. Eloquent upsert needs explicit timestamps // for the bulk path; a single $now keeps created_at/updated_at identical. $now = now(); BannedIp::upsert( [[ 'ip' => $canonical, 'banned_until' => $now->copy()->addMinutes($this->bantime()), 'reason' => $reason, 'attempts' => $hits, 'created_at' => $now, 'updated_at' => $now, ]], ['ip'], ['banned_until', 'reason', 'attempts', 'updated_at'], ); RateLimiter::clear($key); Cache::forget('bruteforce:banned:'.$canonical); AuditEvent::create([ 'user_id' => null, 'actor' => 'system', 'action' => 'auth.ip_banned', 'target' => $canonical, 'ip' => $canonical, 'meta' => ['reason' => $reason, 'attempts' => $hits], ]); } /** * Ban an IP on the FIRST hit — no maxretry threshold, no RateLimiter window. Used by the * honeypot deception layer: a request to a decoy path (or a leaked honeytoken) is proof of * hostile intent, so there is nothing to count. Mirrors the ban write in record() minus the * throttling. Deliberately DOES NOT check enabled(): the honeypot flag already gates whether the * trap/detector run, so a honeypot hit must ban regardless of the brute-force login toggle (a * disabled brute-force protection must not silently disable honeypot bans). Returns true when a * ban was written, false when it no-op'd (invalid / exempt IP) — the caller stays a cheap no-op * for legitimate traffic. Reuses the same private helpers as record() (canonical/isExempt/bantime/maxretry). */ public function banNow(?string $ip, string $reason): bool { if (! $ip || filter_var($ip, FILTER_VALIDATE_IP) === false || $this->isExempt($ip)) { return false; } $canonical = $this->canonical($ip); $now = now(); BannedIp::upsert( [[ 'ip' => $canonical, 'banned_until' => $now->copy()->addMinutes($this->bantime()), 'reason' => $reason, 'attempts' => $this->maxretry(), 'created_at' => $now, 'updated_at' => $now, ]], ['ip'], ['banned_until', 'reason', 'attempts', 'updated_at'], ); Cache::forget('bruteforce:banned:'.$canonical); // Dedup the ban-audit: the honeypot calls banNow on EVERY decoy POST, so without this a prober // looping the fake login would write one auth.ip_banned row per request. Emit the audit at most // once per IP+reason per 60s (Cache::add is atomic); the upsert above stays idempotent. The key // includes $reason so a DISTINCT-reason ban of the same IP (e.g. a honeytoken trip after a // honeypot ban) is still audited. A short 60s TTL (matching record()'s ban dedup) collapses the // rapid scanner flood WITHOUT masking a legitimate later re-ban — e.g. after an operator unban, // which is minutes of human time later, well past this window (so it is not tied to unban()). $auditKey = 'bruteforce:banaudit:'.$canonical.':'.$reason; if (Cache::add($auditKey, 1, 60)) { try { AuditEvent::create([ 'user_id' => null, 'actor' => 'system', 'action' => 'auth.ip_banned', 'target' => $canonical, 'ip' => $canonical, 'meta' => ['reason' => $reason], ]); } catch (\Throwable $e) { // The Cache::add above atomically CLAIMED the dedup slot before the write. If the // write throws (a transient DB blip), release the slot so the 60s window is not // burned with no audit row — the next attempt re-claims and re-audits this ban. Cache::forget($auditKey); throw $e; } } return true; } /** * Whether $ip has an active ban. Does NOT check enabled() — the BlockBannedIp middleware * gates on enabled() before calling this, so disabling the feature stops enforcement while * existing rows persist and resume if it is re-enabled (spec §7). */ public function isBanned(string $ip): bool { if ($this->isExempt($ip)) { return false; // exempt short-circuits — whitelisting unblocks immediately } $canonical = $this->canonical($ip); return Cache::remember('bruteforce:banned:'.$canonical, self::CHECK_TTL, fn () => BannedIp::query() ->where('ip', $canonical) ->where('banned_until', '>', now()) ->exists()); } public function unban(string $ip): void { $canonical = $this->canonical($ip); BannedIp::query()->where('ip', $canonical)->delete(); Cache::forget('bruteforce:banned:'.$canonical); } /** Delete every ban (active or expired) and bust each cache key. Returns the count removed. */ public function unbanAll(): int { $ips = BannedIp::query()->pluck('ip'); foreach ($ips as $ip) { Cache::forget('bruteforce:banned:'.$this->canonical($ip)); } BannedIp::query()->delete(); return $ips->count(); } /** inet_pton-based CIDR / single-IP match (IPv4, IPv6, IPv4-mapped). */ public function matchesCidr(string $ip, string $cidr): bool { if (! str_contains($cidr, '/')) { $a = @inet_pton($ip); $b = @inet_pton($cidr); return $a !== false && $b !== false && $a === $b; } [$subnet, $maskStr] = explode('/', $cidr, 2); $pip = @inet_pton($ip); $psub = @inet_pton($subnet); if ($pip === false || $psub === false || strlen($pip) !== strlen($psub) || ! ctype_digit($maskStr)) { return false; // v4-vs-v6 mismatch or junk mask } $mask = (int) $maskStr; if ($mask > strlen($pip) * 8) { return false; // mask wider than the address family (e.g. IPv4 /33) } $bytes = intdiv($mask, 8); $rem = $mask % 8; if ($bytes > 0 && substr($pip, 0, $bytes) !== substr($psub, 0, $bytes)) { return false; } if ($rem === 0) { return true; } $b = 0xFF & (0xFF << (8 - $rem)); return (ord($pip[$bytes]) & $b) === (ord($psub[$bytes]) & $b); } private function canonical(string $ip): string { $packed = @inet_pton($ip); if ($packed === false) { return $ip; } // Fold an IPv4-mapped IPv6 (::ffff:a.b.c.d) down to plain IPv4 so the same client can't // dodge a ban by switching between the two forms (one ban row + one cache key per IP). if (strlen($packed) === 16 && str_starts_with($packed, "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff")) { $packed = substr($packed, 12); } return (string) inet_ntop($packed); } }