|
|
@@ -9,24 +9,32 @@ use Psr\Log\LoggerInterface;
|
|
|
/**
|
|
|
* Brute-force lockout for the local-admin sign-in (SPEC §M14.2).
|
|
|
*
|
|
|
- * Attempts are bucketed by `(username, source_ip)` so an attacker who is
|
|
|
- * spraying one username from one IP can't lock out a legitimate admin
|
|
|
- * coming in from a different IP. Storage is a per-process in-memory array;
|
|
|
- * the singleton is constructed once per FrankenPHP worker and lives for
|
|
|
- * the worker's lifetime. A container restart clears all entries — this is
|
|
|
- * intentional and documented as the operator's "unlock the admin" path.
|
|
|
+ * Two parallel buckets are evaluated on every attempt:
|
|
|
*
|
|
|
- * Failure progression (clamped — the brief's "1/5/30" steps):
|
|
|
- * 1–4 failures fast retry, no lockout
|
|
|
- * 5 failures lock for 60 s
|
|
|
- * 10 failures lock for 300 s
|
|
|
- * 15+ failures lock for 1800 s
|
|
|
+ * - per-(username, ip): catches single-source spray. Looser triggers so a
|
|
|
+ * legit admin retrying from one IP isn't locked out by a few typos.
|
|
|
+ * Failure ladder: 5 / 10 / 15 → 60 s / 300 s / 1800 s.
|
|
|
*
|
|
|
- * Successful login clears the bucket.
|
|
|
+ * - per-(username): catches a distributed spray (residential proxy pool,
|
|
|
+ * botnet) where each new IP would otherwise be a fresh per-IP bucket.
|
|
|
+ * Tighter ladder so the global bucket trips before the attacker exhausts
|
|
|
+ * a meaningful Argon2id budget, but loose enough that an attacker can't
|
|
|
+ * trivially hold the legit admin out indefinitely.
|
|
|
+ * Failure ladder: 25 / 50 / 100 → 60 s / 300 s / 1800 s.
|
|
|
*
|
|
|
- * Multi-replica caveat: each replica has its own counter. A determined
|
|
|
+ * `isLocked()` returns true if either bucket is locked; `lockoutSeconds…`
|
|
|
+ * returns the larger of the two remaining windows so the user-facing
|
|
|
+ * message reflects the worst-case wait. `recordFailure()` increments both,
|
|
|
+ * `clear()` (called on success) resets both.
|
|
|
+ *
|
|
|
+ * Storage is a per-process in-memory array; the singleton is constructed
|
|
|
+ * once per FrankenPHP worker and lives for the worker's lifetime. A
|
|
|
+ * container restart clears all entries — this is intentional and
|
|
|
+ * documented as the operator's "unlock the admin" path.
|
|
|
+ *
|
|
|
+ * Multi-replica caveat: each replica has its own counters. A determined
|
|
|
* attacker who can reach multiple replicas through a non-sticky LB would
|
|
|
- * see the 5-fail step at 5*N total attempts. Single-replica is the
|
|
|
+ * see the per-username step at 25*N total attempts. Single-replica is the
|
|
|
* documented topology for the ui container; if you scale, put the LB in
|
|
|
* sticky mode.
|
|
|
*/
|
|
|
@@ -37,6 +45,11 @@ final class LoginThrottle
|
|
|
*/
|
|
|
private array $entries = [];
|
|
|
|
|
|
+ /**
|
|
|
+ * @var array<string, array{count: int, lockedUntil: int}>
|
|
|
+ */
|
|
|
+ private array $usernameEntries = [];
|
|
|
+
|
|
|
/** @var \Closure(): int */
|
|
|
private \Closure $timeFn;
|
|
|
|
|
|
@@ -54,47 +67,61 @@ final class LoginThrottle
|
|
|
|
|
|
public function lockoutSecondsRemaining(string $username, string $ip): int
|
|
|
{
|
|
|
- $key = self::key($username, $ip);
|
|
|
- $entry = $this->entries[$key] ?? null;
|
|
|
- if ($entry === null) {
|
|
|
- return 0;
|
|
|
- }
|
|
|
$now = ($this->timeFn)();
|
|
|
- if ($entry['lockedUntil'] <= $now) {
|
|
|
- return 0;
|
|
|
- }
|
|
|
+ $perIp = $this->remaining($this->entries, self::ipKey($username, $ip), $now);
|
|
|
+ $perUser = $this->remaining($this->usernameEntries, self::userKey($username), $now);
|
|
|
|
|
|
- return $entry['lockedUntil'] - $now;
|
|
|
+ return max($perIp, $perUser);
|
|
|
}
|
|
|
|
|
|
public function recordFailure(string $username, string $ip): void
|
|
|
{
|
|
|
- $key = self::key($username, $ip);
|
|
|
$now = ($this->timeFn)();
|
|
|
- $entry = $this->entries[$key] ?? ['count' => 0, 'lockedUntil' => 0];
|
|
|
- $entry['count']++;
|
|
|
- $lockSeconds = self::lockoutSecondsForAttempt($entry['count']);
|
|
|
- if ($lockSeconds > 0) {
|
|
|
- $entry['lockedUntil'] = $now + $lockSeconds;
|
|
|
+
|
|
|
+ $ipKey = self::ipKey($username, $ip);
|
|
|
+ $ipEntry = $this->entries[$ipKey] ?? ['count' => 0, 'lockedUntil' => 0];
|
|
|
+ $ipEntry['count']++;
|
|
|
+ $ipLock = self::ipLockoutSecondsForAttempt($ipEntry['count']);
|
|
|
+ if ($ipLock > 0) {
|
|
|
+ $ipEntry['lockedUntil'] = $now + $ipLock;
|
|
|
$this->logger->error('local login lockout triggered', [
|
|
|
+ 'bucket' => 'ip',
|
|
|
'username' => $username,
|
|
|
'source_ip' => $ip,
|
|
|
- 'failure_count' => $entry['count'],
|
|
|
- 'lock_seconds' => $lockSeconds,
|
|
|
+ 'failure_count' => $ipEntry['count'],
|
|
|
+ 'lock_seconds' => $ipLock,
|
|
|
]);
|
|
|
} else {
|
|
|
$this->logger->warning('local login failure', [
|
|
|
+ 'bucket' => 'ip',
|
|
|
'username' => $username,
|
|
|
'source_ip' => $ip,
|
|
|
- 'failure_count' => $entry['count'],
|
|
|
+ 'failure_count' => $ipEntry['count'],
|
|
|
]);
|
|
|
}
|
|
|
- $this->entries[$key] = $entry;
|
|
|
+ $this->entries[$ipKey] = $ipEntry;
|
|
|
+
|
|
|
+ $userKey = self::userKey($username);
|
|
|
+ $userEntry = $this->usernameEntries[$userKey] ?? ['count' => 0, 'lockedUntil' => 0];
|
|
|
+ $userEntry['count']++;
|
|
|
+ $userLock = self::usernameLockoutSecondsForAttempt($userEntry['count']);
|
|
|
+ if ($userLock > 0) {
|
|
|
+ $userEntry['lockedUntil'] = $now + $userLock;
|
|
|
+ $this->logger->error('local login lockout triggered', [
|
|
|
+ 'bucket' => 'username',
|
|
|
+ 'username' => $username,
|
|
|
+ 'source_ip' => $ip,
|
|
|
+ 'failure_count' => $userEntry['count'],
|
|
|
+ 'lock_seconds' => $userLock,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ $this->usernameEntries[$userKey] = $userEntry;
|
|
|
}
|
|
|
|
|
|
public function clear(string $username, string $ip): void
|
|
|
{
|
|
|
- unset($this->entries[self::key($username, $ip)]);
|
|
|
+ unset($this->entries[self::ipKey($username, $ip)]);
|
|
|
+ unset($this->usernameEntries[self::userKey($username)]);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -104,16 +131,26 @@ final class LoginThrottle
|
|
|
public function reset(): void
|
|
|
{
|
|
|
$this->entries = [];
|
|
|
+ $this->usernameEntries = [];
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Lockout duration in seconds for the given failure count, or 0 if
|
|
|
- * no lockout should fire yet. The early-returns descend from highest
|
|
|
- * threshold to lowest so each step kicks in at exactly the documented
|
|
|
- * count; PHPStan flags `&& $count < 15` as redundant after the first
|
|
|
- * check, so we rely on the early-return ordering instead.
|
|
|
+ * @param array<string, array{count: int, lockedUntil: int}> $bucket
|
|
|
*/
|
|
|
- private static function lockoutSecondsForAttempt(int $count): int
|
|
|
+ private function remaining(array $bucket, string $key, int $now): int
|
|
|
+ {
|
|
|
+ $entry = $bucket[$key] ?? null;
|
|
|
+ if ($entry === null || $entry['lockedUntil'] <= $now) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $entry['lockedUntil'] - $now;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Per-IP lockout duration; 0 means no lockout at this count yet.
|
|
|
+ */
|
|
|
+ private static function ipLockoutSecondsForAttempt(int $count): int
|
|
|
{
|
|
|
if ($count >= 15) {
|
|
|
return 1800;
|
|
|
@@ -128,11 +165,36 @@ final class LoginThrottle
|
|
|
return 0;
|
|
|
}
|
|
|
|
|
|
- private static function key(string $username, string $ip): string
|
|
|
+ /**
|
|
|
+ * Per-username (cross-IP) lockout duration; thresholds are higher than
|
|
|
+ * per-IP because this bucket is global and a too-tight ladder would let
|
|
|
+ * an attacker DoS the legit admin trivially.
|
|
|
+ */
|
|
|
+ private static function usernameLockoutSecondsForAttempt(int $count): int
|
|
|
+ {
|
|
|
+ if ($count >= 100) {
|
|
|
+ return 1800;
|
|
|
+ }
|
|
|
+ if ($count >= 50) {
|
|
|
+ return 300;
|
|
|
+ }
|
|
|
+ if ($count >= 25) {
|
|
|
+ return 60;
|
|
|
+ }
|
|
|
+
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static function ipKey(string $username, string $ip): string
|
|
|
{
|
|
|
// Lower-case the username so casing doesn't multiply buckets, but
|
|
|
// keep the IP byte-for-byte (v6 is case-sensitive in canonical form
|
|
|
// already).
|
|
|
return strtolower($username) . '|' . $ip;
|
|
|
}
|
|
|
+
|
|
|
+ private static function userKey(string $username): string
|
|
|
+ {
|
|
|
+ return strtolower($username);
|
|
|
+ }
|
|
|
}
|