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:
399
src/Services/AuditLogger.php
Normal file
399
src/Services/AuditLogger.php
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user