81efab9cb0
Prepares the site for handover to the incoming committee. Three groups of change: security fixes, a production stability fix, and documentation. SECURITY - upload_blog_image.php accepted unauthenticated uploads with no extension or MIME validation, into a web-served directory. Anyone could upload and execute a .php file. Now requires a session, checks blog ownership, and runs through validateFileUpload() with a new blog_image type. - Added uploads/.htaccess and assets/uploads/.htaccess disabling the PHP engine, so an upload bypass still cannot execute. Force-added: .gitignore contains a bare .htaccess rule. - blog/autosave.php updated posts by blog_id alone, letting any logged-in member overwrite anyone's post. Now scoped to the author, admin override retained. It also silently reassigned authorship to the editing admin; the existing author is now preserved. - add_to_cart.php took item_price and user_id from POST, so drinks could be charged at any price to any member's tab, unauthenticated. Price and name are now looked up server-side from bar_items. Its require paths were also relative and broken. - Session cookies now httponly/secure/samesite with strict mode, set in both .htaccess and .user.ini since php_flag and .user.ini apply under different SAPIs. The 30 minute idle timeout that login set but never checked is now enforced. Google login now regenerates the session id. - Forced HTTPS. Required, because Secure cookies are never sent over plain HTTP and login would fail silently. LetsEncrypt acme-challenge is exempted above the redirect so certificate renewal keeps working. - display_errors off; .htaccess now blocks *.log, error_log, .git and dotfiles. Debug logs containing member IDs, raw webhook bodies and payment signatures were publicly fetchable. - Removed the IKHOKHA_BYPASS_SIGNATURE switch and the logging of raw webhook bodies, app IDs and signatures. Replaced a hardcoded personal Gmail recipient with the optional DEVELOPER_EMAIL variable. - Added auth to fetch_users, fetch_bar_tabs, fetch_drinks, create_bar_tab and submit_order. Converted get_tab_total to a prepared statement. - Tightened 11 mkdir(0777) calls to 0755. - Fixed the validate_login rewrite, which pointed at a nonexistent path and only worked by falling through to the generic .php rule. DATABASE STABILITY openDatabaseConnection() opened a new connection on every call across 73 call sites, and the server was hitting "Too many connections" in production. It now returns one shared handle per request, reusing the connection connection.php already opened. The 40 conn->close() calls in functions.php are removed, since closing the shared handle breaks every later caller; PHP releases it at end of request. A liveness check reconnects transparently if anything closes it. Verified no function was lost (99 before and after), every removed close was inside a braced block, and no transaction spans a helper that would open a nested one. DOCUMENTATION AND TOOLING - docs/HANDOVER.md: operator guide covering architecture, routing, roles, the membership lifecycle, payments, backups, POPIA obligations, environment variables, known issues and a handover checklist. - docs/FUNCTIONS.md: reference for the helpers in functions.php. - scripts/backup.sh: nightly database and uploads backup, written outside the web root, with rotation and verification. Dumps are written to a temp file and only promoted after passing gzip, CREATE TABLE and completion-marker checks, so a failed run can never overwrite a good backup. - .deployignore and .gitattributes added; .gitattributes pins LF on shell scripts and server config because core.autocrlf is true on Windows. - Removed leftover debris: src/api/test_log.php and .htaccess1. All 22 modified PHP files pass php -l. The backup script was tested against a stub mysqldump for healthy, truncated and failing dumps, and for rotation. Nothing was run against a live database or web server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
10 KiB
PHP
260 lines
10 KiB
PHP
<?php
|
|
$rootPath = dirname(__FILE__);
|
|
require_once($rootPath . "/src/config/env.php");
|
|
require_once($rootPath . "/src/config/session.php");
|
|
require_once($rootPath . "/src/config/connection.php");
|
|
require_once($rootPath . "/src/config/functions.php");
|
|
require_once($rootPath . '/google-client/vendor/autoload.php'); // Add this line for Google Client
|
|
require_once($rootPath . "/src/helpers/notification_helper.php");
|
|
|
|
|
|
// Check if connection is established
|
|
if (!$conn) {
|
|
json_encode(['status' => 'error', 'message' => 'Database connection failed.']);
|
|
exit();
|
|
}
|
|
|
|
// Google Client Setup
|
|
$client = new Google_Client();
|
|
$client->setClientId($_ENV['GOOGLE_CLIENT_ID']);
|
|
$client->setClientSecret($_ENV['GOOGLE_CLIENT_SECRET']);
|
|
$client->setRedirectUri($_ENV['HOST'] . '/validate_login.php');
|
|
$client->addScope("email");
|
|
$client->addScope("profile");
|
|
// 👇 Add this to force the account picker
|
|
$client->setPrompt('select_account');
|
|
|
|
// Check if Google login code is set
|
|
if (isset($_GET['code'])) {
|
|
$token = $client->fetchAccessTokenWithAuthCode($_GET['code']);
|
|
if (!isset($token["error"])) {
|
|
$client->setAccessToken($token['access_token']);
|
|
$google_oauth = new Google_Service_Oauth2($client);
|
|
$google_account_info = $google_oauth->userinfo->get();
|
|
|
|
// Get user info from Google
|
|
$email = $google_account_info->email;
|
|
$name = $google_account_info->name;
|
|
$first_name = $google_account_info->given_name;
|
|
$last_name = $google_account_info->family_name;
|
|
$picture = $google_account_info->picture;
|
|
|
|
|
|
|
|
// Check if the user exists in the database
|
|
$query = "SELECT * FROM users WHERE email = ?";
|
|
$stmt = $conn->prepare($query);
|
|
$stmt->bind_param("s", $email);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
|
|
if ($result->num_rows == 0) {
|
|
// User does not exist, so register them
|
|
$password = null; // No password for Google login
|
|
$query = "INSERT INTO users (email, first_name, last_name, profile_pic, password, is_verified) VALUES (?, ?, ?, ?, ?, ?)";
|
|
$stmt = $conn->prepare($query);
|
|
$is_verified = 1; // Assuming Google users are considered verified
|
|
$stmt->bind_param("sssssi", $email, $first_name, $last_name, $picture, $password, $is_verified);
|
|
if ($stmt->execute()) {
|
|
// User successfully registered, set session and redirect
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = $conn->insert_id;
|
|
$_SESSION['first_name'] = $first_name;
|
|
$_SESSION['profile_pic'] = $picture;
|
|
$_SESSION['login_time'] = time();
|
|
$_SESSION['session_timeout'] = 1800; // 30 minutes
|
|
|
|
// Send Notification
|
|
$event = 'user_login';
|
|
$sub_feed = 'logins';
|
|
$data = [
|
|
'actor_id' => $conn->insert_id,
|
|
'actor_avatar' => $picture, // used by UI to show avatar
|
|
'title' => "User Login by {$first_name} {$last_name}"
|
|
];
|
|
addNotification(null, $event, $sub_feed, $data, null);
|
|
|
|
header("Location: index.php");
|
|
exit();
|
|
} else {
|
|
// echo json_encode(['status' => 'error', 'message' => 'Failed to register user.']);
|
|
header("Location: index.php");
|
|
exit();
|
|
}
|
|
} else {
|
|
// User exists, set session and redirect
|
|
$row = $result->fetch_assoc();
|
|
session_regenerate_id(true);
|
|
$_SESSION['user_id'] = $row['user_id'];
|
|
$_SESSION['first_name'] = $row['first_name'];
|
|
$_SESSION['profile_pic'] = $row['profile_pic'];
|
|
$_SESSION['login_time'] = time();
|
|
$_SESSION['session_timeout'] = 1800; // 30 minutes
|
|
|
|
// Send Notification
|
|
$event = 'user_login';
|
|
$sub_feed = 'logins';
|
|
$data = [
|
|
'actor_id' => $_SESSION['user_id'],
|
|
'actor_avatar' => $_SESSION['profile_pic'], // used by UI to show avatar
|
|
'title' => "User Login by {$first_name} {$last_name}"
|
|
];
|
|
addNotification(null, $event, $sub_feed, $data, null);
|
|
|
|
header("Location: index.php");
|
|
exit();
|
|
}
|
|
|
|
$stmt->close();
|
|
} else {
|
|
echo "Login failed.";
|
|
exit();
|
|
}
|
|
}
|
|
|
|
// Check if email and password login is requested
|
|
if (isset($_POST['email']) && isset($_POST['password'])) {
|
|
// CSRF Token Validation
|
|
if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
|
|
auditLog(null, 'CSRF_VALIDATION_FAILED', 'users', null, ['endpoint' => 'validate_login.php']);
|
|
echo json_encode(['status' => 'error', 'message' => 'Security token validation failed. Please try again.']);
|
|
exit();
|
|
}
|
|
|
|
// Retrieve and validate email input
|
|
$email = validateEmail($_POST['email']);
|
|
if ($email === false) {
|
|
auditLog(null, 'INVALID_EMAIL_FORMAT', 'users', null, ['email' => $_POST['email']]);
|
|
echo json_encode(['status' => 'error', 'message' => 'Invalid email format.']);
|
|
exit();
|
|
}
|
|
|
|
// Retrieve and sanitize password
|
|
$password = isset($_POST['password']) ? trim($_POST['password']) : '';
|
|
|
|
// Basic validation
|
|
if (empty($email) || empty($password)) {
|
|
echo json_encode(['status' => 'error', 'message' => 'Please enter both email and password.']);
|
|
exit();
|
|
}
|
|
|
|
// Check for account lockout
|
|
$lockoutStatus = checkAccountLockout($email);
|
|
if ($lockoutStatus['is_locked']) {
|
|
auditLog(null, 'LOGIN_ATTEMPT_LOCKED_ACCOUNT', 'users', null, [
|
|
'email' => $email,
|
|
'locked_until' => $lockoutStatus['locked_until']
|
|
]);
|
|
echo json_encode([
|
|
'status' => 'error',
|
|
'message' => 'Account is temporarily locked due to multiple failed login attempts. Please try again in ' . $lockoutStatus['minutes_remaining'] . ' minutes.'
|
|
]);
|
|
exit();
|
|
}
|
|
|
|
// Check recent failed attempts
|
|
$recentFailedAttempts = countRecentFailedAttempts($email);
|
|
if ($recentFailedAttempts >= 5) {
|
|
// Lock account for 15 minutes
|
|
lockAccount($email, 15);
|
|
auditLog(null, 'ACCOUNT_LOCKED_THRESHOLD', 'users', null, [
|
|
'email' => $email,
|
|
'failed_attempts' => $recentFailedAttempts
|
|
]);
|
|
echo json_encode([
|
|
'status' => 'error',
|
|
'message' => 'Account locked due to multiple failed login attempts. Please try again in 15 minutes.'
|
|
]);
|
|
exit();
|
|
}
|
|
|
|
// Prepare SQL statement to fetch user details
|
|
$query = "SELECT * FROM users WHERE email = ?";
|
|
$stmt = $conn->prepare($query);
|
|
|
|
if (!$stmt) {
|
|
echo json_encode(['status' => 'error', 'message' => 'Database query preparation failed.']);
|
|
exit();
|
|
}
|
|
|
|
$stmt->bind_param("s", $email);
|
|
$stmt->execute();
|
|
$result = $stmt->get_result();
|
|
|
|
// Check if user exists and verify password
|
|
if ($result->num_rows == 1) {
|
|
$row = $result->fetch_assoc();
|
|
|
|
// Check if the user is verified
|
|
if ($row['is_verified'] == 0) {
|
|
recordLoginAttempt($email, false);
|
|
auditLog(null, 'LOGIN_ATTEMPT_UNVERIFIED_ACCOUNT', 'users', $row['user_id']);
|
|
echo json_encode(['status' => 'error', 'message' => 'Your account is not verified. Please check your email for the verification link.']);
|
|
exit();
|
|
}
|
|
|
|
if (password_verify($password, $row['password'])) {
|
|
// Record successful attempt
|
|
recordLoginAttempt($email, true);
|
|
|
|
// Regenerate session ID to prevent session fixation attacks
|
|
session_regenerate_id(true);
|
|
|
|
// Password is correct, set up session
|
|
$_SESSION['user_id'] = $row['user_id'];
|
|
$_SESSION['first_name'] = $row['first_name'];
|
|
$_SESSION['profile_pic'] = $row['profile_pic'];
|
|
|
|
// Set session timeout (30 minutes)
|
|
$_SESSION['login_time'] = time();
|
|
$_SESSION['session_timeout'] = 1800; // 30 minutes in seconds
|
|
|
|
// Send Notification
|
|
$event = 'user_login';
|
|
$sub_feed = 'logins';
|
|
$data = [
|
|
'actor_id' => $_SESSION['user_id'],
|
|
'actor_avatar' => $_SESSION['profile_pic'], // used by UI to show avatar
|
|
'title' => "User Login by {$row['first_name']} {$row['last_name']}"
|
|
];
|
|
addNotification(null, $event, $sub_feed, $data, null);
|
|
|
|
auditLog($row['user_id'], 'LOGIN_SUCCESS', 'users', $row['user_id']);
|
|
echo json_encode(['status' => 'success', 'message' => 'Successful Login']);
|
|
} else {
|
|
// Password is incorrect - record failed attempt
|
|
recordLoginAttempt($email, false);
|
|
auditLog(null, 'LOGIN_FAILED_INVALID_PASSWORD', 'users', null, ['email' => $email]);
|
|
|
|
// Check if this was the threshold failure
|
|
$newFailureCount = countRecentFailedAttempts($email);
|
|
if ($newFailureCount >= 5) {
|
|
lockAccount($email, 15);
|
|
echo json_encode([
|
|
'status' => 'error',
|
|
'message' => 'Too many failed login attempts. Account locked for 15 minutes.'
|
|
]);
|
|
} else {
|
|
$attemptsRemaining = 5 - $newFailureCount;
|
|
echo json_encode([
|
|
'status' => 'error',
|
|
'message' => 'Invalid password. ' . $attemptsRemaining . ' attempts remaining before account lockout.'
|
|
]);
|
|
}
|
|
}
|
|
} else {
|
|
// User does not exist - still record attempt
|
|
recordLoginAttempt($email, false);
|
|
auditLog(null, 'LOGIN_FAILED_USER_NOT_FOUND', 'users', null, ['email' => $email]);
|
|
echo json_encode(['status' => 'error', 'message' => 'User with that email does not exist.']);
|
|
}
|
|
|
|
// Close the statement and connection
|
|
$stmt->close();
|
|
}
|
|
|
|
// Close connection
|
|
$conn->close();
|
|
exit();
|
|
?>
|