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:
284
src/Middleware/RateLimitMiddleware.php
Normal file
284
src/Middleware/RateLimitMiddleware.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user