Files
Thanakorn 73c680e844 Harden sign-in and password reset
- 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
2026-09-24 14:53:40 +07:00

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