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], ]); } /** * 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); } }