new funcionality
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -22,20 +24,122 @@ class AnalyticsSummary
|
||||
'kpis' => [
|
||||
'total_users' => User::count(),
|
||||
'active_sessions' => Token::query()->where('revoked', false)->where('expires_at', '>', now())->count(),
|
||||
'logins_today' => LoginEvent::whereDate('created_at', today())->count(),
|
||||
'logins_7d' => LoginEvent::where('created_at', '>=', now()->subDays(7))->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()
|
||||
@@ -61,6 +165,7 @@ class AnalyticsSummary
|
||||
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')
|
||||
@@ -73,6 +178,7 @@ class AnalyticsSummary
|
||||
{
|
||||
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')
|
||||
@@ -84,6 +190,7 @@ class AnalyticsSummary
|
||||
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()
|
||||
|
||||
37
app/Support/ClientSecretRotation.php
Normal file
37
app/Support/ClientSecretRotation.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Client;
|
||||
|
||||
/**
|
||||
* Compartido por OAuth\ClientController (sesión) y Api\ClientController
|
||||
* (token) — mismo patrón que el resto de las clases en App\Support.
|
||||
*/
|
||||
class ClientSecretRotation
|
||||
{
|
||||
public const GRACE_PERIOD_HOURS = 48;
|
||||
|
||||
/**
|
||||
* Genera un secret nuevo y deja el anterior válido por GRACE_PERIOD_HOURS
|
||||
* más (ver App\Services\Passport\ClientRepository, que es quien
|
||||
* realmente lo verifica en cada request a /oauth/token). Devuelve el
|
||||
* secret nuevo en texto plano — se muestra una sola vez, igual que al
|
||||
* crear el cliente.
|
||||
*/
|
||||
public static function rotate(Client $client): string
|
||||
{
|
||||
// El atributo "secret" en el modelo Client ya viene hasheado (mutator
|
||||
// de Passport) — lo copiamos tal cual, no hay que volver a hashearlo.
|
||||
$client->forceFill([
|
||||
'previous_secret' => $client->getAttributes()['secret'] ?? null,
|
||||
'previous_secret_expires_at' => now()->addHours(self::GRACE_PERIOD_HOURS),
|
||||
]);
|
||||
|
||||
$client->secret = Str::random(40);
|
||||
$client->save();
|
||||
|
||||
return $client->plainSecret;
|
||||
}
|
||||
}
|
||||
28
app/Support/TokenRevocation.php
Normal file
28
app/Support/TokenRevocation.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Events\TokenRevoked;
|
||||
use Laravel\Passport\Token;
|
||||
|
||||
/**
|
||||
* Único punto de revocación de un token OAuth — usado por el self-service
|
||||
* (AuthorizedAppController, ClientController), por el admin (UserSessions) y
|
||||
* por la revocación en cascada al revocar un client. Centralizarlo acá
|
||||
* garantiza que el evento TokenRevoked (que dispara los webhooks salientes)
|
||||
* se dispare siempre, sin importar desde dónde se originó la revocación.
|
||||
*/
|
||||
class TokenRevocation
|
||||
{
|
||||
public static function revoke(Token $token): void
|
||||
{
|
||||
if ($token->revoked) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token->revoke();
|
||||
$token->refreshToken?->revoke();
|
||||
|
||||
TokenRevoked::dispatch($token);
|
||||
}
|
||||
}
|
||||
33
app/Support/TwoFactorVerification.php
Normal file
33
app/Support/TwoFactorVerification.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\User;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
|
||||
class TwoFactorVerification
|
||||
{
|
||||
/**
|
||||
* Valida un código TOTP o de recuperación contra el 2FA de un usuario.
|
||||
* Un código de recuperación válido se consume (no sirve una segunda vez).
|
||||
*/
|
||||
public static function verify(User $user, string $code): bool
|
||||
{
|
||||
if ($user->two_factor_secret && (new Google2FA())->verifyKey($user->two_factor_secret, $code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$recoveryCodes = $user->two_factor_recovery_codes ?? [];
|
||||
$normalized = strtoupper(trim($code));
|
||||
|
||||
if (in_array($normalized, $recoveryCodes, true)) {
|
||||
$user->forceFill([
|
||||
'two_factor_recovery_codes' => array_values(array_diff($recoveryCodes, [$normalized])),
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
58
app/Support/UserSessions.php
Normal file
58
app/Support/UserSessions.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Tokens OAuth activos de un usuario ("sesiones") — compartido por
|
||||
* Admin\UserSessionController (sesión) y Api\UserSessionController (token),
|
||||
* igual que AnalyticsSummary/AuditLog::paginatedFor.
|
||||
*/
|
||||
class UserSessions
|
||||
{
|
||||
public static function active(User $user)
|
||||
{
|
||||
return $user->tokens()
|
||||
->where('revoked', false)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->with('client:id,name,revoked')
|
||||
->latest('created_at')
|
||||
->get()
|
||||
->map(fn ($token) => [
|
||||
'id' => $token->id,
|
||||
'client_name' => $token->client?->name,
|
||||
'client_revoked' => (bool) $token->client?->revoked,
|
||||
'name' => $token->name,
|
||||
'scopes' => $token->scopes,
|
||||
'created_at' => $token->created_at,
|
||||
'expires_at' => $token->expires_at,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function revoke(User $user, string $tokenId): bool
|
||||
{
|
||||
$token = $user->tokens()->where('id', $tokenId)->first();
|
||||
|
||||
if (! $token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
TokenRevocation::revoke($token);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function revokeAll(User $user): int
|
||||
{
|
||||
$tokens = $user->tokens()->where('revoked', false)->get();
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
TokenRevocation::revoke($token);
|
||||
}
|
||||
|
||||
return $tokens->count();
|
||||
}
|
||||
}
|
||||
81
app/Support/WebhookEndpoints.php
Normal file
81
app/Support/WebhookEndpoints.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\WebhookEndpoint;
|
||||
|
||||
/**
|
||||
* Compartido por OAuth\WebhookController (sesión) y Api\WebhookController
|
||||
* (token) — mismo patrón que AnalyticsSummary/AuditLog::paginatedFor/UserSessions.
|
||||
*/
|
||||
class WebhookEndpoints
|
||||
{
|
||||
public const EVENTS = ['token.revoked', 'user.deactivated'];
|
||||
|
||||
/**
|
||||
* Protección anti-SSRF: rechaza URLs que resuelven a IPs privadas,
|
||||
* loopback o reservadas (incluye 169.254.169.254, el endpoint de
|
||||
* metadata de instancia en AWS/GCP/Azure). Se llama tanto al
|
||||
* crear/editar un webhook como, de nuevo, justo antes de cada envío
|
||||
* (SendWebhookJob) — un dominio público al registrarse puede resolver
|
||||
* a una IP interna más tarde (DNS rebinding).
|
||||
*/
|
||||
public static function isSafeUrl(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
|
||||
if (! $parts || empty($parts['host']) || ! in_array($parts['scheme'] ?? '', ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = $parts['host'];
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
return self::isPublicIp($host);
|
||||
}
|
||||
|
||||
$records = array_merge(
|
||||
@dns_get_record($host, DNS_A) ?: [],
|
||||
@dns_get_record($host, DNS_AAAA) ?: [],
|
||||
);
|
||||
|
||||
if (empty($records)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
|
||||
|
||||
if (! $ip || ! self::isPublicIp($ip)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function isPublicIp(string $ip): bool
|
||||
{
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
||||
}
|
||||
|
||||
public static function present(WebhookEndpoint $endpoint): array
|
||||
{
|
||||
$last = $endpoint->deliveries()->latest('created_at')->first();
|
||||
|
||||
return [
|
||||
'id' => $endpoint->id,
|
||||
'client_id' => $endpoint->client_id,
|
||||
'url' => $endpoint->url,
|
||||
'events' => $endpoint->events,
|
||||
'active' => $endpoint->active,
|
||||
'created_at' => $endpoint->created_at,
|
||||
'last_delivery' => $last ? [
|
||||
'event' => $last->event,
|
||||
'response_status' => $last->response_status,
|
||||
'delivered_at' => $last->delivered_at,
|
||||
'created_at' => $last->created_at,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user