211 lines
8.2 KiB
PHP
211 lines
8.2 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
use App\Models\LoginEvent;
|
|
use App\Models\OAuthAuthorizationEvent;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Laravel\Passport\Passport;
|
|
use Laravel\Passport\Token;
|
|
|
|
/**
|
|
* Agregación compartida por Admin\AnalyticsController (sesión) y
|
|
* Api\AnalyticsController (token) — misma data, dos superficies de auth.
|
|
*/
|
|
class AnalyticsSummary
|
|
{
|
|
public static function build(int $days = 30): array
|
|
{
|
|
$since = Carbon::now()->subDays($days - 1)->startOfDay();
|
|
|
|
return [
|
|
'kpis' => [
|
|
'total_users' => User::count(),
|
|
'active_sessions' => Token::query()->where('revoked', false)->where('expires_at', '>', now())->count(),
|
|
'logins_today' => LoginEvent::successful()->whereDate('created_at', today())->count(),
|
|
'logins_7d' => LoginEvent::successful()->where('created_at', '>=', now()->subDays(7))->count(),
|
|
'login_failures_7d' => LoginEvent::where('type', 'session')->where('outcome', 'failure')->where('created_at', '>=', now()->subDays(7))->count(),
|
|
'login_failure_rate_7d' => self::loginFailureRate(now()->subDays(7)),
|
|
],
|
|
'logins_by_day' => self::loginsByDay($since, $days),
|
|
'logins_by_app' => self::loginsByApp($since),
|
|
'top_users' => self::topUsers($since),
|
|
'recent' => self::recent(),
|
|
'oauth_funnel' => self::oauthFunnel($since),
|
|
'oauth_funnel_by_client' => self::oauthFunnelByClient($since),
|
|
'active_tokens_by_client' => self::activeTokensByClient(),
|
|
'scope_usage' => self::scopeUsage($since),
|
|
];
|
|
}
|
|
|
|
private static function loginFailureRate(Carbon $since): float
|
|
{
|
|
$success = LoginEvent::where('type', 'session')->where('outcome', 'success')->where('created_at', '>=', $since)->count();
|
|
$failure = LoginEvent::where('type', 'session')->where('outcome', 'failure')->where('created_at', '>=', $since)->count();
|
|
$total = $success + $failure;
|
|
|
|
return $total === 0 ? 0.0 : round(($failure / $total) * 100, 1);
|
|
}
|
|
|
|
private static function oauthFunnel(Carbon $since): array
|
|
{
|
|
$counts = OAuthAuthorizationEvent::query()
|
|
->where('created_at', '>=', $since)
|
|
->selectRaw('outcome, count(*) as count')
|
|
->groupBy('outcome')
|
|
->pluck('count', 'outcome');
|
|
|
|
$attempted = (int) ($counts['attempted'] ?? 0);
|
|
$approved = (int) ($counts['approved'] ?? 0);
|
|
$denied = (int) ($counts['denied'] ?? 0);
|
|
|
|
return [
|
|
'attempted' => $attempted,
|
|
'approved' => $approved,
|
|
'denied' => $denied,
|
|
'approval_rate' => $attempted === 0 ? 0.0 : round(($approved / $attempted) * 100, 1),
|
|
];
|
|
}
|
|
|
|
private static function oauthFunnelByClient(Carbon $since): array
|
|
{
|
|
return OAuthAuthorizationEvent::query()
|
|
->join('oauth_clients', 'oauth_clients.id', '=', 'oauth_authorization_events.client_id')
|
|
->where('oauth_authorization_events.created_at', '>=', $since)
|
|
->groupBy('oauth_clients.id', 'oauth_clients.name')
|
|
->orderByDesc('attempted')
|
|
->limit(8)
|
|
->get([
|
|
'oauth_clients.name as client_name',
|
|
DB::raw("sum(case when outcome = 'attempted' then 1 else 0 end) as attempted"),
|
|
DB::raw("sum(case when outcome = 'approved' then 1 else 0 end) as approved"),
|
|
DB::raw("sum(case when outcome = 'denied' then 1 else 0 end) as denied"),
|
|
])
|
|
->map(fn ($row) => [
|
|
'client_name' => $row->client_name,
|
|
'attempted' => (int) $row->attempted,
|
|
'approved' => (int) $row->approved,
|
|
'denied' => (int) $row->denied,
|
|
])
|
|
->toArray();
|
|
}
|
|
|
|
private static function activeTokensByClient(): array
|
|
{
|
|
return Token::query()
|
|
->join('oauth_clients', 'oauth_clients.id', '=', 'oauth_access_tokens.client_id')
|
|
->where('oauth_access_tokens.revoked', false)
|
|
->where('oauth_access_tokens.expires_at', '>', now())
|
|
->groupBy('oauth_clients.id', 'oauth_clients.name')
|
|
->orderByDesc('count')
|
|
->limit(8)
|
|
->get(['oauth_clients.name as client_name', DB::raw('count(*) as count')])
|
|
->toArray();
|
|
}
|
|
|
|
private static function scopeUsage(Carbon $since): array
|
|
{
|
|
$descriptions = Passport::scopes()->keyBy('id');
|
|
|
|
$counts = [];
|
|
Token::query()
|
|
->where('oauth_access_tokens.created_at', '>=', $since)
|
|
->where('revoked', false)
|
|
->pluck('scopes')
|
|
->each(function ($raw) use (&$counts) {
|
|
$scopes = is_array($raw) ? $raw : (json_decode($raw ?? '[]', true) ?? []);
|
|
|
|
foreach ($scopes as $scope) {
|
|
$counts[$scope] = ($counts[$scope] ?? 0) + 1;
|
|
}
|
|
});
|
|
|
|
arsort($counts);
|
|
|
|
return collect($counts)
|
|
->take(8)
|
|
->map(fn ($count, $scope) => [
|
|
'scope' => $scope,
|
|
'description' => $descriptions->get($scope)?->description ?? $scope,
|
|
'count' => $count,
|
|
])
|
|
->values()
|
|
->toArray();
|
|
}
|
|
|
|
private static function loginsByDay(Carbon $since, int $days): array
|
|
{
|
|
$rows = LoginEvent::query()
|
|
->selectRaw("date(created_at) as day, type, count(*) as count")
|
|
->where('outcome', 'success')
|
|
->where('created_at', '>=', $since)
|
|
->groupBy('day', 'type')
|
|
->get()
|
|
->groupBy('day');
|
|
|
|
$out = [];
|
|
for ($i = 0; $i < $days; $i++) {
|
|
$date = $since->copy()->addDays($i)->toDateString();
|
|
$dayRows = $rows->get($date, collect());
|
|
|
|
$out[] = [
|
|
'date' => $date,
|
|
'session' => (int) $dayRows->firstWhere('type', 'session')?->count,
|
|
'oauth' => (int) $dayRows->firstWhere('type', 'oauth')?->count,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private static function loginsByApp(Carbon $since): array
|
|
{
|
|
return LoginEvent::query()
|
|
->join('oauth_clients', 'oauth_clients.id', '=', 'login_events.client_id')
|
|
->where('login_events.type', 'oauth')
|
|
->where('login_events.outcome', 'success')
|
|
->where('login_events.created_at', '>=', $since)
|
|
->groupBy('oauth_clients.id', 'oauth_clients.name')
|
|
->orderByDesc('count')
|
|
->limit(8)
|
|
->get(['oauth_clients.name as client_name', DB::raw('count(*) as count')])
|
|
->toArray();
|
|
}
|
|
|
|
private static function topUsers(Carbon $since): array
|
|
{
|
|
return LoginEvent::query()
|
|
->join('users', 'users.id', '=', 'login_events.user_id')
|
|
->where('login_events.outcome', 'success')
|
|
->where('login_events.created_at', '>=', $since)
|
|
->groupBy('users.id', 'users.name', 'users.email')
|
|
->orderByDesc('count')
|
|
->limit(8)
|
|
->get(['users.name', 'users.email', DB::raw('count(*) as count')])
|
|
->toArray();
|
|
}
|
|
|
|
private static function recent(int $limit = 15): array
|
|
{
|
|
return LoginEvent::with(['user:id,name,email', 'client:id,name'])
|
|
->where('outcome', 'success')
|
|
->latest('created_at')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn (LoginEvent $event) => [
|
|
'user_name' => $event->user?->name,
|
|
'user_email' => $event->user?->email,
|
|
// null para logins de sesión (el frontend lo traduce como "Panel admin"/"Admin
|
|
// panel" según el idioma) y para apps borradas (el frontend traduce ese caso
|
|
// también) — así el backend no filtra texto sin traducir a la UI.
|
|
'client_name' => $event->type === 'session' ? null : $event->client?->name,
|
|
'type' => $event->type,
|
|
'ip_address' => $event->ip_address,
|
|
'created_at' => $event->created_at,
|
|
])
|
|
->toArray();
|
|
}
|
|
}
|