|
|
@@ -27,10 +27,13 @@ use Psr\Log\LoggerInterface;
|
|
|
* 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.
|
|
|
+ * Storage is delegated to a {@see ThrottleStore}. In production we use
|
|
|
+ * {@see FileThrottleStore}, which serialises read-modify-write across all
|
|
|
+ * FrankenPHP workers via `flock()` on a sibling lock file. That fixes
|
|
|
+ * SEC_REVIEW F6: a worker recycle no longer wipes counters and parallel
|
|
|
+ * workers no longer multiply the allowed attempts. The file lives on the
|
|
|
+ * container's writable layer (no shared volume), so a container restart
|
|
|
+ * still clears it — the documented "operator unlocks 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
|
|
|
@@ -40,15 +43,7 @@ use Psr\Log\LoggerInterface;
|
|
|
*/
|
|
|
final class LoginThrottle
|
|
|
{
|
|
|
- /**
|
|
|
- * @var array<string, array{count: int, lockedUntil: int}>
|
|
|
- */
|
|
|
- private array $entries = [];
|
|
|
-
|
|
|
- /**
|
|
|
- * @var array<string, array{count: int, lockedUntil: int}>
|
|
|
- */
|
|
|
- private array $usernameEntries = [];
|
|
|
+ private readonly ThrottleStore $store;
|
|
|
|
|
|
/** @var \Closure(): int */
|
|
|
private \Closure $timeFn;
|
|
|
@@ -56,8 +51,10 @@ final class LoginThrottle
|
|
|
public function __construct(
|
|
|
private readonly LoggerInterface $logger,
|
|
|
?\Closure $timeFn = null,
|
|
|
+ ?ThrottleStore $store = null,
|
|
|
) {
|
|
|
$this->timeFn = $timeFn ?? static fn (): int => time();
|
|
|
+ $this->store = $store ?? new InMemoryThrottleStore();
|
|
|
}
|
|
|
|
|
|
public function isLocked(string $username, string $ip): bool
|
|
|
@@ -68,8 +65,9 @@ final class LoginThrottle
|
|
|
public function lockoutSecondsRemaining(string $username, string $ip): int
|
|
|
{
|
|
|
$now = ($this->timeFn)();
|
|
|
- $perIp = $this->remaining($this->entries, self::ipKey($username, $ip), $now);
|
|
|
- $perUser = $this->remaining($this->usernameEntries, self::userKey($username), $now);
|
|
|
+ $state = $this->store->load();
|
|
|
+ $perIp = $this->remaining($state['ip'], self::ipKey($username, $ip), $now);
|
|
|
+ $perUser = $this->remaining($state['username'], self::userKey($username), $now);
|
|
|
|
|
|
return max($perIp, $perUser);
|
|
|
}
|
|
|
@@ -77,51 +75,90 @@ final class LoginThrottle
|
|
|
public function recordFailure(string $username, string $ip): void
|
|
|
{
|
|
|
$now = ($this->timeFn)();
|
|
|
-
|
|
|
$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' => $ipEntry['count'],
|
|
|
- 'lock_seconds' => $ipLock,
|
|
|
- ]);
|
|
|
- } else {
|
|
|
- $this->logger->warning('local login failure', [
|
|
|
- 'bucket' => 'ip',
|
|
|
- 'username' => $username,
|
|
|
- 'source_ip' => $ip,
|
|
|
- 'failure_count' => $ipEntry['count'],
|
|
|
- ]);
|
|
|
- }
|
|
|
- $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,
|
|
|
- ]);
|
|
|
+
|
|
|
+ /** @var list<array{level: string, message: string, context: array<string, mixed>}> $logEvents */
|
|
|
+ $logEvents = [];
|
|
|
+
|
|
|
+ $this->store->mutate(static function (array $state) use (
|
|
|
+ $now,
|
|
|
+ $ipKey,
|
|
|
+ $userKey,
|
|
|
+ $username,
|
|
|
+ $ip,
|
|
|
+ &$logEvents,
|
|
|
+ ): array {
|
|
|
+ $ipEntry = $state['ip'][$ipKey] ?? ['count' => 0, 'lockedUntil' => 0];
|
|
|
+ $ipEntry['count']++;
|
|
|
+ $ipLock = self::ipLockoutSecondsForAttempt($ipEntry['count']);
|
|
|
+ if ($ipLock > 0) {
|
|
|
+ $ipEntry['lockedUntil'] = $now + $ipLock;
|
|
|
+ $logEvents[] = [
|
|
|
+ 'level' => 'error',
|
|
|
+ 'message' => 'local login lockout triggered',
|
|
|
+ 'context' => [
|
|
|
+ 'bucket' => 'ip',
|
|
|
+ 'username' => $username,
|
|
|
+ 'source_ip' => $ip,
|
|
|
+ 'failure_count' => $ipEntry['count'],
|
|
|
+ 'lock_seconds' => $ipLock,
|
|
|
+ ],
|
|
|
+ ];
|
|
|
+ } else {
|
|
|
+ $logEvents[] = [
|
|
|
+ 'level' => 'warning',
|
|
|
+ 'message' => 'local login failure',
|
|
|
+ 'context' => [
|
|
|
+ 'bucket' => 'ip',
|
|
|
+ 'username' => $username,
|
|
|
+ 'source_ip' => $ip,
|
|
|
+ 'failure_count' => $ipEntry['count'],
|
|
|
+ ],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ $state['ip'][$ipKey] = $ipEntry;
|
|
|
+
|
|
|
+ $userEntry = $state['username'][$userKey] ?? ['count' => 0, 'lockedUntil' => 0];
|
|
|
+ $userEntry['count']++;
|
|
|
+ $userLock = self::usernameLockoutSecondsForAttempt($userEntry['count']);
|
|
|
+ if ($userLock > 0) {
|
|
|
+ $userEntry['lockedUntil'] = $now + $userLock;
|
|
|
+ $logEvents[] = [
|
|
|
+ 'level' => 'error',
|
|
|
+ 'message' => 'local login lockout triggered',
|
|
|
+ 'context' => [
|
|
|
+ 'bucket' => 'username',
|
|
|
+ 'username' => $username,
|
|
|
+ 'source_ip' => $ip,
|
|
|
+ 'failure_count' => $userEntry['count'],
|
|
|
+ 'lock_seconds' => $userLock,
|
|
|
+ ],
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ $state['username'][$userKey] = $userEntry;
|
|
|
+
|
|
|
+ return $state;
|
|
|
+ });
|
|
|
+
|
|
|
+ foreach ($logEvents as $ev) {
|
|
|
+ if ($ev['level'] === 'error') {
|
|
|
+ $this->logger->error($ev['message'], $ev['context']);
|
|
|
+ } else {
|
|
|
+ $this->logger->warning($ev['message'], $ev['context']);
|
|
|
+ }
|
|
|
}
|
|
|
- $this->usernameEntries[$userKey] = $userEntry;
|
|
|
}
|
|
|
|
|
|
public function clear(string $username, string $ip): void
|
|
|
{
|
|
|
- unset($this->entries[self::ipKey($username, $ip)]);
|
|
|
- unset($this->usernameEntries[self::userKey($username)]);
|
|
|
+ $ipKey = self::ipKey($username, $ip);
|
|
|
+ $userKey = self::userKey($username);
|
|
|
+ $this->store->mutate(static function (array $state) use ($ipKey, $userKey): array {
|
|
|
+ unset($state['ip'][$ipKey], $state['username'][$userKey]);
|
|
|
+
|
|
|
+ return $state;
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -130,8 +167,7 @@ final class LoginThrottle
|
|
|
*/
|
|
|
public function reset(): void
|
|
|
{
|
|
|
- $this->entries = [];
|
|
|
- $this->usernameEntries = [];
|
|
|
+ $this->store->reset();
|
|
|
}
|
|
|
|
|
|
/**
|