Files
app/api/includes/AuditLog.php
Simon bb2bbb8ff6 feat: mobilni responsivita, testy, klavesove zkratky, drag & drop, univerzalizace
- Mobile responsive CSS (touch targets 44px, iOS anti-zoom, reduced motion)
- Vitest setup s 39 testy (formatters, attendanceHelpers, useTableSort)
- Klavesove zkratky (Shift+? napoveda, Ctrl+S ulozit, navigace)
- Drag & drop pro polozky nabidek (@dnd-kit, SortableRow, useSortableList)
- Univerzalizace: odstraneni BOHA brandingu z UI, emailu, PDF
- Smazany nepotrebne soubory (deploy.sh, AUTH_SYSTEM.md, example_design, .htaccess)
- CORS konfigurovatelny pres env promennou

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:33:37 +01:00

555 lines
16 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 *
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 *
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 *
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 [];
}
}
}