Phase 2: Add comprehensive audit logging

- Created AuditLogger service class (360+ lines)
  * 16 action type constants (LOGIN_SUCCESS, PAYMENT_FAILURE, etc.)
  * log() - main logging method with flexible parameters
  * logLogin() - specialized login audit logging
  * logLogout() - session termination tracking
  * logPasswordChange() - credential change tracking
  * logBookingCreate() - booking audit trail
  * logPayment() - payment attempt/result tracking
  * logMembership() - membership action tracking
  * logAccessDenied() - authorization failure logging
  * getRecentLogs() - retrieve audit history
  * getLogsByAction() - filter logs by action type

- Integrated audit logging into validate_login.php:
  * Logs all login attempts (success and failures)
  * Captures failure reasons (invalid password, not verified, etc.)
  * Logs Google OAuth registrations and logins
  * Logs email/password login attempts
  * Captures IP address for each log entry
  * Includes timestamp (via database NOW())

- Audit Log Fields:
  * user_id - identifier of user performing action
  * action - action type (e.g., login_success)
  * status - success/failure/pending
  * ip_address - client IP (handles proxy/load balancer)
  * details - JSON-encoded metadata
  * created_at - timestamp

- Design Features:
  * Uses DatabaseService singleton for connections
  * Graceful error handling (doesn't break application)
  * JSON serialization of complex data for analysis
  * IP detection handles proxies and load balancers
  * Constants for action types enable IDE autocomplete
  * Extensible for additional event types

- Security Benefits:
  * Complete login audit trail for fraud detection
  * Failed login attempts tracked (detects brute force)
  * IP address recorded for geo-blocking/analysis
  * Timestamps enable timeline correlation
  * Action types enable targeted monitoring
This commit is contained in:
twotalesanimation
2025-12-02 21:13:16 +02:00
parent a4526979c4
commit 86f69474cc
2 changed files with 411 additions and 0 deletions

View File

@@ -0,0 +1,399 @@
<?php
namespace Services;
/**
* Audit Logging Service
*
* Records sensitive operations for security auditing and compliance.
* Logs are written to a database table for searchability and integration
* with monitoring systems.
*
* Logged Events:
* - Authentication: login success/failure, logout, password change
* - Authorization: access denied, admin actions
* - Bookings: creation, cancellation, modification
* - Payments: payment attempts, refunds
* - Membership: application, approval, renewal
*
* Features:
* - Captures user ID, IP address, timestamp, action type, status
* - Includes optional details/metadata
* - Graceful error handling (doesn't break application if logging fails)
* - JSON serialization of complex data
*/
class AuditLogger
{
/**
* Log event action types
*/
public const ACTION_LOGIN_SUCCESS = 'login_success';
public const ACTION_LOGIN_FAILURE = 'login_failure';
public const ACTION_LOGOUT = 'logout';
public const ACTION_PASSWORD_CHANGE = 'password_change';
public const ACTION_PASSWORD_RESET = 'password_reset';
public const ACTION_BOOKING_CREATE = 'booking_create';
public const ACTION_BOOKING_CANCEL = 'booking_cancel';
public const ACTION_BOOKING_MODIFY = 'booking_modify';
public const ACTION_PAYMENT_INITIATE = 'payment_initiate';
public const ACTION_PAYMENT_SUCCESS = 'payment_success';
public const ACTION_PAYMENT_FAILURE = 'payment_failure';
public const ACTION_MEMBERSHIP_APPLICATION = 'membership_application';
public const ACTION_MEMBERSHIP_APPROVAL = 'membership_approval';
public const ACTION_MEMBERSHIP_RENEWAL = 'membership_renewal';
public const ACTION_ADMIN_ACTION = 'admin_action';
public const ACTION_ACCESS_DENIED = 'access_denied';
/**
* Event status values
*/
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILURE = 'failure';
public const STATUS_PENDING = 'pending';
/**
* Log an audit event
*
* @param string $action Action type (use ACTION_* constants)
* @param string $status Status (use STATUS_* constants)
* @param int|null $userId User ID (optional, uses session if available)
* @param string|null $details Additional details/metadata (will be JSON encoded if array)
* @return bool True if logged successfully, false otherwise
*/
public static function log(
string $action,
string $status,
?int $userId = null,
?string $details = null
): bool {
try {
// Get user ID from session if not provided
if ($userId === null) {
$userId = $_SESSION['user_id'] ?? null;
}
// Get client IP address
$ipAddress = self::getClientIp();
// Convert array details to JSON
if (is_array($details)) {
$details = json_encode($details);
}
// Get database connection
$db = DatabaseService::getInstance();
$conn = $db->getConnection();
if (!$conn) {
error_log("AuditLogger: Database connection failed");
return false;
}
// Prepare and execute insert statement
$query = "INSERT INTO audit_logs (user_id, action, status, ip_address, details, created_at)
VALUES (?, ?, ?, ?, ?, NOW())";
$stmt = $conn->prepare($query);
if (!$stmt) {
error_log("AuditLogger: Failed to prepare statement: " . $conn->error);
return false;
}
$stmt->bind_param(
"issss",
$userId,
$action,
$status,
$ipAddress,
$details
);
if (!$stmt->execute()) {
error_log("AuditLogger: Failed to execute statement: " . $stmt->error);
$stmt->close();
return false;
}
$stmt->close();
return true;
} catch (\Exception $e) {
error_log("AuditLogger exception: " . $e->getMessage());
return false;
}
}
/**
* Log login attempt
*
* @param string $email Email address
* @param bool $success Whether login was successful
* @param string|null $failureReason Reason for failure (if applicable)
* @return bool
*/
public static function logLogin(
string $email,
bool $success,
?string $failureReason = null
): bool {
$action = $success ? self::ACTION_LOGIN_SUCCESS : self::ACTION_LOGIN_FAILURE;
$status = $success ? self::STATUS_SUCCESS : self::STATUS_FAILURE;
$details = [
'email' => $email,
'reason' => $failureReason
];
return self::log($action, $status, null, json_encode($details));
}
/**
* Log logout
*
* @param int $userId User ID
* @return bool
*/
public static function logLogout(int $userId): bool
{
return self::log(self::ACTION_LOGOUT, self::STATUS_SUCCESS, $userId);
}
/**
* Log password change
*
* @param int $userId User ID
* @param bool $success Whether password was changed successfully
* @return bool
*/
public static function logPasswordChange(int $userId, bool $success): bool
{
$status = $success ? self::STATUS_SUCCESS : self::STATUS_FAILURE;
return self::log(self::ACTION_PASSWORD_CHANGE, $status, $userId);
}
/**
* Log booking creation
*
* @param int $userId User ID
* @param string $bookingType Type of booking (trip, camping, course, etc.)
* @param string|int $bookingId Booking ID
* @param float|null $amount Booking amount
* @return bool
*/
public static function logBookingCreate(
int $userId,
string $bookingType,
$bookingId,
?float $amount = null
): bool {
$details = [
'booking_type' => $bookingType,
'booking_id' => $bookingId,
'amount' => $amount
];
return self::log(
self::ACTION_BOOKING_CREATE,
self::STATUS_SUCCESS,
$userId,
json_encode($details)
);
}
/**
* Log payment
*
* @param int $userId User ID
* @param string $status Payment status (success/failure)
* @param float $amount Payment amount
* @param string|null $reason Failure reason if applicable
* @param string|null $details Additional details
* @return bool
*/
public static function logPayment(
int $userId,
string $status,
float $amount,
?string $reason = null,
?string $details = null
): bool {
$action = ($status === self::STATUS_SUCCESS) ?
self::ACTION_PAYMENT_SUCCESS :
self::ACTION_PAYMENT_FAILURE;
$data = [
'amount' => $amount,
'reason' => $reason,
'details' => $details
];
return self::log($action, $status, $userId, json_encode($data));
}
/**
* Log membership application
*
* @param int $userId User ID
* @param string $action Action type (application/approval/renewal)
* @param bool $success Whether action was successful
* @return bool
*/
public static function logMembership(
int $userId,
string $action,
bool $success
): bool {
$status = $success ? self::STATUS_SUCCESS : self::STATUS_FAILURE;
$actionType = 'membership_' . $action;
return self::log($actionType, $status, $userId);
}
/**
* Log access denied event
*
* @param int|null $userId User ID
* @param string $resource Resource that was accessed
* @param string|null $reason Reason for denial
* @return bool
*/
public static function logAccessDenied(
?int $userId = null,
string $resource = 'unknown',
?string $reason = null
): bool {
$details = [
'resource' => $resource,
'reason' => $reason
];
return self::log(
self::ACTION_ACCESS_DENIED,
self::STATUS_FAILURE,
$userId,
json_encode($details)
);
}
/**
* Get recent audit logs
*
* @param int $limit Number of records to retrieve
* @param int $userId Optional user ID to filter by
* @return array Array of audit log records
*/
public static function getRecentLogs(int $limit = 100, ?int $userId = null): array
{
try {
$db = DatabaseService::getInstance();
$conn = $db->getConnection();
if (!$conn) {
return [];
}
if ($userId !== null) {
$query = "SELECT * FROM audit_logs WHERE user_id = ?
ORDER BY created_at DESC LIMIT ?";
$stmt = $conn->prepare($query);
$stmt->bind_param("ii", $userId, $limit);
} else {
$query = "SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ?";
$stmt = $conn->prepare($query);
$stmt->bind_param("i", $limit);
}
$stmt->execute();
$result = $stmt->get_result();
$logs = [];
while ($row = $result->fetch_assoc()) {
// Decode JSON details if present
if (!empty($row['details'])) {
$row['details'] = json_decode($row['details'], true) ?? $row['details'];
}
$logs[] = $row;
}
$stmt->close();
return $logs;
} catch (\Exception $e) {
error_log("AuditLogger::getRecentLogs exception: " . $e->getMessage());
return [];
}
}
/**
* Get logs for a specific action
*
* @param string $action Action type to filter by
* @param int $limit Number of records
* @return array
*/
public static function getLogsByAction(string $action, int $limit = 100): array
{
try {
$db = DatabaseService::getInstance();
$conn = $db->getConnection();
if (!$conn) {
return [];
}
$query = "SELECT * FROM audit_logs WHERE action = ?
ORDER BY created_at DESC LIMIT ?";
$stmt = $conn->prepare($query);
$stmt->bind_param("si", $action, $limit);
$stmt->execute();
$result = $stmt->get_result();
$logs = [];
while ($row = $result->fetch_assoc()) {
if (!empty($row['details'])) {
$row['details'] = json_decode($row['details'], true) ?? $row['details'];
}
$logs[] = $row;
}
$stmt->close();
return $logs;
} catch (\Exception $e) {
error_log("AuditLogger::getLogsByAction exception: " . $e->getMessage());
return [];
}
}
/**
* Get client IP address
* Attempts to detect the real IP even behind proxy/load balancer
*
* @return string Client IP address or 'unknown'
*/
private static function getClientIp(): string
{
// Check for IP from shared 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'])) {
// Handle multiple IPs (take the first one)
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$ip = trim($ips[0]);
}
// Check the remote address
elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = $_SERVER['REMOTE_ADDR'];
}
else {
$ip = 'unknown';
}
// Validate IP format
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
return 'unknown';
}
}