- OTP attempt limits, constant-time compare, random reference codes - DB-backed rate limits (429) on sign-in, OTP, reset, register, onboarding - one generic sign-in failure message; reset request no longer reveals accounts - no password kept in the session; real status codes on failures
100 lines
4.1 KiB
PHP
100 lines
4.1 KiB
PHP
<?php
|
|
/**
|
|
* rate_limit.php — DB-backed request throttle for the unauthenticated login,
|
|
* OTP and registration endpoints.
|
|
*
|
|
* Counters live in wms.auth_throttle (created by setup.php), not in the PHP
|
|
* session: an attacker simply drops the session cookie to reset a session
|
|
* counter. Each (bucket, key) pair counts hits in a fixed window; keys are
|
|
* stored as SHA-256 hashes so the table never holds raw IPs or emails.
|
|
*
|
|
* The helper fails open: if the table is missing or the query fails, the error
|
|
* is logged and the request is allowed, so a schema problem can never lock
|
|
* every user out of the login page.
|
|
*
|
|
* Usage:
|
|
* require_once '../../../assets/utils/rate_limit.php';
|
|
* rate_limit_guard($pdo1, [
|
|
* ['login_ip', rate_limit_client_ip(), 20, 900],
|
|
* ['login_user', $username, 10, 900],
|
|
* ]);
|
|
*/
|
|
|
|
if (!function_exists('rate_limit_client_ip')) {
|
|
/**
|
|
* The client address as Apache sees it. X-Forwarded-For is deliberately not
|
|
* trusted here: any client can send it, which would let them pick a fresh
|
|
* key for every request.
|
|
*/
|
|
function rate_limit_client_ip(): string {
|
|
return (string)($_SERVER['REMOTE_ADDR'] ?? '');
|
|
}
|
|
}
|
|
|
|
if (!function_exists('rate_limit_hit')) {
|
|
/**
|
|
* Count one hit for ($bucket, $key) and report whether the limit is exceeded.
|
|
*
|
|
* @param PDO $pdo Connection to the wms (auth) database.
|
|
* @param string $bucket Endpoint/purpose name, e.g. 'login_ip'.
|
|
* @param string $key Raw key (IP, normalised username/email, user id).
|
|
* @param int $max Hits allowed per window.
|
|
* @param int $window_seconds Window length in seconds.
|
|
* @return bool true when this hit is over the limit.
|
|
*/
|
|
function rate_limit_hit(PDO $pdo, string $bucket, string $key, int $max, int $window_seconds): bool {
|
|
if ($key === '') return false;
|
|
$key_hash = hash('sha256', $bucket . '|' . $key);
|
|
try {
|
|
// One statement per hit: a new row starts at 1; an existing row either
|
|
// restarts its window (expired) or counts up. MySQL applies the SET
|
|
// list left to right, so `hits` still sees the old window_start.
|
|
$pdo->prepare(
|
|
"INSERT INTO auth_throttle (bucket, key_hash, window_start, hits)
|
|
VALUES (:b, :k, NOW(), 1)
|
|
ON DUPLICATE KEY UPDATE
|
|
hits = IF(window_start < NOW() - INTERVAL :w1 SECOND, 1, hits + 1),
|
|
window_start = IF(window_start < NOW() - INTERVAL :w2 SECOND, NOW(), window_start)"
|
|
)->execute([':b' => $bucket, ':k' => $key_hash, ':w1' => $window_seconds, ':w2' => $window_seconds]);
|
|
|
|
$sth = $pdo->prepare("SELECT hits FROM auth_throttle WHERE bucket = :b AND key_hash = :k");
|
|
$sth->execute([':b' => $bucket, ':k' => $key_hash]);
|
|
return (int)$sth->fetchColumn() > $max;
|
|
} catch (Throwable $e) {
|
|
error_log('[rate_limit] throttle check skipped (' . $bucket . '): ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!function_exists('rate_limit_guard')) {
|
|
/**
|
|
* Count every check and stop the request with HTTP 429 if any is over its
|
|
* limit. Each check is [bucket, key, max, window_seconds].
|
|
*/
|
|
function rate_limit_guard(PDO $pdo, array $checks): void {
|
|
$limited = false;
|
|
foreach ($checks as [$bucket, $key, $max, $window]) {
|
|
if (rate_limit_hit($pdo, $bucket, (string)$key, (int)$max, (int)$window)) {
|
|
$limited = true;
|
|
}
|
|
}
|
|
if ($limited) {
|
|
rate_limit_reject();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!function_exists('rate_limit_reject')) {
|
|
/** Answer 429 with the same generic message everywhere and stop. */
|
|
function rate_limit_reject(): void {
|
|
http_response_code(429);
|
|
header('Retry-After: 300');
|
|
exit(json_encode([
|
|
'success' => 0,
|
|
'message' => 'Too many requests. Please wait a few minutes and try again.',
|
|
'code' => 'rate_limited',
|
|
]));
|
|
}
|
|
}
|