Phase 1: Implement CSRF protection, input validation, and rate limiting

Major security improvements:
- Added CSRF token generation, validation, and cleanup functions
- Implemented comprehensive input validators (email, phone, name, date, amount, ID, file uploads)
- Added rate limiting with login attempt tracking and account lockout (5 failures = 15 min lockout)
- Implemented session fixation protection with session_regenerate_id() and 30-min timeout
- Fixed SQL injection in getResultFromTable() with whitelisted columns/tables
- Added audit logging for security events
- Applied CSRF validation to all 7 process_*.php files
- Applied input validation to critical endpoints (login, registration, bookings, application)
- Created database migration for login_attempts, audit_log tables and locked_until column

Modified files:
- functions.php: +500 lines of security functions
- validate_login.php: Added CSRF, rate limiting, session hardening
- register_user.php: Added CSRF, input validation, registration rate limiting
- process_*.php (7 files): Added CSRF token validation
- Created migration: 001_phase1_security_schema.sql

Next steps: Add CSRF tokens to form templates, harden file uploads, create testing checklist
This commit is contained in:
twotalesanimation
2025-12-03 11:28:53 +02:00
parent 062dc46ffd
commit 1ef4d06627
13 changed files with 1729 additions and 133 deletions

View File

@@ -92,14 +92,12 @@ function calculateDaysAndNights($startDate, $endDate)
function sendVerificationEmail($email, $name, $token)
{
global $mailjet;
$message = [
'Messages' => [
[
'From' => [
'Email' => "info@4wdcsa.co.za",
'Name' => "4WDCSA"
'Email' => $_ENV['MAILJET_FROM_EMAIL'],
'Name' => $_ENV['MAILJET_FROM_NAME']
],
'To' => [
[
@@ -119,36 +117,33 @@ function sendVerificationEmail($email, $name, $token)
];
$client = new Client([
// Base URI is used with relative requests
'base_uri' => 'https://api.mailjet.com/v3.1/',
]);
$response = $client->request('POST', 'send', [
'json' => $message,
'auth' => ['1a44f8d5e847537dbb8d3c76fe73a93c', 'ec98b45c53a7694c4f30d09eee9ad280']
'auth' => [$_ENV['MAILJET_API_KEY'], $_ENV['MAILJET_API_SECRET']]
]);
if ($response->getStatusCode() == 200) {
$body = $response->getBody();
$response = json_decode($body);
if ($response->Messages[0]->Status == 'success') {
return True;
return true;
} else {
return False;
return false;
}
}
}
function sendInvoice($email, $name, $eft_id, $amount, $description)
{
global $mailjet;
$message = [
'Messages' => [
[
'From' => [
'Email' => "info@4wdcsa.co.za",
'Name' => "4WDCSA"
'Email' => $_ENV['MAILJET_FROM_EMAIL'],
'Name' => $_ENV['MAILJET_FROM_NAME']
],
'To' => [
[
@@ -169,22 +164,21 @@ function sendInvoice($email, $name, $eft_id, $amount, $description)
];
$client = new Client([
// Base URI is used with relative requests
'base_uri' => 'https://api.mailjet.com/v3.1/',
]);
$response = $client->request('POST', 'send', [
'json' => $message,
'auth' => ['1a44f8d5e847537dbb8d3c76fe73a93c', 'ec98b45c53a7694c4f30d09eee9ad280']
'auth' => [$_ENV['MAILJET_API_KEY'], $_ENV['MAILJET_API_SECRET']]
]);
if ($response->getStatusCode() == 200) {
$body = $response->getBody();
$response = json_decode($body);
if ($response->Messages[0]->Status == 'success') {
return True;
return true;
} else {
return False;
return false;
}
}
}
@@ -209,14 +203,12 @@ function getEFTDetails($eft_id) {
function sendPOP($fullname, $eft_id, $amount, $description)
{
global $mailjet;
$message = [
'Messages' => [
[
'From' => [
'Email' => "info@4wdcsa.co.za",
'Name' => "4WDCSA Web Admin"
'Email' => $_ENV['MAILJET_FROM_EMAIL'],
'Name' => $_ENV['MAILJET_FROM_NAME'] . ' Web Admin'
],
'To' => [
[
@@ -224,7 +216,7 @@ function sendPOP($fullname, $eft_id, $amount, $description)
'Name' => 'Chris Pinto'
],
[
'Email' => 'info@4wdcsa.co.za',
'Email' => $_ENV['MAILJET_FROM_EMAIL'],
'Name' => 'Jacqui Boshoff'
],
[
@@ -246,36 +238,33 @@ function sendPOP($fullname, $eft_id, $amount, $description)
];
$client = new Client([
// Base URI is used with relative requests
'base_uri' => 'https://api.mailjet.com/v3.1/',
]);
$response = $client->request('POST', 'send', [
'json' => $message,
'auth' => ['1a44f8d5e847537dbb8d3c76fe73a93c', 'ec98b45c53a7694c4f30d09eee9ad280']
'auth' => [$_ENV['MAILJET_API_KEY'], $_ENV['MAILJET_API_SECRET']]
]);
if ($response->getStatusCode() == 200) {
$body = $response->getBody();
$response = json_decode($body);
if ($response->Messages[0]->Status == 'success') {
return True;
return true;
} else {
return False;
return false;
}
}
}
function sendEmail($email, $subject, $message)
{
global $mailjet;
$message = [
$messageData = [
'Messages' => [
[
'From' => [
'Email' => "info@4wdcsa.co.za",
'Name' => "4WDCSA"
'Email' => $_ENV['MAILJET_FROM_EMAIL'],
'Name' => $_ENV['MAILJET_FROM_NAME']
],
'To' => [
[
@@ -289,36 +278,33 @@ function sendEmail($email, $subject, $message)
];
$client = new Client([
// Base URI is used with relative requests
'base_uri' => 'https://api.mailjet.com/v3.1/',
]);
$response = $client->request('POST', 'send', [
'json' => $message,
'auth' => ['1a44f8d5e847537dbb8d3c76fe73a93c', 'ec98b45c53a7694c4f30d09eee9ad280']
'json' => $messageData,
'auth' => [$_ENV['MAILJET_API_KEY'], $_ENV['MAILJET_API_SECRET']]
]);
if ($response->getStatusCode() == 200) {
$body = $response->getBody();
$response = json_decode($body);
if ($response->Messages[0]->Status == 'success') {
return True;
return true;
} else {
return False;
return false;
}
}
}
function sendAdminNotification($subject, $message)
{
global $mailjet;
$mail = [
'Messages' => [
[
'From' => [
'Email' => "info@4wdcsa.co.za",
'Name' => "4WDCSA"
'Email' => $_ENV['MAILJET_FROM_EMAIL'],
'Name' => $_ENV['MAILJET_FROM_NAME']
],
'To' => [
[
@@ -337,36 +323,33 @@ function sendAdminNotification($subject, $message)
];
$client = new Client([
// Base URI is used with relative requests
'base_uri' => 'https://api.mailjet.com/v3.1/',
]);
$response = $client->request('POST', 'send', [
'json' => $mail,
'auth' => ['1a44f8d5e847537dbb8d3c76fe73a93c', 'ec98b45c53a7694c4f30d09eee9ad280']
'auth' => [$_ENV['MAILJET_API_KEY'], $_ENV['MAILJET_API_SECRET']]
]);
if ($response->getStatusCode() == 200) {
$body = $response->getBody();
$response = json_decode($body);
if ($response->Messages[0]->Status == 'success') {
return True;
return true;
} else {
return False;
return false;
}
}
}
function sendPaymentConfirmation($email, $name, $description)
{
global $mailjet;
$message = [
'Messages' => [
[
'From' => [
'Email' => "info@4wdcsa.co.za",
'Name' => "4WDCSA"
'Email' => $_ENV['MAILJET_FROM_EMAIL'],
'Name' => $_ENV['MAILJET_FROM_NAME']
],
'To' => [
[
@@ -386,22 +369,21 @@ function sendPaymentConfirmation($email, $name, $description)
];
$client = new Client([
// Base URI is used with relative requests
'base_uri' => 'https://api.mailjet.com/v3.1/',
]);
$response = $client->request('POST', 'send', [
'json' => $message,
'auth' => ['1a44f8d5e847537dbb8d3c76fe73a93c', 'ec98b45c53a7694c4f30d09eee9ad280']
'auth' => [$_ENV['MAILJET_API_KEY'], $_ENV['MAILJET_API_SECRET']]
]);
if ($response->getStatusCode() == 200) {
$body = $response->getBody();
$response = json_decode($body);
if ($response->Messages[0]->Status == 'success') {
return True;
return true;
} else {
return False;
return false;
}
}
}
@@ -591,7 +573,7 @@ function processPayment($payment_id, $amount, $description)
{
$conn = openDatabaseConnection();
$status = "AWAITING PAYMENT";
$domain = 'www.thepinto.co.za/4wdcsa';
$domain = $_ENV['PAYFAST_DOMAIN'];
$user_id = $_SESSION['user_id'];
// Insert the order into the orders table
$stmt = $conn->prepare("
@@ -625,7 +607,7 @@ function processPayment($payment_id, $amount, $description)
* @param null $passPhrase
* @return string
*/
function generateSignature($data, $passPhrase = 'SheSells7Shells')
function generateSignature($data, $passPhrase = null)
{
// Create parameter string
$pfOutput = '';
@@ -644,8 +626,8 @@ function processPayment($payment_id, $amount, $description)
// Construct variables
$data = array(
// Merchant details
'merchant_id' => '10021495',
'merchant_key' => 'yzpdydo934j92',
'merchant_id' => $_ENV['PAYFAST_MERCHANT_ID'],
'merchant_key' => $_ENV['PAYFAST_MERCHANT_KEY'],
'return_url' => 'https://' . $domain . '/bookings.php',
'cancel_url' => 'https://' . $domain . '/cancel_booking.php?booking_id=' . $encryptedId,
'notify_url' => 'https://' . $domain . '/confirm.php',
@@ -659,11 +641,11 @@ function processPayment($payment_id, $amount, $description)
'item_name' => '4WDCSA: ' . $description // Describe the item(s) or use a generic description
);
$signature = generateSignature($data); // Assuming you have this function defined
$signature = generateSignature($data, $_ENV['PAYFAST_PASSPHRASE']);
$data['signature'] = $signature;
// Determine the PayFast URL based on the mode
$testingMode = true;
$testingMode = $_ENV['PAYFAST_TESTING_MODE'] === 'true';
$pfHost = $testingMode ? 'sandbox.payfast.co.za' : 'www.payfast.co.za';
// Generate the HTML form with hidden inputs and an auto-submit script
@@ -690,7 +672,7 @@ function processMembershipPayment($payment_id, $amount, $description)
{
$conn = openDatabaseConnection();
$status = "AWAITING PAYMENT";
$domain = 'www.thepinto.co.za/4wdcsa';
$domain = $_ENV['PAYFAST_DOMAIN'];
$user_id = $_SESSION['user_id'];
// Insert the order into the orders table
$stmt = $conn->prepare("
@@ -724,7 +706,7 @@ function processMembershipPayment($payment_id, $amount, $description)
* @param null $passPhrase
* @return string
*/
function generateSignature($data, $passPhrase = 'SheSells7Shells')
function generateSignature($data, $passPhrase = null)
{
// Create parameter string
$pfOutput = '';
@@ -743,8 +725,8 @@ function processMembershipPayment($payment_id, $amount, $description)
// Construct variables
$data = array(
// Merchant details
'merchant_id' => '10021495',
'merchant_key' => 'yzpdydo934j92',
'merchant_id' => $_ENV['PAYFAST_MERCHANT_ID'],
'merchant_key' => $_ENV['PAYFAST_MERCHANT_KEY'],
'return_url' => 'https://' . $domain . '/account_settings.php',
'cancel_url' => 'https://' . $domain . '/cancel_application.php?id=' . $encryptedId,
'notify_url' => 'https://' . $domain . '/confirm2.php',
@@ -758,11 +740,11 @@ function processMembershipPayment($payment_id, $amount, $description)
'item_name' => $description // Describe the item(s) or use a generic description
);
$signature = generateSignature($data); // Assuming you have this function defined
$signature = generateSignature($data, $_ENV['PAYFAST_PASSPHRASE']);
$data['signature'] = $signature;
// Determine the PayFast URL based on the mode
$testingMode = true;
$testingMode = $_ENV['PAYFAST_TESTING_MODE'] === 'true';
$pfHost = $testingMode ? 'sandbox.payfast.co.za' : 'www.payfast.co.za';
// Generate the HTML form with hidden inputs and an auto-submit script
@@ -1904,16 +1886,84 @@ function processLegacyMembership($user_id) {
}
}
/**
* SECURITY WARNING: This function uses dynamic table/column names which makes it vulnerable to SQL injection.
* ONLY call this function with whitelisted table and column names.
* NEVER accept table/column names directly from user input.
*
* Retrieves a single value from a database table.
* @param string $table Table name (MUST be whitelisted - see allowed_tables array)
* @param string $column Column name to retrieve (MUST be whitelisted - see allowed_columns array)
* @param string $match Column name for WHERE clause (MUST be whitelisted)
* @param mixed $identifier Value to match in WHERE clause (parameterized - safe)
* @return mixed The result value or null if not found
*/
function getResultFromTable($table, $column, $match, $identifier) {
// WHITELIST: Define allowed tables to prevent table name injection
$allowed_tables = [
'users',
'membership_application',
'membership_fees',
'bookings',
'payments',
'efts',
'trips',
'courses',
'blogs',
'events',
'campsites',
'bar_transactions',
'login_attempts',
'legacy_members'
];
// WHITELIST: Define allowed columns per table (simplified - add more as needed)
$allowed_columns = [
'legacy_members' => ['amount', 'legacy_id', 'email', 'name'],
'users' => ['user_id', 'email', 'first_name', 'last_name', 'phone_number', 'password', 'profile_pic', 'is_verified', 'type', 'locked_until'],
'membership_fees' => ['payment_id', 'user_id', 'amount', 'payment_status', 'payment_date'],
'bookings' => ['booking_id', 'user_id', 'total_amount', 'status', 'booking_type'],
'payments' => ['payment_id', 'user_id', 'amount', 'status'],
'trips' => ['trip_id', 'trip_name', 'description'],
'courses' => ['course_id', 'course_name', 'description'],
'blogs' => ['blog_id', 'title', 'content'],
'events' => ['event_id', 'event_name', 'description'],
'campsites' => ['campsite_id', 'name', 'location'],
'efts' => ['eft_id', 'amount', 'status', 'booking_id'],
'bar_transactions' => ['transaction_id', 'amount', 'date'],
'login_attempts' => ['attempt_id', 'email', 'ip_address', 'success']
];
$conn = openDatabaseConnection();
$sql = "SELECT `$column` FROM `$table` WHERE `$match` = ?";
$stmt = $conn->prepare($sql);
if (!$stmt) {
// Validate table name is in whitelist
if (!in_array($table, $allowed_tables, true)) {
error_log("Security Warning: getResultFromTable() called with non-whitelisted table: $table");
return null;
}
// Validate column name is in whitelist for this table
if (!isset($allowed_columns[$table]) || !in_array($column, $allowed_columns[$table], true)) {
error_log("Security Warning: getResultFromTable() called with non-whitelisted column: $column for table: $table");
return null;
}
// Validate match column is in whitelist for this table
if (!isset($allowed_columns[$table]) || !in_array($match, $allowed_columns[$table], true)) {
error_log("Security Warning: getResultFromTable() called with non-whitelisted match column: $match for table: $table");
return null;
}
$stmt->bind_param('i', $identifier);
$conn = openDatabaseConnection();
// Use backticks for table and column identifiers (safe after whitelist validation)
$sql = "SELECT `" . $column . "` FROM `" . $table . "` WHERE `" . $match . "` = ?";
$stmt = $conn->prepare($sql);
if (!$stmt) {
error_log("Database prepare error: " . $conn->error);
return null;
}
// Determine parameter type based on identifier
$paramType = is_int($identifier) ? 'i' : 's';
$stmt->bind_param($paramType, $identifier);
$stmt->execute();
$stmt->bind_result($result);
$stmt->fetch();
@@ -1977,3 +2027,522 @@ function hasPhoneNumber($user_id) {
// Return true only if a phone number exists and is not empty
return !empty($phone_number);
}
// ==================== CSRF PROTECTION FUNCTIONS ====================
/**
* Generates a CSRF token and stores it in the session with expiration
* @param int $duration Token expiration time in seconds (default 3600 = 1 hour)
* @return string The generated CSRF token
*/
function generateCSRFToken($duration = 3600) {
// Initialize CSRF token storage in session if needed
if (!isset($_SESSION['csrf_tokens'])) {
$_SESSION['csrf_tokens'] = [];
}
// Clean up expired tokens
cleanupExpiredTokens();
// Generate a random token
$token = bin2hex(random_bytes(32));
// Store token with expiration timestamp
$_SESSION['csrf_tokens'][$token] = time() + $duration;
return $token;
}
/**
* Validates a CSRF token from user input
* @param string $token The token to validate (typically from $_POST['csrf_token'])
* @return bool True if token is valid, false otherwise
*/
function validateCSRFToken($token) {
// Check if token exists in session
if (!isset($_SESSION['csrf_tokens']) || !isset($_SESSION['csrf_tokens'][$token])) {
return false;
}
// Check if token has expired
if ($_SESSION['csrf_tokens'][$token] < time()) {
unset($_SESSION['csrf_tokens'][$token]);
return false;
}
// Token is valid - remove it from session (single-use)
unset($_SESSION['csrf_tokens'][$token]);
return true;
}
/**
* Removes expired tokens from the session
*/
function cleanupExpiredTokens() {
if (!isset($_SESSION['csrf_tokens'])) {
return;
}
$currentTime = time();
foreach ($_SESSION['csrf_tokens'] as $token => $expiration) {
if ($expiration < $currentTime) {
unset($_SESSION['csrf_tokens'][$token]);
}
}
}
// ==================== INPUT VALIDATION FUNCTIONS ====================
/**
* Validates and sanitizes email input
* @param string $email The email to validate
* @param int $maxLength Maximum allowed length (default 254 per RFC 5321)
* @return string|false Sanitized email or false if invalid
*/
function validateEmail($email, $maxLength = 254) {
// Check length
if (strlen($email) > $maxLength) {
return false;
}
// Filter and validate
$filtered = filter_var($email, FILTER_VALIDATE_EMAIL);
// Sanitize if valid
return $filtered ? filter_var($filtered, FILTER_SANITIZE_EMAIL) : false;
}
/**
* Validates phone number format
* @param string $phone The phone number to validate
* @return string|false Sanitized phone number or false if invalid
*/
function validatePhoneNumber($phone) {
// Remove common formatting characters
$cleaned = preg_replace('/[^\d+\-\s().]/', '', $phone);
// Check length (between 7 and 20 digits)
$digitCount = strlen(preg_replace('/[^\d]/', '', $cleaned));
if ($digitCount < 7 || $digitCount > 20) {
return false;
}
return $cleaned;
}
/**
* Validates and sanitizes a name (first name, last name)
* @param string $name The name to validate
* @param int $minLength Minimum allowed length (default 2)
* @param int $maxLength Maximum allowed length (default 100)
* @return string|false Sanitized name or false if invalid
*/
function validateName($name, $minLength = 2, $maxLength = 100) {
// Trim whitespace
$name = trim($name);
// Check length
if (strlen($name) < $minLength || strlen($name) > $maxLength) {
return false;
}
// Only allow letters, spaces, hyphens, and apostrophes
if (!preg_match('/^[a-zA-Z\s\'-]+$/', $name)) {
return false;
}
return htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
}
/**
* Validates a date string in YYYY-MM-DD format
* @param string $date The date string to validate
* @param string $format Expected date format (default 'Y-m-d')
* @return string|false Valid date string or false if invalid
*/
function validateDate($date, $format = 'Y-m-d') {
$d = DateTime::createFromFormat($format, $date);
// Check if date is valid and in correct format
if (!$d || $d->format($format) !== $date) {
return false;
}
return $date;
}
/**
* Validates a numeric amount (for currency)
* @param mixed $amount The amount to validate
* @param float $min Minimum allowed amount (default 0)
* @param float $max Maximum allowed amount (default 999999.99)
* @return float|false Valid amount or false if invalid
*/
function validateAmount($amount, $min = 0, $max = 999999.99) {
// Try to convert to float
$value = filter_var($amount, FILTER_VALIDATE_FLOAT, [
'options' => [
'min_range' => $min,
'max_range' => $max,
'decimal' => '.'
]
]);
// Must have at most 2 decimal places
if ($value !== false) {
$parts = explode('.', (string)$amount);
if (isset($parts[1]) && strlen($parts[1]) > 2) {
return false;
}
}
return $value;
}
/**
* Validates an integer within a range
* @param mixed $int The integer to validate
* @param int $min Minimum allowed value (default 0)
* @param int $max Maximum allowed value (default 2147483647)
* @return int|false Valid integer or false if invalid
*/
function validateInteger($int, $min = 0, $max = 2147483647) {
$value = filter_var($int, FILTER_VALIDATE_INT, [
'options' => [
'min_range' => $min,
'max_range' => $max
]
]);
return $value !== false ? $value : false;
}
/**
* Validates South African ID number (13 digits)
* @param string $idNumber The ID number to validate
* @return string|false Valid ID number or false if invalid
*/
function validateSAIDNumber($idNumber) {
// Remove any whitespace
$idNumber = preg_replace('/\s/', '', $idNumber);
// Must be exactly 13 digits
if (!preg_match('/^\d{13}$/', $idNumber)) {
return false;
}
// Optional: Validate checksum (Luhn algorithm)
$sum = 0;
for ($i = 0; $i < 13; $i++) {
$digit = (int)$idNumber[$i];
// Double every even-positioned digit (0-indexed)
if ($i % 2 == 0) {
$digit *= 2;
if ($digit > 9) {
$digit -= 9;
}
}
$sum += $digit;
}
// Last digit should make sum divisible by 10
if ($sum % 10 != 0) {
return false;
}
return $idNumber;
}
/**
* Sanitizes text input, removing potentially dangerous characters
* @param string $text The text to sanitize
* @param int $maxLength Maximum allowed length
* @return string Sanitized text
*/
function sanitizeTextInput($text, $maxLength = 1000) {
// Trim whitespace
$text = trim($text);
// Limit length
$text = substr($text, 0, $maxLength);
// Encode HTML special characters
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
/**
* Validates file uploads for security
* @param array $file The $_FILES element to validate
* @param array $allowedTypes Array of allowed MIME types (e.g., ['image/jpeg', 'image/png', 'application/pdf'])
* @param int $maxSize Maximum file size in bytes
* @return string|false Sanitized filename or false if invalid
*/
function validateFileUpload($file, $allowedTypes = [], $maxSize = 5242880) { // 5MB default
// Check for upload errors
if (!isset($file['error']) || $file['error'] !== UPLOAD_ERR_OK) {
return false;
}
// Check file size
if (!isset($file['size']) || $file['size'] > $maxSize) {
return false;
}
// Check MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes, true)) {
return false;
}
// Generate random filename with original extension
$pathinfo = pathinfo($file['name']);
$extension = strtolower($pathinfo['extension']);
// Whitelist allowed extensions
$allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'gif', 'webp'];
if (!in_array($extension, $allowedExtensions)) {
return false;
}
// Generate random filename
$randomName = bin2hex(random_bytes(16)) . '.' . $extension;
return $randomName;
}
// ==================== RATE LIMITING & ACCOUNT LOCKOUT FUNCTIONS ====================
/**
* Records a login attempt in the login_attempts table
* @param string $email The email address attempting to login
* @param bool $success Whether the login was successful
* @return void
*/
function recordLoginAttempt($email, $success = false) {
// Get client IP address
$ip = getClientIPAddress();
$conn = openDatabaseConnection();
if (!$conn) {
return;
}
$email = strtolower(trim($email));
$sql = "INSERT INTO login_attempts (email, ip_address, success) VALUES (?, ?, ?)";
$stmt = $conn->prepare($sql);
if ($stmt) {
$success_int = $success ? 1 : 0;
$stmt->bind_param('ssi', $email, $ip, $success_int);
$stmt->execute();
$stmt->close();
}
}
/**
* Gets the client's IP address safely
* @return string The client's IP address
*/
function getClientIPAddress() {
// Check for IP from share internet
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
}
// Check for IP passed from proxy
elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
}
// Check for remote IP
else {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
// Validate IP format
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$ip = '0.0.0.0';
}
return $ip;
}
/**
* Checks if an account is locked and returns lockout status
* @param string $email The email address to check
* @return array ['is_locked' => bool, 'locked_until' => datetime string or null, 'minutes_remaining' => int]
*/
function checkAccountLockout($email) {
$conn = openDatabaseConnection();
if (!$conn) {
return ['is_locked' => false, 'locked_until' => null, 'minutes_remaining' => 0];
}
$email = strtolower(trim($email));
$now = date('Y-m-d H:i:s');
$sql = "SELECT locked_until FROM users WHERE email = ? LIMIT 1";
$stmt = $conn->prepare($sql);
if (!$stmt) {
return ['is_locked' => false, 'locked_until' => null, 'minutes_remaining' => 0];
}
$stmt->bind_param('s', $email);
$stmt->execute();
$stmt->bind_result($locked_until);
$stmt->fetch();
$stmt->close();
if ($locked_until === null) {
return ['is_locked' => false, 'locked_until' => null, 'minutes_remaining' => 0];
}
if ($locked_until > $now) {
// Account is still locked
$lockTime = strtotime($locked_until);
$nowTime = strtotime($now);
$secondsRemaining = max(0, $lockTime - $nowTime);
$minutesRemaining = ceil($secondsRemaining / 60);
return [
'is_locked' => true,
'locked_until' => $locked_until,
'minutes_remaining' => $minutesRemaining
];
}
// Lockout has expired, clear it
$sql = "UPDATE users SET locked_until = NULL WHERE email = ?";
$stmt = $conn->prepare($sql);
if ($stmt) {
$stmt->bind_param('s', $email);
$stmt->execute();
$stmt->close();
}
return ['is_locked' => false, 'locked_until' => null, 'minutes_remaining' => 0];
}
/**
* Counts recent failed login attempts for an email + IP combination
* @param string $email The email address
* @param int $minutesBack How many minutes back to check (default 15)
* @return int Number of failed attempts
*/
function countRecentFailedAttempts($email, $minutesBack = 15) {
$conn = openDatabaseConnection();
if (!$conn) {
return 0;
}
$email = strtolower(trim($email));
$ip = getClientIPAddress();
$cutoffTime = date('Y-m-d H:i:s', time() - ($minutesBack * 60));
$sql = "SELECT COUNT(*) as count FROM login_attempts
WHERE email = ? AND ip_address = ? AND success = 0
AND attempted_at > ?";
$stmt = $conn->prepare($sql);
if (!$stmt) {
return 0;
}
$stmt->bind_param('sss', $email, $ip, $cutoffTime);
$stmt->execute();
$stmt->bind_result($count);
$stmt->fetch();
$stmt->close();
return (int)$count;
}
/**
* Locks an account for a specified duration
* @param string $email The email address to lock
* @param int $minutes Duration of lockout in minutes (default 15)
* @return bool True if successful, false otherwise
*/
function lockAccount($email, $minutes = 15) {
$conn = openDatabaseConnection();
if (!$conn) {
return false;
}
$email = strtolower(trim($email));
$lockUntil = date('Y-m-d H:i:s', time() + ($minutes * 60));
$sql = "UPDATE users SET locked_until = ? WHERE email = ?";
$stmt = $conn->prepare($sql);
if (!$stmt) {
return false;
}
$stmt->bind_param('ss', $lockUntil, $email);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Unlocks an account (admin function)
* @param string $email The email address to unlock
* @return bool True if successful, false otherwise
*/
function unlockAccount($email) {
$conn = openDatabaseConnection();
if (!$conn) {
return false;
}
$email = strtolower(trim($email));
$sql = "UPDATE users SET locked_until = NULL WHERE email = ?";
$stmt = $conn->prepare($sql);
if (!$stmt) {
return false;
}
$stmt->bind_param('s', $email);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Logs an action to the audit log table
* @param int $user_id User ID (null if not authenticated)
* @param string $action Action name (e.g., 'LOGIN', 'FAILED_LOGIN', 'ACCOUNT_LOCKED')
* @param string $resource_type Resource type being affected
* @param int $resource_id Resource ID being affected
* @param array $details Additional details about the action
* @return bool True if successful
*/
function auditLog($user_id, $action, $resource_type = null, $resource_id = null, $details = null) {
$conn = openDatabaseConnection();
if (!$conn) {
return false;
}
$ip = getClientIPAddress();
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$detailsJson = $details ? json_encode($details) : null;
$sql = "INSERT INTO audit_log (user_id, action, resource_type, resource_id, ip_address, user_agent, details)
VALUES (?, ?, ?, ?, ?, ?, ?)";
$stmt = $conn->prepare($sql);
if (!$stmt) {
return false;
}
$stmt->bind_param('issdsss', $user_id, $action, $resource_type, $resource_id, $ip, $userAgent, $detailsJson);
$result = $stmt->execute();
$stmt->close();
return $result;
}