Phase 2: Add rate limiting and session regeneration

- Created RateLimitMiddleware class with 8 public methods
  * isLimited() - check if limit exceeded
  * incrementAttempt() - increment attempt counter
  * getRemainingAttempts() - get remaining attempts
  * getTimeRemaining() - get time remaining in window
  * reset() - reset counter after success
  * requireLimit() - check and die if exceeded
  * getStatus() - get status info for monitoring
  * Support for time-window based rate limiting

- Integrated rate limiting into critical endpoints:
  * validate_login.php: 5 attempts per 900 seconds (15 minutes)
  * send_reset_link.php: 3 attempts per 1800 seconds (30 minutes)
  * Prevents brute force attacks and password reset abuse
  * Still increments counter for non-existent emails (prevents enumeration)

- Integrated session regeneration on successful login:
  * Google OAuth login (both new and existing users)
  * Email/password login
  * Uses AuthenticationService::regenerateSession()
  * Prevents session fixation attacks

- Rate limit counters stored in PHP session
- Time-window based with 15-minute and 30-minute windows
- Graceful error messages with retry_after in JSON responses
- AJAX-aware error handling
This commit is contained in:
twotalesanimation
2025-12-02 21:10:48 +02:00
parent a311e81a12
commit a4526979c4
3 changed files with 334 additions and 3 deletions

View File

@@ -0,0 +1,284 @@
<?php
namespace Middleware;
use Services\DatabaseService;
/**
* Rate Limiting Middleware
*
* Provides rate limiting for sensitive endpoints like login, password reset,
* and API endpoints. Uses session-based counters with time windows.
*
* Features:
* - Time-window based rate limiting (e.g., 5 attempts per 15 minutes)
* - IP-based and user-based tracking
* - Graceful degradation if storage unavailable
* - Configurable limits per endpoint
*
* Usage:
* RateLimitMiddleware::checkLimit('login', 5, 900); // 5 attempts per 15 mins
* RateLimitMiddleware::incrementAttempt('login');
* RateLimitMiddleware::reset('login'); // After successful attempt
*/
class RateLimitMiddleware
{
/**
* Session key prefix for rate limiting counters
*/
private const RATE_LIMIT_PREFIX = '_rate_limit_';
/**
* Session key for timestamp tracking
*/
private const RATE_LIMIT_TIME_PREFIX = '_rate_limit_time_';
/**
* Check if client has exceeded rate limit
*
* @param string $endpoint Name of the endpoint (e.g., 'login', 'password_reset')
* @param int $maxAttempts Maximum attempts allowed
* @param int $timeWindow Time window in seconds (default: 900 = 15 minutes)
* @return bool True if limit exceeded, false if within limit
*/
public static function isLimited(
string $endpoint,
int $maxAttempts = 5,
int $timeWindow = 900
): bool {
self::startSession();
$counterKey = self::RATE_LIMIT_PREFIX . $endpoint;
$timeKey = self::RATE_LIMIT_TIME_PREFIX . $endpoint;
$currentTime = time();
$lastAttemptTime = $_SESSION[$timeKey] ?? 0;
$attempts = $_SESSION[$counterKey] ?? 0;
// Reset if time window has expired
if ($currentTime - $lastAttemptTime > $timeWindow) {
$_SESSION[$counterKey] = 0;
$_SESSION[$timeKey] = $currentTime;
return false; // Not limited (fresh window)
}
// Check if limit exceeded
return $attempts >= $maxAttempts;
}
/**
* Increment the attempt counter for an endpoint
*
* @param string $endpoint Name of the endpoint
* @param int $timeWindow Time window in seconds (default: 900 = 15 minutes)
* @return int New attempt count
*/
public static function incrementAttempt(
string $endpoint,
int $timeWindow = 900
): int {
self::startSession();
$counterKey = self::RATE_LIMIT_PREFIX . $endpoint;
$timeKey = self::RATE_LIMIT_TIME_PREFIX . $endpoint;
$currentTime = time();
$lastAttemptTime = $_SESSION[$timeKey] ?? 0;
$attempts = $_SESSION[$counterKey] ?? 0;
// Reset if time window has expired
if ($currentTime - $lastAttemptTime > $timeWindow) {
$_SESSION[$counterKey] = 1;
$_SESSION[$timeKey] = $currentTime;
return 1;
}
// Increment counter
$_SESSION[$counterKey] = ++$attempts;
// Update timestamp (keep initial window start)
if (!isset($_SESSION[$timeKey])) {
$_SESSION[$timeKey] = $currentTime;
}
return $attempts;
}
/**
* Get remaining attempts for an endpoint
*
* @param string $endpoint Name of the endpoint
* @param int $maxAttempts Maximum attempts allowed
* @param int $timeWindow Time window in seconds
* @return int Number of remaining attempts (0 if limit exceeded)
*/
public static function getRemainingAttempts(
string $endpoint,
int $maxAttempts = 5,
int $timeWindow = 900
): int {
self::startSession();
$counterKey = self::RATE_LIMIT_PREFIX . $endpoint;
$timeKey = self::RATE_LIMIT_TIME_PREFIX . $endpoint;
$currentTime = time();
$lastAttemptTime = $_SESSION[$timeKey] ?? 0;
$attempts = $_SESSION[$counterKey] ?? 0;
// Reset if time window has expired
if ($currentTime - $lastAttemptTime > $timeWindow) {
return $maxAttempts;
}
return max(0, $maxAttempts - $attempts);
}
/**
* Get seconds remaining in the current time window
*
* @param string $endpoint Name of the endpoint
* @param int $timeWindow Time window in seconds
* @return int Seconds remaining in window
*/
public static function getTimeRemaining(
string $endpoint,
int $timeWindow = 900
): int {
self::startSession();
$timeKey = self::RATE_LIMIT_TIME_PREFIX . $endpoint;
$currentTime = time();
$lastAttemptTime = $_SESSION[$timeKey] ?? 0;
if ($lastAttemptTime === 0) {
return $timeWindow;
}
$elapsed = $currentTime - $lastAttemptTime;
if ($elapsed >= $timeWindow) {
return $timeWindow; // Window expired, new window starts
}
return $timeWindow - $elapsed;
}
/**
* Reset the rate limit counter for an endpoint
* Call this after successful operation (e.g., after successful login)
*
* @param string $endpoint Name of the endpoint
* @return void
*/
public static function reset(string $endpoint): void
{
self::startSession();
$counterKey = self::RATE_LIMIT_PREFIX . $endpoint;
$timeKey = self::RATE_LIMIT_TIME_PREFIX . $endpoint;
unset($_SESSION[$counterKey]);
unset($_SESSION[$timeKey]);
}
/**
* Check limit and throw exception if exceeded
* Dies immediately with message if limit is reached
*
* @param string $endpoint Name of the endpoint
* @param int $maxAttempts Maximum attempts allowed
* @param int $timeWindow Time window in seconds
* @param string $customMessage Optional custom error message
* @return void Dies if limit exceeded
*/
public static function requireLimit(
string $endpoint,
int $maxAttempts = 5,
int $timeWindow = 900,
string $customMessage = null
): void {
if (self::isLimited($endpoint, $maxAttempts, $timeWindow)) {
$remaining = self::getTimeRemaining($endpoint, $timeWindow);
$message = $customMessage ?? sprintf(
'Too many attempts. Please try again in %d seconds.',
$remaining
);
if (self::isAjaxRequest()) {
header('Content-Type: application/json');
http_response_code(429); // Too Many Requests
die(json_encode([
'status' => 'error',
'message' => $message,
'retry_after' => $remaining
]));
} else {
http_response_code(429);
die("<h1>Too Many Requests</h1><p>$message</p>");
}
}
}
/**
* Check if request is AJAX
*
* @return bool True if request is AJAX
*/
private static function isAjaxRequest(): bool
{
return (
isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'
) || (
isset($_SERVER['CONTENT_TYPE']) &&
strpos($_SERVER['CONTENT_TYPE'], 'application/json') !== false
);
}
/**
* Start session if not already started
*
* @return void
*/
private static function startSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
/**
* Get rate limit status for an endpoint
* Useful for logging and monitoring
*
* @param string $endpoint Name of the endpoint
* @param int $maxAttempts Maximum attempts allowed
* @param int $timeWindow Time window in seconds
* @return array Status array with keys: attempts, max_attempts, remaining, time_remaining, limited
*/
public static function getStatus(
string $endpoint,
int $maxAttempts = 5,
int $timeWindow = 900
): array {
self::startSession();
$counterKey = self::RATE_LIMIT_PREFIX . $endpoint;
$attempts = $_SESSION[$counterKey] ?? 0;
$remaining = self::getRemainingAttempts($endpoint, $maxAttempts, $timeWindow);
$timeRemaining = self::getTimeRemaining($endpoint, $timeWindow);
$isLimited = self::isLimited($endpoint, $maxAttempts, $timeWindow);
return [
'endpoint' => $endpoint,
'attempts' => $attempts,
'max_attempts' => $maxAttempts,
'remaining' => $remaining,
'time_remaining' => $timeRemaining,
'limited' => $isLimited
];
}
}