Files
feuerwerk/api/lib/LoginThrottle.php
T
adminandClaude Opus 5.5 ce1f25f2eb
feuerwerk / gate (push) Successful in 5m12s
feuerwerk / suite (push) Canceled after 4m52s
Prune old sign-in failures by their current time (#427)
The CI gate of cfd6d7a was red in limits.test.php: "pruning drops an
address whose failures are all old". PHP 8.2 in the CI image still
answered the modification time it had cached before the test moved it
back, PHP 8.5 on stein2 did not.

- LoginThrottle::prune() clears the stat cache before it reads times.

Tests: limits.test.php in feuerwerk-ci (PHP 8.2.34) 21/21, red before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:12:10 +02:00

123 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
/**
* The brake on guessing passwords (#427): five failed sign-ins from one address within 15 minutes,
* and that address is refused for 15 minutes after the last of them. The right password is refused
* as well while it lasts, or guessing would only have to wait for its hit. A success does not wipe
* the count either: with an account of one's own, that would be the way to reset it.
*
* `{dir}/{sha256(address)}.json` holds the times of the recent failures, written 0640 like
* everything else that is private. The address is the one nginx found behind the Brandmauer.
*/
final class LoginThrottle
{
public const MAX_FAILURES = 5;
public const WINDOW_SECONDS = 15 * 60;
/** @param (Closure():int)|null $clock the tests' way to move time on */
public function __construct(
private readonly string $dir,
private readonly ?Closure $clock = null,
) {}
/**
* Seconds until another attempt counts, 0 when one may be made now.
*
* @param list<int> $failures when the recent failures were
*/
public static function wait(array $failures, int $now): int
{
$times = self::recent($failures, $now);
sort($times);
$until = 0;
for ($i = self::MAX_FAILURES - 1; $i < count($times); $i++) {
if ($times[$i] - $times[$i - self::MAX_FAILURES + 1] < self::WINDOW_SECONDS) {
$until = $times[$i] + self::WINDOW_SECONDS;
}
}
return max(0, $until - $now);
}
/** Seconds until `$address` may try again, 0 when it may now. */
public function waitFor(string $address): int
{
$path = $this->path($address);
$raw = is_file($path) ? file_get_contents($path) : false;
return self::wait($raw === false ? [] : self::decode($raw), $this->now());
}
/** Counts a failed attempt of `$address` and answers the wait that follows from it. */
public function fail(string $address): int
{
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException('Cannot create the directory of failed sign-ins');
}
$path = $this->path($address);
$handle = fopen($path, 'c+');
if ($handle === false) {
throw new RuntimeException('Cannot count a failed sign-in');
}
try {
chmod($path, AtomicFile::HIDDEN);
flock($handle, LOCK_EX);
$now = $this->now();
$failures = self::recent(self::decode((string) stream_get_contents($handle)), $now);
$failures[] = $now;
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, (string) json_encode(['failures' => $failures]));
fflush($handle);
return self::wait($failures, $now);
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
/** Removes the files of addresses whose failures are all too old to matter. */
public function prune(): int
{
// PHP keeps what it learned of a file for the rest of the request; a time read earlier
// would keep a file that has gone stale since, or drop one written since.
clearstatcache();
$removed = 0;
foreach (glob($this->dir . '/*.json') ?: [] as $path) {
if ((int) filemtime($path) < $this->now() - 2 * self::WINDOW_SECONDS && unlink($path)) {
$removed++;
}
}
return $removed;
}
/**
* The failures that can still matter: five of them lie within one window, and the lock lasts one
* window after the last, so nothing older than two windows can.
*
* @param list<int> $failures
* @return list<int>
*/
private static function recent(array $failures, int $now): array
{
return array_values(array_filter($failures, static fn(int $at): bool => $at > $now - 2 * self::WINDOW_SECONDS));
}
/** @return list<int> */
private static function decode(string $raw): array
{
$data = json_decode($raw, true);
$failures = is_array($data) && is_array($data['failures'] ?? null) ? $data['failures'] : [];
return array_values(array_map('intval', array_filter($failures, 'is_int')));
}
private function path(string $address): string
{
return $this->dir . '/' . hash('sha256', $address) . '.json';
}
private function now(): int
{
return $this->clock === null ? time() : ($this->clock)();
}
}