From a4526979c491604244275e10020cd06835246304 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Tue, 2 Dec 2025 21:10:48 +0200 Subject: [PATCH] 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 --- send_reset_link.php | 19 +- src/Middleware/RateLimitMiddleware.php | 284 +++++++++++++++++++++++++ validate_login.php | 34 ++- 3 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 src/Middleware/RateLimitMiddleware.php diff --git a/send_reset_link.php b/send_reset_link.php index 82a672e1..4f585a02 100644 --- a/send_reset_link.php +++ b/send_reset_link.php @@ -3,9 +3,21 @@ require_once("env.php"); require_once("connection.php"); require_once("functions.php"); +use Middleware\RateLimitMiddleware; + $response = array('status' => 'error', 'message' => 'Something went wrong'); if (isset($_POST['email'])) { + // Check rate limit first (3 attempts per 30 minutes to prevent abuse) + if (RateLimitMiddleware::isLimited('password_reset', 3, 1800)) { + $remaining = RateLimitMiddleware::getTimeRemaining('password_reset', 1800); + $response['status'] = 'error'; + $response['message'] = "Too many password reset requests. Please try again in {$remaining} seconds."; + $response['retry_after'] = $remaining; + echo json_encode($response); + exit(); + } + $email = $_POST['email']; // Check if the email exists @@ -23,7 +35,7 @@ if (isset($_POST['email'])) { $token = bin2hex(random_bytes(50)); // Store the token and expiration time in the database - $expiry = date("Y-m-d H:i:s", strtotime('+3 hour')); // Token expires in 1 hour + $expiry = date("Y-m-d H:i:s", strtotime('+3 hour')); // Token expires in 3 hour $sql = "INSERT INTO password_resets (user_id, token, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE token = VALUES(token), expires_at = VALUES(expires_at)"; $stmt = $conn->prepare($sql); @@ -36,9 +48,14 @@ if (isset($_POST['email'])) { $message = "Click the following link to reset your password: $reset_link"; sendEmail($email, $subject, $message); + // Reset rate limit on successful request + RateLimitMiddleware::reset('password_reset'); + $response['status'] = 'success'; $response['message'] = 'Password reset link has been sent to your email.'; } else { + // Increment rate limit even for non-existent emails (prevent email enumeration) + RateLimitMiddleware::incrementAttempt('password_reset', 1800); $response['message'] = 'Email not found.'; } } diff --git a/src/Middleware/RateLimitMiddleware.php b/src/Middleware/RateLimitMiddleware.php new file mode 100644 index 00000000..1f72b48a --- /dev/null +++ b/src/Middleware/RateLimitMiddleware.php @@ -0,0 +1,284 @@ + $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("
$message
"); + } + } + } + + /** + * 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 + ]; + } +} diff --git a/validate_login.php b/validate_login.php index fdfce164..cb144376 100644 --- a/validate_login.php +++ b/validate_login.php @@ -6,6 +6,8 @@ require_once("functions.php"); require_once 'google-client/vendor/autoload.php'; // Add this line for Google Client use Middleware\CsrfMiddleware; +use Middleware\RateLimitMiddleware; +use Services\AuthenticationService; // Check if connection is established if (!$conn) { @@ -64,6 +66,10 @@ if (isset($_GET['code'])) { $_SESSION['first_name'] = $first_name; $_SESSION['profile_pic'] = $picture; processLegacyMembership($_SESSION['user_id']); + // Regenerate session to prevent session fixation attacks + AuthenticationService::regenerateSession(); + // Reset rate limit on successful login + RateLimitMiddleware::reset('login'); // echo json_encode(['status' => 'success', 'message' => 'Google login successful']); header("Location: index.php"); exit(); @@ -79,6 +85,10 @@ if (isset($_GET['code'])) { $_SESSION['first_name'] = $row['first_name']; $_SESSION['profile_pic'] = $row['profile_pic']; sendEmail('chrispintoza@gmail.com', '4WDCSA: New User Login', $name.' has just logged in using Google Login.'); + // Regenerate session to prevent session fixation attacks + AuthenticationService::regenerateSession(); + // Reset rate limit on successful login + RateLimitMiddleware::reset('login'); // echo json_encode(['status' => 'success', 'message' => 'Google login successful']); header("Location: index.php"); exit(); @@ -93,6 +103,17 @@ if (isset($_GET['code'])) { // Check if email and password login is requested if (isset($_POST['email']) && isset($_POST['password'])) { + // Check rate limit first (5 attempts per 15 minutes) + if (RateLimitMiddleware::isLimited('login', 5, 900)) { + $remaining = RateLimitMiddleware::getTimeRemaining('login', 900); + echo json_encode([ + 'status' => 'error', + 'message' => "Too many login attempts. Please try again in {$remaining} seconds.", + 'retry_after' => $remaining + ]); + exit(); + } + // Retrieve and sanitize form data $email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL); $password = trim($_POST['password']); // Remove extra spaces @@ -100,11 +121,13 @@ if (isset($_POST['email']) && isset($_POST['password'])) { // Validate input if (empty($email) || empty($password)) { echo json_encode(['status' => 'error', 'message' => 'Please enter both email and password.']); + RateLimitMiddleware::incrementAttempt('login', 900); exit(); } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['status' => 'error', 'message' => 'Invalid email format.']); + RateLimitMiddleware::incrementAttempt('login', 900); exit(); } @@ -128,6 +151,7 @@ if (isset($_POST['email']) && isset($_POST['password'])) { // Check if the user is verified if ($row['is_verified'] == 0) { echo json_encode(['status' => 'error', 'message' => 'Your account is not verified. Please check your email for the verification link.']); + RateLimitMiddleware::incrementAttempt('login', 900); exit(); } @@ -136,13 +160,19 @@ if (isset($_POST['email']) && isset($_POST['password'])) { $_SESSION['user_id'] = $row['user_id']; // Adjust as per your table structure $_SESSION['first_name'] = $row['first_name']; // Adjust as per your table structure $_SESSION['profile_pic'] = $row['profile_pic']; + // Regenerate session to prevent session fixation attacks + AuthenticationService::regenerateSession(); + // Reset rate limit on successful login + RateLimitMiddleware::reset('login'); echo json_encode(['status' => 'success', 'message' => 'Successful Login']); } else { - // Password is incorrect + // Password is incorrect - increment rate limit + RateLimitMiddleware::incrementAttempt('login', 900); echo json_encode(['status' => 'error', 'message' => 'Invalid password.']); } } else { - // User does not exist + // User does not exist - still increment rate limit to prevent email enumeration + RateLimitMiddleware::incrementAttempt('login', 900); echo json_encode(['status' => 'error', 'message' => 'User with that email does not exist.']); }