You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
418 lines
13 KiB
418 lines
13 KiB
<?php |
|
|
|
namespace App\Services; |
|
|
|
use App\Models\AuditLog; |
|
use App\Support\ApiActor; |
|
use Illuminate\Http\Request; |
|
use Illuminate\Support\Facades\Schema; |
|
use Throwable; |
|
|
|
class AuditLogService |
|
{ |
|
public function __construct( |
|
private readonly ClientContextService $clientContext |
|
) { |
|
} |
|
|
|
public function isAvailable(): bool |
|
{ |
|
try { |
|
return Schema::connection('audit')->hasTable('audit_logs'); |
|
} catch (Throwable) { |
|
return false; |
|
} |
|
} |
|
|
|
public function log( |
|
string $action, |
|
string $actorType = 'web', |
|
?string $actorUsername = null, |
|
?string $actorClient = null, |
|
?string $resource = null, |
|
?Request $request = null, |
|
?int $statusCode = null, |
|
array $metadata = [] |
|
): void { |
|
if (! $this->isAvailable()) { |
|
return; |
|
} |
|
|
|
try { |
|
$ipInfo = $request ? $this->clientIpInfo($request) : ['ip' => null, 'scope' => 'unknown']; |
|
|
|
$metadata = $this->enrichMetadata($request, $metadata, $ipInfo['scope']); |
|
|
|
if ($request?->header('X-Forwarded-For')) { |
|
$metadata['forwarded_for'] = $request->header('X-Forwarded-For'); |
|
} |
|
|
|
AuditLog::query()->create([ |
|
'occurred_at' => now(), |
|
'actor_type' => $actorType, |
|
'actor_username' => $actorUsername, |
|
'actor_client' => $actorClient, |
|
'action' => $action, |
|
'resource' => $resource, |
|
'method' => $request?->method(), |
|
'path' => $request ? $request->path() : null, |
|
'status_code' => $statusCode, |
|
'ip_address' => $ipInfo['ip'], |
|
'user_agent' => $request?->userAgent(), |
|
'metadata' => $metadata, |
|
]); |
|
} catch (Throwable) { |
|
// Audit must not break main flow. |
|
} |
|
} |
|
|
|
public function logWeb(string $action, ?string $resource = null, ?Request $request = null, array $metadata = []): void |
|
{ |
|
$this->log( |
|
action: $action, |
|
actorType: 'web', |
|
actorUsername: session('ldap_username'), |
|
resource: $resource, |
|
request: $request ?? request(), |
|
metadata: $metadata |
|
); |
|
} |
|
|
|
public function logPublic(string $action, ?string $resource = null, ?Request $request = null, array $metadata = []): void |
|
{ |
|
$this->log( |
|
action: $action, |
|
actorType: 'web', |
|
actorUsername: $metadata['username'] ?? null, |
|
resource: $resource, |
|
request: $request ?? request(), |
|
metadata: $metadata |
|
); |
|
} |
|
|
|
public function crudActionFromRoute(Request $request): string |
|
{ |
|
$routeName = $request->route()?->getName() ?? 'web.unknown'; |
|
|
|
return $routeName; |
|
} |
|
|
|
public function crudVerb(string $method): string |
|
{ |
|
return match (strtoupper($method)) { |
|
'POST' => 'create', |
|
'PUT', 'PATCH' => 'update', |
|
'DELETE' => 'delete', |
|
'GET' => 'read', |
|
default => strtolower($method), |
|
}; |
|
} |
|
|
|
public function resourceFromRequest(Request $request): ?string |
|
{ |
|
$params = $request->route()?->parameters() ?? []; |
|
$parts = []; |
|
|
|
foreach ($params as $param) { |
|
if (is_object($param)) { |
|
if (method_exists($param, 'getRouteKey')) { |
|
$parts[] = (string) $param->getRouteKey(); |
|
} elseif (isset($param->name)) { |
|
$parts[] = (string) $param->name; |
|
} else { |
|
$parts[] = class_basename($param); |
|
} |
|
} else { |
|
$parts[] = (string) $param; |
|
} |
|
} |
|
|
|
return $parts !== [] ? implode('/', $parts) : null; |
|
} |
|
|
|
public function clientIpInfo(Request $request): array |
|
{ |
|
$ip = $request->ip(); |
|
|
|
return [ |
|
'ip' => $ip, |
|
'scope' => $this->classifyIp($ip), |
|
]; |
|
} |
|
|
|
public function classifyIp(?string $ip): string |
|
{ |
|
if ($ip === null || $ip === '') { |
|
return 'unknown'; |
|
} |
|
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { |
|
if (str_starts_with($ip, '127.') || $ip === '0.0.0.0') { |
|
return 'localhost'; |
|
} |
|
|
|
if (preg_match('/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/', $ip)) { |
|
return 'private'; |
|
} |
|
|
|
return 'public'; |
|
} |
|
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { |
|
$lower = strtolower($ip); |
|
|
|
if ($lower === '::1') { |
|
return 'localhost'; |
|
} |
|
|
|
if (str_starts_with($lower, 'fe80:') || str_starts_with($lower, 'fc') || str_starts_with($lower, 'fd')) { |
|
return 'private'; |
|
} |
|
|
|
return 'public'; |
|
} |
|
|
|
return 'unknown'; |
|
} |
|
|
|
public function ipScopeLabel(string $scope): string |
|
{ |
|
return match ($scope) { |
|
'public' => 'Publik', |
|
'private' => 'Lokal (LAN)', |
|
'localhost' => 'Localhost', |
|
default => 'Tidak diketahui', |
|
}; |
|
} |
|
|
|
public function logApi(Request $request, ApiActor $actor, int $statusCode): void |
|
{ |
|
if (! in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { |
|
return; |
|
} |
|
|
|
$routeName = $request->route()?->getName() ?? 'api.request'; |
|
|
|
$this->log( |
|
action: $routeName, |
|
actorType: 'api', |
|
actorUsername: $actor->type === 'ldap' ? $actor->identifier : null, |
|
actorClient: $actor->identifier, |
|
resource: $this->resourceFromRequest($request), |
|
request: $request, |
|
statusCode: $statusCode, |
|
metadata: [ |
|
'crud' => $this->crudVerb($request->method()), |
|
'auth_type' => $actor->type, |
|
'scopes' => $actor->scopes, |
|
'is_admin' => $actor->isAdmin, |
|
'route' => $routeName, |
|
] |
|
); |
|
} |
|
|
|
public function listRecent( |
|
int $limit = 300, |
|
?string $search = null, |
|
?string $actorType = null, |
|
?string $module = null, |
|
?string $ipScope = null |
|
): array { |
|
if (! $this->isAvailable()) { |
|
return ['rows' => [], 'error' => 'Audit database is not available.']; |
|
} |
|
|
|
$query = AuditLog::query()->orderByDesc('occurred_at')->limit($limit); |
|
|
|
if ($search !== null && $search !== '') { |
|
$needle = '%' . $search . '%'; |
|
$query->where(function ($q) use ($needle) { |
|
$q->where('actor_username', 'like', $needle) |
|
->orWhere('actor_client', 'like', $needle) |
|
->orWhere('action', 'like', $needle) |
|
->orWhere('resource', 'like', $needle) |
|
->orWhere('path', 'like', $needle) |
|
->orWhere('ip_address', 'like', $needle); |
|
}); |
|
} |
|
|
|
if ($actorType !== null && $actorType !== '') { |
|
$query->where('actor_type', $actorType); |
|
} |
|
|
|
if ($module !== null && $module !== '') { |
|
$query->where(function ($q) use ($module) { |
|
$q->where('action', 'like', $module.'%') |
|
->orWhere('action', 'like', '%.'.$module.'.%') |
|
->orWhere('action', 'like', $module.'.%'); |
|
}); |
|
} |
|
|
|
if ($ipScope !== null && $ipScope !== '') { |
|
$query->where('metadata->ip_scope', $ipScope); |
|
} |
|
|
|
return [ |
|
'rows' => $query->get()->all(), |
|
'error' => null, |
|
]; |
|
} |
|
|
|
public function enrichMetadata(?Request $request, array $metadata, ?string $ipScope = null): array |
|
{ |
|
if ($ipScope !== null) { |
|
$metadata['ip_scope'] = $ipScope; |
|
} elseif ($request !== null) { |
|
$metadata['ip_scope'] = $this->clientIpInfo($request)['scope']; |
|
} |
|
|
|
if ($request !== null) { |
|
$client = $this->clientContext->fromSession($request); |
|
|
|
if ($client === [] && $request->userAgent()) { |
|
$client = $this->clientContext->parseUserAgent($request->userAgent()); |
|
} |
|
|
|
if ($client !== []) { |
|
$metadata['client'] = $client; |
|
} |
|
} |
|
|
|
return $metadata; |
|
} |
|
|
|
public function logPageView( |
|
Request $request, |
|
string $pageVisitId, |
|
string $path, |
|
?string $title, |
|
?string $referrer, |
|
array $clientContext |
|
): void { |
|
$this->log( |
|
action: 'web.ui.view', |
|
actorType: 'web', |
|
actorUsername: session('ldap_username'), |
|
resource: $path, |
|
request: $request, |
|
metadata: $this->enrichMetadata($request, [ |
|
'page_visit_id' => $pageVisitId, |
|
'page_title' => $title, |
|
'referrer' => $referrer ? mb_substr($referrer, 0, 500) : null, |
|
'client' => $clientContext, |
|
'event' => 'page_view', |
|
]) |
|
); |
|
} |
|
|
|
public function logPageDuration( |
|
Request $request, |
|
string $pageVisitId, |
|
string $path, |
|
int $durationMs, |
|
array $clientContext |
|
): void { |
|
$durationSeconds = (int) round($durationMs / 1000); |
|
|
|
$this->log( |
|
action: 'web.ui.duration', |
|
actorType: 'web', |
|
actorUsername: session('ldap_username'), |
|
resource: $path, |
|
request: $request, |
|
metadata: $this->enrichMetadata($request, [ |
|
'page_visit_id' => $pageVisitId, |
|
'duration_ms' => $durationMs, |
|
'duration_seconds' => $durationSeconds, |
|
'client' => $clientContext, |
|
'event' => 'page_duration', |
|
]) |
|
); |
|
} |
|
|
|
/** |
|
* @return array{page_views: list<array{path: string, count: int}>, page_durations: list<array{path: string, visits: int, avg_seconds: float, total_seconds: int}>} |
|
*/ |
|
public function uiAccessStats(int $days = 7, int $limit = 15): array |
|
{ |
|
if (! $this->isAvailable()) { |
|
return ['page_views' => [], 'page_durations' => []]; |
|
} |
|
|
|
$since = now()->subDays($days); |
|
|
|
$views = AuditLog::query() |
|
->where('action', 'web.ui.view') |
|
->where('occurred_at', '>=', $since) |
|
->get(['resource', 'path']); |
|
|
|
$viewCounts = []; |
|
foreach ($views as $row) { |
|
$path = $row->resource ?? $row->path ?? '/'; |
|
$viewCounts[$path] = ($viewCounts[$path] ?? 0) + 1; |
|
} |
|
arsort($viewCounts); |
|
$pageViews = []; |
|
foreach (array_slice($viewCounts, 0, $limit, true) as $path => $count) { |
|
$pageViews[] = ['path' => $path, 'count' => $count]; |
|
} |
|
|
|
$durations = AuditLog::query() |
|
->where('action', 'web.ui.duration') |
|
->where('occurred_at', '>=', $since) |
|
->get(['resource', 'path', 'metadata']); |
|
|
|
$durationStats = []; |
|
foreach ($durations as $row) { |
|
$path = $row->resource ?? $row->path ?? '/'; |
|
$seconds = (int) ($row->metadata['duration_seconds'] ?? 0); |
|
|
|
if (! isset($durationStats[$path])) { |
|
$durationStats[$path] = ['total' => 0, 'count' => 0]; |
|
} |
|
|
|
$durationStats[$path]['total'] += $seconds; |
|
$durationStats[$path]['count']++; |
|
} |
|
|
|
$pageDurations = []; |
|
foreach ($durationStats as $path => $stat) { |
|
$pageDurations[] = [ |
|
'path' => $path, |
|
'visits' => $stat['count'], |
|
'avg_seconds' => $stat['count'] > 0 ? round($stat['total'] / $stat['count'], 1) : 0, |
|
'total_seconds' => $stat['total'], |
|
]; |
|
} |
|
|
|
usort($pageDurations, fn ($a, $b) => $b['total_seconds'] <=> $a['total_seconds']); |
|
|
|
return [ |
|
'page_views' => $pageViews, |
|
'page_durations' => array_slice($pageDurations, 0, $limit), |
|
]; |
|
} |
|
|
|
public function listForUser(string $username, int $limit = 50): array |
|
{ |
|
if (! $this->isAvailable()) { |
|
return ['rows' => [], 'error' => 'Audit database is not available.']; |
|
} |
|
|
|
$needle = '%' . $username . '%'; |
|
|
|
$rows = AuditLog::query() |
|
->where(function ($q) use ($username, $needle) { |
|
$q->where('actor_username', $username) |
|
->orWhere('resource', $username) |
|
->orWhere('resource', 'like', $needle) |
|
->orWhere('metadata->username', $username); |
|
}) |
|
->orderByDesc('occurred_at') |
|
->limit($limit) |
|
->get() |
|
->all(); |
|
|
|
return ['rows' => $rows, 'error' => null]; |
|
} |
|
}
|
|
|