- SELECT * nahrazen explicitnimi sloupci ve 22 PHP souborech (69+ vyskytu) - users-handlers.php: password_hash explicitne vyloucen z dotazu - Overdue detekce presunuta do invoices.php routeru (1x pred dispatch misto 3x v handlerech) - Validator.php: validacni helper s pravidly required, string, int, email, in, numeric - PaginationHelper: PHPStan typy opraveny Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
561 lines
17 KiB
PHP
561 lines
17 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Audit Logging System
|
|
*
|
|
* Comprehensive audit trail for all administrative actions
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
class AuditLog
|
|
{
|
|
// Action types
|
|
public const ACTION_LOGIN = 'login';
|
|
public const ACTION_LOGIN_FAILED = 'login_failed';
|
|
public const ACTION_LOGOUT = 'logout';
|
|
public const ACTION_CREATE = 'create';
|
|
public const ACTION_UPDATE = 'update';
|
|
public const ACTION_DELETE = 'delete';
|
|
public const ACTION_VIEW = 'view';
|
|
public const ACTION_ACTIVATE = 'activate';
|
|
public const ACTION_DEACTIVATE = 'deactivate';
|
|
public const ACTION_PASSWORD_CHANGE = 'password_change';
|
|
public const ACTION_PERMISSION_CHANGE = 'permission_change';
|
|
public const ACTION_ACCESS_DENIED = 'access_denied';
|
|
|
|
private static ?int $currentUserId = null;
|
|
private static ?string $currentUsername = null;
|
|
|
|
/**
|
|
* Nastaví kontext aktuálního uživatele pro všechny následující logy
|
|
*/
|
|
public static function setUser(int $userId, string $username): void
|
|
{
|
|
self::$currentUserId = $userId;
|
|
self::$currentUsername = $username;
|
|
}
|
|
|
|
/**
|
|
* Log an action
|
|
*
|
|
* @param string $action Action type (use class constants)
|
|
* @param string|null $entityType Entity type (e.g., 'user', 'project')
|
|
* @param int|null $entityId Entity ID
|
|
* @param string|null $description Human-readable description
|
|
* @param array<string, mixed>|null $oldValues Previous values (for updates)
|
|
* @param array<string, mixed>|null $newValues New values (for updates/creates)
|
|
*/
|
|
public static function log(
|
|
string $action,
|
|
?string $entityType = null,
|
|
?int $entityId = null,
|
|
?string $description = null,
|
|
?array $oldValues = null,
|
|
?array $newValues = null
|
|
): void {
|
|
try {
|
|
$pdo = db();
|
|
|
|
$userId = self::$currentUserId;
|
|
$username = self::$currentUsername;
|
|
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO audit_logs (
|
|
user_id,
|
|
username,
|
|
user_ip,
|
|
action,
|
|
entity_type,
|
|
entity_id,
|
|
description,
|
|
old_values,
|
|
new_values,
|
|
user_agent,
|
|
session_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
');
|
|
|
|
$stmt->execute([
|
|
$userId,
|
|
$username,
|
|
getClientIp(),
|
|
$action,
|
|
$entityType,
|
|
$entityId,
|
|
$description,
|
|
$oldValues ? json_encode($oldValues, JSON_UNESCAPED_UNICODE) : null,
|
|
$newValues ? json_encode($newValues, JSON_UNESCAPED_UNICODE) : null,
|
|
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
|
session_id() ?: null,
|
|
]);
|
|
} catch (PDOException $e) {
|
|
error_log('AuditLog error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log successful login
|
|
*
|
|
* @param int $userId User ID
|
|
* @param string $username Username
|
|
*/
|
|
public static function logLogin(int $userId, string $username): void
|
|
{
|
|
try {
|
|
$pdo = db();
|
|
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO audit_logs (
|
|
user_id,
|
|
username,
|
|
user_ip,
|
|
action,
|
|
entity_type,
|
|
entity_id,
|
|
description,
|
|
user_agent,
|
|
session_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
');
|
|
|
|
$stmt->execute([
|
|
$userId,
|
|
$username,
|
|
getClientIp(),
|
|
self::ACTION_LOGIN,
|
|
'user',
|
|
$userId,
|
|
"Přihlášení uživatele '$username'",
|
|
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
|
session_id() ?: null,
|
|
]);
|
|
} catch (PDOException $e) {
|
|
error_log('AuditLog login error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log failed login attempt
|
|
*
|
|
* @param string $username Attempted username
|
|
* @param string $reason Failure reason
|
|
*/
|
|
public static function logLoginFailed(string $username, string $reason = 'invalid_credentials'): void
|
|
{
|
|
try {
|
|
$pdo = db();
|
|
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO audit_logs (
|
|
username,
|
|
user_ip,
|
|
action,
|
|
entity_type,
|
|
description,
|
|
user_agent,
|
|
session_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
');
|
|
|
|
$stmt->execute([
|
|
$username,
|
|
getClientIp(),
|
|
self::ACTION_LOGIN_FAILED,
|
|
'user',
|
|
"Neúspěšné přihlášení '$username': $reason",
|
|
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
|
session_id() ?: null,
|
|
]);
|
|
} catch (PDOException $e) {
|
|
error_log('AuditLog login failed error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log logout
|
|
*
|
|
* @param int|null $userId User ID (optional, for JWT-based auth)
|
|
* @param string|null $username Username (optional, for JWT-based auth)
|
|
*/
|
|
public static function logLogout(?int $userId = null, ?string $username = null): void
|
|
{
|
|
if ($userId === null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$pdo = db();
|
|
|
|
$stmt = $pdo->prepare('
|
|
INSERT INTO audit_logs (
|
|
user_id,
|
|
username,
|
|
user_ip,
|
|
action,
|
|
entity_type,
|
|
entity_id,
|
|
description,
|
|
user_agent
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
');
|
|
|
|
$stmt->execute([
|
|
$userId,
|
|
$username,
|
|
getClientIp(),
|
|
self::ACTION_LOGOUT,
|
|
'user',
|
|
$userId,
|
|
"Odhlášení uživatele '{$username}'",
|
|
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
|
]);
|
|
} catch (PDOException $e) {
|
|
error_log('AuditLog logout error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log entity creation
|
|
*
|
|
* @param string $entityType Entity type
|
|
* @param int $entityId Entity ID
|
|
* @param array<string, mixed> $data Created data
|
|
* @param string|null $description Optional description
|
|
*/
|
|
public static function logCreate(
|
|
string $entityType,
|
|
int $entityId,
|
|
array $data,
|
|
?string $description = null
|
|
): void {
|
|
// Remove sensitive fields from logged data
|
|
$safeData = self::sanitizeData($data);
|
|
|
|
self::log(
|
|
self::ACTION_CREATE,
|
|
$entityType,
|
|
$entityId,
|
|
$description ?? "Vytvořen $entityType #$entityId",
|
|
null,
|
|
$safeData
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Log entity update
|
|
*
|
|
* @param string $entityType Entity type
|
|
* @param int $entityId Entity ID
|
|
* @param array<string, mixed> $oldData Old values
|
|
* @param array<string, mixed> $newData New values
|
|
* @param string|null $description Optional description
|
|
*/
|
|
public static function logUpdate(
|
|
string $entityType,
|
|
int $entityId,
|
|
array $oldData,
|
|
array $newData,
|
|
?string $description = null
|
|
): void {
|
|
// Only log changed fields
|
|
$changes = self::getChanges($oldData, $newData);
|
|
|
|
if (empty($changes['old']) && empty($changes['new'])) {
|
|
return; // No actual changes
|
|
}
|
|
|
|
self::log(
|
|
self::ACTION_UPDATE,
|
|
$entityType,
|
|
$entityId,
|
|
$description ?? "Upraven $entityType #$entityId",
|
|
$changes['old'],
|
|
$changes['new']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Log entity deletion
|
|
*
|
|
* @param string $entityType Entity type
|
|
* @param int $entityId Entity ID
|
|
* @param array<string, mixed>|null $data Deleted entity data
|
|
* @param string|null $description Optional description
|
|
*/
|
|
public static function logDelete(
|
|
string $entityType,
|
|
int $entityId,
|
|
?array $data = null,
|
|
?string $description = null
|
|
): void {
|
|
$safeData = $data ? self::sanitizeData($data) : null;
|
|
|
|
self::log(
|
|
self::ACTION_DELETE,
|
|
$entityType,
|
|
$entityId,
|
|
$description ?? "Smazán $entityType #$entityId",
|
|
$safeData,
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Log access denied
|
|
*
|
|
* @param string $resource Resource that was denied
|
|
* @param string|null $permission Required permission
|
|
*/
|
|
public static function logAccessDenied(string $resource, ?string $permission = null): void
|
|
{
|
|
$description = "Přístup odepřen k '$resource'";
|
|
if ($permission) {
|
|
$description .= " (vyžaduje: $permission)";
|
|
}
|
|
|
|
self::log(
|
|
self::ACTION_ACCESS_DENIED,
|
|
null,
|
|
null,
|
|
$description
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get changes between old and new data
|
|
*
|
|
* @param array<string, mixed> $oldData Old values
|
|
* @param array<string, mixed> $newData New values
|
|
* @return array{old: array<string, mixed>, new: array<string, mixed>}
|
|
*/
|
|
private static function getChanges(array $oldData, array $newData): array
|
|
{
|
|
$oldData = self::sanitizeData($oldData);
|
|
$newData = self::sanitizeData($newData);
|
|
|
|
$changedOld = [];
|
|
$changedNew = [];
|
|
|
|
// Find changed fields
|
|
foreach ($newData as $key => $newValue) {
|
|
$oldValue = $oldData[$key] ?? null;
|
|
|
|
if ($oldValue !== $newValue) {
|
|
$changedOld[$key] = $oldValue;
|
|
$changedNew[$key] = $newValue;
|
|
}
|
|
}
|
|
|
|
// Find removed fields
|
|
foreach ($oldData as $key => $oldValue) {
|
|
if (!array_key_exists($key, $newData)) {
|
|
$changedOld[$key] = $oldValue;
|
|
$changedNew[$key] = null;
|
|
}
|
|
}
|
|
|
|
return ['old' => $changedOld, 'new' => $changedNew];
|
|
}
|
|
|
|
/**
|
|
* Remove sensitive fields from data before logging
|
|
*
|
|
* @param array<string, mixed> $data Data to sanitize
|
|
* @return array<string, mixed> Sanitized data
|
|
*/
|
|
private static function sanitizeData(array $data): array
|
|
{
|
|
$sensitiveFields = [
|
|
'password',
|
|
'password_hash',
|
|
'token',
|
|
'token_hash',
|
|
'secret',
|
|
'api_key',
|
|
'private_key',
|
|
'csrf_token',
|
|
];
|
|
|
|
foreach ($sensitiveFields as $field) {
|
|
if (isset($data[$field])) {
|
|
$data[$field] = '[REDACTED]';
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Get audit logs with filtering and pagination
|
|
*
|
|
* @param array<string, mixed> $filters Filter options
|
|
* @param int $page Page number (1-based)
|
|
* @param int $perPage Items per page
|
|
* @return array{logs: list<array<string, mixed>>, total: int, pages: int, page: int, per_page: int}
|
|
*/
|
|
public static function getLogs(array $filters = [], int $page = 1, int $perPage = 50): array
|
|
{
|
|
try {
|
|
$pdo = db();
|
|
|
|
$where = [];
|
|
$params = [];
|
|
|
|
// Apply filters
|
|
if (!empty($filters['user_id'])) {
|
|
$where[] = 'user_id = ?';
|
|
$params[] = $filters['user_id'];
|
|
}
|
|
|
|
if (!empty($filters['username'])) {
|
|
$where[] = 'username LIKE ?';
|
|
$params[] = '%' . $filters['username'] . '%';
|
|
}
|
|
|
|
if (!empty($filters['action'])) {
|
|
$where[] = 'action = ?';
|
|
$params[] = $filters['action'];
|
|
}
|
|
|
|
if (!empty($filters['entity_type'])) {
|
|
$where[] = 'entity_type = ?';
|
|
$params[] = $filters['entity_type'];
|
|
}
|
|
|
|
if (!empty($filters['ip'])) {
|
|
$where[] = 'user_ip LIKE ?';
|
|
$params[] = '%' . $filters['ip'] . '%';
|
|
}
|
|
|
|
if (!empty($filters['date_from'])) {
|
|
$where[] = 'created_at >= ?';
|
|
$params[] = $filters['date_from'] . ' 00:00:00';
|
|
}
|
|
|
|
if (!empty($filters['date_to'])) {
|
|
$where[] = 'created_at <= ?';
|
|
$params[] = $filters['date_to'] . ' 23:59:59';
|
|
}
|
|
|
|
if (!empty($filters['search'])) {
|
|
$where[] = '(description LIKE ? OR username LIKE ?)';
|
|
$searchTerm = '%' . $filters['search'] . '%';
|
|
$params[] = $searchTerm;
|
|
$params[] = $searchTerm;
|
|
}
|
|
|
|
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
|
|
|
// Count total
|
|
$countSql = "SELECT COUNT(*) FROM audit_logs $whereClause";
|
|
$stmt = $pdo->prepare($countSql);
|
|
$stmt->execute($params);
|
|
$total = (int) $stmt->fetchColumn();
|
|
|
|
// Calculate pagination
|
|
$pages = max(1, ceil($total / $perPage));
|
|
$page = max(1, min($page, $pages));
|
|
$offset = ($page - 1) * $perPage;
|
|
|
|
// Get logs
|
|
$sql = "
|
|
SELECT id, user_id, username, user_ip, action,
|
|
entity_type, entity_id, description,
|
|
old_values, new_values, created_at
|
|
FROM audit_logs
|
|
$whereClause
|
|
ORDER BY created_at DESC
|
|
LIMIT $perPage OFFSET $offset
|
|
";
|
|
|
|
$stmt = $pdo->prepare($sql);
|
|
$stmt->execute($params);
|
|
$logs = $stmt->fetchAll();
|
|
|
|
// Parse JSON fields
|
|
foreach ($logs as &$log) {
|
|
$log['old_values'] = $log['old_values'] ? json_decode($log['old_values'], true) : null;
|
|
$log['new_values'] = $log['new_values'] ? json_decode($log['new_values'], true) : null;
|
|
}
|
|
|
|
return [
|
|
'logs' => $logs,
|
|
'total' => $total,
|
|
'pages' => $pages,
|
|
'page' => $page,
|
|
'per_page' => $perPage,
|
|
];
|
|
} catch (PDOException $e) {
|
|
error_log('AuditLog getLogs error: ' . $e->getMessage());
|
|
return ['logs' => [], 'total' => 0, 'pages' => 0, 'page' => 1, 'per_page' => $perPage];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get recent activity for a user
|
|
*
|
|
* @param int $userId User ID
|
|
* @param int $limit Number of records
|
|
* @return list<array<string, mixed>> Recent logs
|
|
*/
|
|
public static function getUserActivity(int $userId, int $limit = 10): array
|
|
{
|
|
try {
|
|
$pdo = db();
|
|
|
|
$stmt = $pdo->prepare('
|
|
SELECT id, user_id, username, user_ip, action,
|
|
entity_type, entity_id, description,
|
|
old_values, new_values, created_at
|
|
FROM audit_logs
|
|
WHERE user_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
');
|
|
$stmt->execute([$userId, $limit]);
|
|
|
|
return $stmt->fetchAll();
|
|
} catch (PDOException $e) {
|
|
error_log('AuditLog getUserActivity error: ' . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get entity history
|
|
*
|
|
* @param string $entityType Entity type
|
|
* @param int $entityId Entity ID
|
|
* @return list<array<string, mixed>> Audit log history
|
|
*/
|
|
public static function getEntityHistory(string $entityType, int $entityId): array
|
|
{
|
|
try {
|
|
$pdo = db();
|
|
|
|
$stmt = $pdo->prepare('
|
|
SELECT id, user_id, username, user_ip, action,
|
|
entity_type, entity_id, description,
|
|
old_values, new_values, created_at
|
|
FROM audit_logs
|
|
WHERE entity_type = ? AND entity_id = ?
|
|
ORDER BY created_at DESC
|
|
');
|
|
$stmt->execute([$entityType, $entityId]);
|
|
|
|
$logs = $stmt->fetchAll();
|
|
|
|
foreach ($logs as &$log) {
|
|
$log['old_values'] = $log['old_values'] ? json_decode($log['old_values'], true) : null;
|
|
$log['new_values'] = $log['new_values'] ? json_decode($log['new_values'], true) : null;
|
|
}
|
|
|
|
return $logs;
|
|
} catch (PDOException $e) {
|
|
error_log('AuditLog getEntityHistory error: ' . $e->getMessage());
|
|
return [];
|
|
}
|
|
}
|
|
}
|