new funcionality

This commit is contained in:
2026-07-27 17:20:02 +02:00
parent c2b5291a74
commit c5ce8a7a36
101 changed files with 6889 additions and 176 deletions

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Laravel\Passport\Token;
class TokenRevoked
{
use Dispatchable;
public function __construct(public Token $token)
{
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
class UserDeactivated
{
use Dispatchable;
public function __construct(public User $user)
{
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request;
class AuditLogController extends Controller
{
public function index(Request $request)
{
return response()->json(AuditLog::paginatedFor($request));
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Controllers\Admin;
use App\Events\UserDeactivated;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -43,6 +45,8 @@ class UserController extends Controller
{
$user->update(['approved_at' => now()]);
AuditLog::record(request(), 'user.approved', 'user', $user->id, $user->email);
return response()->json($this->present($user));
}
@@ -65,6 +69,12 @@ class UserController extends Controller
$user->assignRole($data['role']);
AuditLog::record($request, 'user.created', 'user', $user->id, $user->email, [
'name' => $user->name,
'email' => $user->email,
'role' => $data['role'],
]);
return response()->json($this->present($user), 201);
}
@@ -82,6 +92,13 @@ class UserController extends Controller
abort(422, __('messages.cannot_remove_own_admin_role'));
}
$before = [
'name' => $user->name,
'email' => $user->email,
'is_active' => $user->is_active,
'role' => $user->getRoleNames()->first(),
];
$user->update([
'name' => $data['name'],
'email' => $data['email'],
@@ -91,7 +108,15 @@ class UserController extends Controller
$user->syncRoles([$data['role']]);
return response()->json($this->present($user->fresh()));
$fresh = $user->fresh();
if ($before['is_active'] && ! $fresh->is_active) {
UserDeactivated::dispatch($fresh);
}
$this->recordUserUpdate($request, $fresh, $before, isset($data['password']));
return response()->json($this->present($fresh));
}
/**
@@ -106,6 +131,8 @@ class UserController extends Controller
abort(422, __('messages.cannot_delete_last_admin'));
}
AuditLog::record($request, 'user.deleted', 'user', $user->id, $user->email);
$user->delete();
return response()->noContent();
@@ -116,6 +143,8 @@ class UserController extends Controller
$user = User::onlyTrashed()->findOrFail($id);
$user->restore();
AuditLog::record(request(), 'user.restored', 'user', $user->id, $user->email);
return response()->json($this->present($user));
}
@@ -126,6 +155,8 @@ class UserController extends Controller
{
$user = User::onlyTrashed()->findOrFail($id);
AuditLog::record($request, 'user.force_deleted', 'user', $user->id, $user->email);
$user->forceDelete();
return response()->noContent();
@@ -168,6 +199,11 @@ class UserController extends Controller
'updated_at' => now(),
]);
AuditLog::record($request, 'user.app_role_assigned', 'user', $user->id, $user->email, [
'client_id' => $data['client_id'],
'role' => $data['role'],
]);
return response()->json($data, 201);
}
@@ -179,9 +215,44 @@ class UserController extends Controller
->where('role', $role)
->delete();
AuditLog::record(request(), 'user.app_role_revoked', 'user', $user->id, $user->email, [
'client_id' => $clientId,
'role' => $role,
]);
return response()->noContent();
}
/**
* Solo registra los campos que efectivamente cambiaron (nunca la contraseña
* en sí, solo si cambió o no) — evita ruido en el audit log en updates que
* no tocan nada relevante.
*/
private function recordUserUpdate(Request $request, User $user, array $before, bool $passwordChanged): void
{
$after = [
'name' => $user->name,
'email' => $user->email,
'is_active' => $user->is_active,
'role' => $user->getRoleNames()->first(),
];
$changes = [];
foreach ($before as $field => $previous) {
if ($previous !== $after[$field]) {
$changes[$field] = ['from' => $previous, 'to' => $after[$field]];
}
}
if ($passwordChanged) {
$changes['password'] = ['changed' => true];
}
if ($changes !== []) {
AuditLog::record($request, 'user.updated', 'user', $user->id, $user->email, $changes);
}
}
private function present(User $user): array
{
return [

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use App\Support\UserSessions;
use Illuminate\Http\Request;
class UserSessionController extends Controller
{
public function index(User $user)
{
return response()->json(UserSessions::active($user));
}
public function destroy(Request $request, User $user, string $tokenId)
{
abort_unless(UserSessions::revoke($user, $tokenId), 404);
AuditLog::record($request, 'user.session_revoked', 'user', $user->id, $user->email);
return response()->noContent();
}
public function destroyAll(Request $request, User $user)
{
$count = UserSessions::revokeAll($user);
AuditLog::record($request, 'user.sessions_revoked_all', 'user', $user->id, $user->email, [
'count' => $count,
]);
return response()->noContent();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
use Laravel\Passport\Http\Middleware\CheckToken;
use OpenApi\Attributes as OA;
class AuditLogController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [new Middleware(CheckToken::using('users:read'))];
}
#[OA\Get(
path: '/audit-log',
summary: 'Historial de auditoría: altas/bajas de usuarios, cambios de rol y de clientes OAuth',
description: 'Requiere el permiso audit.view (o un token client_credentials, que actúa como servicio de confianza).',
security: [['oauth2' => ['users:read']]],
tags: ['Audit'],
parameters: [
new OA\Parameter(name: 'action', in: 'query', required: false, description: 'Filtrar por acción exacta (ej. user.updated)', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'subject_type', in: 'query', required: false, description: 'user | client', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'per_page', in: 'query', required: false, description: '5-100, default 25', schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 200, description: 'Listado paginado'),
new OA\Response(response: 403, description: 'Sin permiso audit.view'),
]
)]
public function index(Request $request)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('audit.view'), 403);
return response()->json(AuditLog::paginatedFor($request));
}
}

View File

@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use App\Support\ClientSecretRotation;
use App\Support\TokenRevocation;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
@@ -30,7 +33,7 @@ class ClientController extends Controller implements HasMiddleware
{
return [
new Middleware(CheckToken::using('account:read'), only: ['index']),
new Middleware(CheckToken::using('account:write'), only: ['store', 'update', 'destroy']),
new Middleware(CheckToken::using('account:write'), only: ['store', 'update', 'destroy', 'rotateSecret']),
];
}
@@ -107,6 +110,11 @@ class ClientController extends Controller implements HasMiddleware
'revoked' => false,
]);
AuditLog::record($request, 'client.created', 'client', $client->id, $client->name, [
'grant_type' => $data['grant_type'],
'confidential' => $confidential,
]);
return response()->json([
'client' => $client,
'plain_secret' => $client->plainSecret,
@@ -142,6 +150,36 @@ class ClientController extends Controller implements HasMiddleware
return response()->json($client);
}
#[OA\Post(
path: '/clients/{client}/rotate-secret',
summary: 'Rotar el secret de un client confidencial (el anterior sigue valiendo por 48hs)',
description: 'El secret anterior no se corta al instante: sigue siendo válido durante un periodo de gracia (48hs), para no romper integraciones ya desplegadas que todavía no se actualizaron al secret nuevo.',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
parameters: [new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'Secret rotado (incluye plain_secret una sola vez)'),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
new OA\Response(response: 422, description: 'El cliente no es confidencial (no tiene secret)'),
]
)]
public function rotateSecret(Request $request, Client $client)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
abort_unless($client->confidential(), 422, __('messages.client_not_confidential'));
$plainSecret = ClientSecretRotation::rotate($client);
AuditLog::record($request, 'client.secret_rotated', 'client', $client->id, $client->name, [
'previous_secret_expires_at' => $client->previous_secret_expires_at,
]);
return response()->json([
'plain_secret' => $plainSecret,
'previous_secret_expires_at' => $client->previous_secret_expires_at,
]);
}
#[OA\Delete(
path: '/clients/{client}',
summary: 'Revocar uno de tus clientes OAuth (y sus tokens)',
@@ -157,8 +195,17 @@ class ClientController extends Controller implements HasMiddleware
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
$tokens = $client->tokens()->where('revoked', false)->get();
$client->update(['revoked' => true]);
$client->tokens()->update(['revoked' => true]);
foreach ($tokens as $token) {
TokenRevocation::revoke($token);
}
AuditLog::record($request, 'client.revoked', 'client', $client->id, $client->name, [
'tokens_revoked' => $tokens->count(),
]);
return response()->noContent();
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Events\UserDeactivated;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
@@ -197,6 +198,8 @@ class UserController extends Controller implements HasMiddleware
abort(422, __('messages.cannot_remove_own_admin_role'));
}
$wasActive = $user->is_active;
$user->update([
...array_diff_key($data, array_flip(['password', 'role'])),
...(isset($data['password']) ? ['password' => Hash::make($data['password'])] : []),
@@ -206,7 +209,13 @@ class UserController extends Controller implements HasMiddleware
$user->syncRoles([$data['role']]);
}
return response()->json($this->present($user->fresh()));
$fresh = $user->fresh();
if ($wasActive && ! $fresh->is_active) {
UserDeactivated::dispatch($fresh);
}
return response()->json($this->present($fresh));
}
#[OA\Delete(

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use App\Support\UserSessions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
use Laravel\Passport\Http\Middleware\CheckToken;
use OpenApi\Attributes as OA;
class UserSessionController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
new Middleware(CheckToken::using('users:read'), only: ['index']),
new Middleware(CheckToken::using('users:write'), only: ['destroy', 'destroyAll']),
];
}
#[OA\Get(
path: '/users/{user}/sessions',
summary: 'Tokens OAuth activos de un usuario ("sesiones")',
description: 'Requiere el permiso users.view (o un token client_credentials, servicio de confianza).',
security: [['oauth2' => ['users:read']]],
tags: ['Users'],
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
responses: [
new OA\Response(response: 200, description: 'Listado de tokens activos'),
new OA\Response(response: 403, description: 'Sin permiso users.view'),
]
)]
public function index(Request $request, User $user)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.view'), 403);
return response()->json(UserSessions::active($user));
}
#[OA\Delete(
path: '/users/{user}/sessions/{tokenId}',
summary: 'Revocar un token OAuth activo de un usuario',
description: 'Requiere el permiso users.update (o un token client_credentials, servicio de confianza).',
security: [['oauth2' => ['users:write']]],
tags: ['Users'],
parameters: [
new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'tokenId', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 204, description: 'Token revocado'),
new OA\Response(response: 403, description: 'Sin permiso users.update'),
new OA\Response(response: 404, description: 'El token no existe o no pertenece a ese usuario'),
]
)]
public function destroy(Request $request, User $user, string $tokenId)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.update'), 403);
abort_unless(UserSessions::revoke($user, $tokenId), 404);
AuditLog::record($request, 'user.session_revoked', 'user', $user->id, $user->email);
return response()->noContent();
}
#[OA\Delete(
path: '/users/{user}/sessions',
summary: 'Revocar todos los tokens OAuth activos de un usuario',
description: 'Requiere el permiso users.update (o un token client_credentials, servicio de confianza).',
security: [['oauth2' => ['users:write']]],
tags: ['Users'],
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
responses: [
new OA\Response(response: 204, description: 'Todos los tokens revocados'),
new OA\Response(response: 403, description: 'Sin permiso users.update'),
]
)]
public function destroyAll(Request $request, User $user)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.update'), 403);
$count = UserSessions::revokeAll($user);
AuditLog::record($request, 'user.sessions_revoked_all', 'user', $user->id, $user->email, [
'count' => $count,
]);
return response()->noContent();
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\SendWebhookJob;
use App\Models\AuditLog;
use App\Models\User;
use App\Models\WebhookDelivery;
use App\Models\WebhookEndpoint;
use App\Rules\SafeWebhookUrl;
use App\Support\WebhookEndpoints;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Laravel\Passport\Client;
use Laravel\Passport\Http\Middleware\CheckToken;
use OpenApi\Attributes as OA;
class WebhookController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
new Middleware(CheckToken::using('account:read'), only: ['index', 'deliveries']),
new Middleware(CheckToken::using('account:write'), only: ['store', 'update', 'destroy', 'retryDelivery']),
];
}
#[OA\Get(
path: '/clients/{client}/webhooks',
summary: 'Listar los webhooks registrados en uno de tus clientes OAuth',
security: [['oauth2' => ['account:read']]],
tags: ['Account'],
parameters: [new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 200, description: 'Listado de webhooks'),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
]
)]
public function index(Request $request, Client $client)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
return response()->json(
WebhookEndpoint::where('client_id', $client->id)
->latest('created_at')
->get()
->map(fn (WebhookEndpoint $endpoint) => WebhookEndpoints::present($endpoint))
);
}
#[OA\Post(
path: '/clients/{client}/webhooks',
summary: 'Registrar un webhook nuevo en uno de tus clientes OAuth',
description: 'Eventos disponibles: token.revoked (se revocó un token de este client), user.deactivated (un usuario con token vigente para este client fue desactivado). El secret se devuelve una sola vez, usalo para verificar la firma HMAC-SHA256 en el header X-Webhook-Signature.',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
parameters: [new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['url', 'events'],
properties: [
new OA\Property(property: 'url', type: 'string', format: 'uri'),
new OA\Property(property: 'events', type: 'array', items: new OA\Items(type: 'string', enum: ['token.revoked', 'user.deactivated'])),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Webhook creado (incluye secret una sola vez)'),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
]
)]
public function store(Request $request, Client $client)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
$data = $request->validate([
'url' => ['required', 'url', 'max:2048', new SafeWebhookUrl()],
'events' => ['required', 'array', 'min:1'],
'events.*' => [Rule::in(WebhookEndpoints::EVENTS)],
]);
$secret = Str::random(40);
$endpoint = WebhookEndpoint::create([
'client_id' => $client->id,
'url' => $data['url'],
'secret' => $secret,
'events' => $data['events'],
'active' => true,
]);
AuditLog::record($request, 'client.webhook_created', 'client', $client->id, $client->name, [
'url' => $data['url'],
'events' => $data['events'],
]);
return response()->json([
...WebhookEndpoints::present($endpoint),
'secret' => $secret,
], 201);
}
#[OA\Put(
path: '/clients/{client}/webhooks/{webhook}',
summary: 'Editar un webhook (url, eventos o activo/pausado)',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
parameters: [
new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'webhook', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 200, description: 'Webhook actualizado'),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
]
)]
public function update(Request $request, Client $client, WebhookEndpoint $webhook)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
abort_unless($webhook->client_id === $client->id, 404);
$data = $request->validate([
'url' => ['sometimes', 'url', 'max:2048', new SafeWebhookUrl()],
'events' => ['sometimes', 'array', 'min:1'],
'events.*' => [Rule::in(WebhookEndpoints::EVENTS)],
'active' => ['sometimes', 'boolean'],
]);
$webhook->update($data);
return response()->json(WebhookEndpoints::present($webhook->fresh()));
}
#[OA\Delete(
path: '/clients/{client}/webhooks/{webhook}',
summary: 'Borrar un webhook',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
parameters: [
new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'webhook', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 204, description: 'Webhook borrado'),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
]
)]
public function destroy(Request $request, Client $client, WebhookEndpoint $webhook)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
abort_unless($webhook->client_id === $client->id, 404);
AuditLog::record($request, 'client.webhook_deleted', 'client', $client->id, $client->name, [
'url' => $webhook->url,
]);
$webhook->delete();
return response()->noContent();
}
#[OA\Get(
path: '/clients/{client}/webhooks/{webhook}/deliveries',
summary: 'Historial paginado de intentos de entrega de un webhook',
security: [['oauth2' => ['account:read']]],
tags: ['Account'],
parameters: [
new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'webhook', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'per_page', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 200, description: 'Listado paginado de entregas'),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
]
)]
public function deliveries(Request $request, Client $client, WebhookEndpoint $webhook)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
abort_unless($webhook->client_id === $client->id, 404);
return response()->json(WebhookDelivery::paginatedFor($webhook, $request));
}
#[OA\Post(
path: '/clients/{client}/webhooks/{webhook}/deliveries/{delivery}/retry',
summary: 'Reintentar manualmente una entrega puntual',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
parameters: [
new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'webhook', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'delivery', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 200, description: 'Reintento encolado'),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
]
)]
public function retryDelivery(Request $request, Client $client, WebhookEndpoint $webhook, WebhookDelivery $delivery)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
abort_unless($webhook->client_id === $client->id, 404);
abort_unless($delivery->webhook_endpoint_id === $webhook->id, 404);
SendWebhookJob::dispatch($delivery);
return response()->json(['queued' => true]);
}
private function requireUser(Request $request): User
{
return $request->user('api') ?? abort(422, __('messages.self_service_requires_user'));
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\LoginEvent;
use App\Models\User;
use App\Support\TwoFactorVerification;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -73,33 +74,76 @@ class AuthController extends Controller
'password' => ['required', 'string'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
// Auth::validate() (no attempt()): solo chequea credenciales, no
// loguea todavía — necesitamos poder frenar acá mismo si el usuario
// tiene 2FA activo, antes de que exista sesión autenticada.
if (! Auth::validate($credentials)) {
$this->recordFailedLogin($request);
throw ValidationException::withMessages([
'email' => __('messages.invalid_credentials'),
]);
}
if (! Auth::user()->is_active) {
Auth::logout();
$user = User::where('email', $credentials['email'])->first();
if (! $user->is_active) {
throw ValidationException::withMessages([
'email' => __('messages.account_deactivated'),
]);
}
if (Auth::user()->isPending()) {
Auth::logout();
if ($user->isPending()) {
throw ValidationException::withMessages([
'email' => __('messages.account_pending_approval'),
]);
}
if ($user->hasTwoFactorEnabled()) {
$request->session()->put('login.mfa_user_id', $user->id);
$request->session()->put('login.mfa_remember', $request->boolean('remember'));
return response()->json(['two_factor' => true]);
}
Auth::login($user, $request->boolean('remember'));
$request->session()->regenerate();
$this->recordSessionLogin($request, Auth::user());
$this->recordSessionLogin($request, $user);
return response()->json([
'user' => $this->present(Auth::user()),
'user' => $this->present($user),
'intended' => $request->session()->pull('url.intended'),
]);
}
/**
* Segundo paso del login cuando el usuario tiene 2FA activo: recibe un
* código TOTP o de recuperación y, si es válido, recién ahí crea la sesión.
*/
public function loginTwoFactor(Request $request)
{
$data = $request->validate([
'code' => ['required', 'string'],
]);
$userId = $request->session()->get('login.mfa_user_id');
$user = $userId ? User::find($userId) : null;
if (! $user || ! TwoFactorVerification::verify($user, $data['code'])) {
throw ValidationException::withMessages([
'code' => __('messages.invalid_two_factor_code'),
]);
}
$remember = $request->session()->pull('login.mfa_remember', false);
$request->session()->forget('login.mfa_user_id');
Auth::login($user, $remember);
$request->session()->regenerate();
$this->recordSessionLogin($request, $user);
return response()->json([
'user' => $this->present($user),
'intended' => $request->session()->pull('url.intended'),
]);
}
@@ -124,6 +168,23 @@ class AuthController extends Controller
'user_id' => $user->id,
'client_id' => null,
'type' => 'session',
'outcome' => 'success',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => now(),
]);
}
// Solo credenciales inválidas (la señal relevante para detectar fuerza
// bruta) — cuenta desactivada o pendiente de aprobación no cuenta como
// "fallo" para esta métrica, son estados de cuenta, no intentos de ataque.
private function recordFailedLogin(Request $request): void
{
LoginEvent::create([
'user_id' => null,
'client_id' => null,
'type' => 'session',
'outcome' => 'failure',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => now(),
@@ -136,6 +197,7 @@ class AuthController extends Controller
...$user->toArray(),
'roles' => $user->getRoleNames(),
'permissions' => $user->getAllPermissions()->pluck('name'),
'two_factor_enabled' => $user->hasTwoFactorEnabled(),
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use App\Support\UserSessions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
class PasswordResetController extends Controller
{
/**
* Pide el link de restablecimiento. La respuesta es SIEMPRE la misma
* (exista o no una cuenta con ese email) — lo contrario permite
* enumerar cuentas registradas probando emails uno por uno.
*/
public function forgotPassword(Request $request)
{
$request->validate(['email' => ['required', 'string', 'email']]);
Password::sendResetLink($request->only('email'));
return response()->json(['message' => __('messages.password_reset_link_sent')]);
}
/**
* Confirma el restablecimiento con el token del email. Si sale bien,
* cierra todas las sesiones OAuth activas de la cuenta — el motivo más
* común para resetear una contraseña es sospechar que se filtró, así
* que no tiene sentido dejar vigente lo que se emitió con la vieja.
*/
public function resetPassword(Request $request)
{
$data = $request->validate([
'token' => ['required', 'string'],
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$status = Password::reset(
$data,
function (User $user, string $password) use ($request) {
$user->forceFill(['password' => Hash::make($password)])->save();
$revoked = UserSessions::revokeAll($user);
AuditLog::record($request, 'user.password_reset', 'user', $user->id, $user->email, [
'sessions_revoked' => $revoked,
]);
}
);
if ($status !== Password::PASSWORD_RESET) {
throw ValidationException::withMessages(['email' => __($status)]);
}
return response()->noContent();
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use PragmaRX\Google2FA\Google2FA;
class TwoFactorController extends Controller
{
/**
* Genera un secreto nuevo (pendiente de confirmar) y el QR para escanear.
* No queda "activo" hasta que se confirma con un código real (confirm()).
*/
public function setup(Request $request)
{
$user = $request->user();
abort_if($user->hasTwoFactorEnabled(), 422, __('messages.two_factor_already_enabled'));
$google2fa = new Google2FA();
$secret = $google2fa->generateSecretKey();
$user->forceFill([
'two_factor_secret' => $secret,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
return response()->json([
'secret' => $secret,
'otpauth_url' => $google2fa->getQRCodeUrl(config('app.name'), $user->email, $secret),
]);
}
/**
* Confirma la activación con un código real de la app autenticadora.
* Recién acá se generan y devuelven los códigos de recuperación (una sola vez).
*/
public function confirm(Request $request)
{
$data = $request->validate([
'code' => ['required', 'string'],
]);
$user = $request->user();
abort_if(! $user->two_factor_secret, 422, __('messages.two_factor_not_pending'));
if (! (new Google2FA())->verifyKey($user->two_factor_secret, $data['code'])) {
throw ValidationException::withMessages([
'code' => __('messages.invalid_two_factor_code'),
]);
}
$recoveryCodes = $this->generateRecoveryCodes();
$user->forceFill([
'two_factor_recovery_codes' => $recoveryCodes,
'two_factor_confirmed_at' => now(),
])->save();
AuditLog::record($request, 'user.2fa_enabled', 'user', $user->id, $user->email);
return response()->json(['recovery_codes' => $recoveryCodes]);
}
public function disable(Request $request)
{
$data = $request->validate([
'password' => ['required', 'string'],
]);
$user = $request->user();
if (! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages([
'password' => __('messages.invalid_password'),
]);
}
$user->forceFill([
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
AuditLog::record($request, 'user.2fa_disabled', 'user', $user->id, $user->email);
return response()->noContent();
}
/**
* Invalida los códigos de recuperación anteriores y genera un juego nuevo
* (una sola vez más, igual que en la confirmación inicial).
*/
public function regenerateRecoveryCodes(Request $request)
{
$data = $request->validate([
'password' => ['required', 'string'],
]);
$user = $request->user();
abort_unless($user->hasTwoFactorEnabled(), 422, __('messages.two_factor_not_enabled'));
if (! Hash::check($data['password'], $user->password)) {
throw ValidationException::withMessages([
'password' => __('messages.invalid_password'),
]);
}
$recoveryCodes = $this->generateRecoveryCodes();
$user->forceFill(['two_factor_recovery_codes' => $recoveryCodes])->save();
AuditLog::record($request, 'user.2fa_recovery_regenerated', 'user', $user->id, $user->email);
return response()->json(['recovery_codes' => $recoveryCodes]);
}
private function generateRecoveryCodes(): array
{
return collect(range(1, 8))
->map(fn () => Str::upper(Str::random(4).'-'.Str::random(4)))
->all();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\OAuth;
use App\Http\Controllers\Controller;
use App\Models\OAuthAuthorizationEvent;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class AuthorizationEventController extends Controller
{
/**
* Registra un paso del funnel de autorización OAuth (intento, aprobación
* o denegación) desde la pantalla de consentimiento (Authorize.vue).
* Es solo para analíticas: no participa en el flujo real de OAuth, que
* sigue resuelto enteramente por las rutas nativas de Passport.
*/
public function store(Request $request)
{
$data = $request->validate([
'client_id' => ['required', 'string', Rule::exists('oauth_clients', 'id')],
'outcome' => ['required', Rule::in(['attempted', 'approved', 'denied'])],
'scopes' => ['array'],
'scopes.*' => ['string'],
]);
OAuthAuthorizationEvent::create([
'user_id' => $request->user()->id,
'client_id' => $data['client_id'],
'outcome' => $data['outcome'],
'scopes' => $data['scopes'] ?? [],
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => now(),
]);
return response()->noContent();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\OAuth;
use App\Http\Controllers\Controller;
use App\Support\TokenRevocation;
use Illuminate\Http\Request;
class AuthorizedAppController extends Controller
@@ -28,8 +29,7 @@ class AuthorizedAppController extends Controller
abort_unless($token, 404);
$token->revoke();
$token->refreshToken?->revoke();
TokenRevocation::revoke($token);
return response()->noContent();
}

View File

@@ -3,6 +3,9 @@
namespace App\Http\Controllers\OAuth;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Support\ClientSecretRotation;
use App\Support\TokenRevocation;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
@@ -55,6 +58,11 @@ class ClientController extends Controller
'revoked' => false,
]);
AuditLog::record($request, 'client.created', 'client', $client->id, $client->name, [
'grant_type' => $data['grant_type'],
'confidential' => $confidential,
]);
return response()->json([
'client' => $client,
'plain_secret' => $client->plainSecret,
@@ -82,6 +90,28 @@ class ClientController extends Controller
return response()->json($client);
}
/**
* Rotate a confidential client's secret. The previous secret keeps
* working for a grace period so deployed integrations don't break
* instantly (see App\Support\ClientSecretRotation).
*/
public function rotateSecret(Request $request, Client $client)
{
abort_unless($client->owner_id === $request->user()->id, 404);
abort_unless($client->confidential(), 422, __('messages.client_not_confidential'));
$plainSecret = ClientSecretRotation::rotate($client);
AuditLog::record($request, 'client.secret_rotated', 'client', $client->id, $client->name, [
'previous_secret_expires_at' => $client->previous_secret_expires_at,
]);
return response()->json([
'plain_secret' => $plainSecret,
'previous_secret_expires_at' => $client->previous_secret_expires_at,
]);
}
/**
* Revoke (soft-delete) a client and its tokens.
*/
@@ -89,8 +119,17 @@ class ClientController extends Controller
{
abort_unless($client->owner_id === $request->user()->id, 404);
$tokens = $client->tokens()->where('revoked', false)->get();
$client->update(['revoked' => true]);
$client->tokens()->update(['revoked' => true]);
foreach ($tokens as $token) {
TokenRevocation::revoke($token);
}
AuditLog::record($request, 'client.revoked', 'client', $client->id, $client->name, [
'tokens_revoked' => $tokens->count(),
]);
return response()->noContent();
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Http\Controllers\OAuth;
use App\Http\Controllers\Controller;
use App\Jobs\SendWebhookJob;
use App\Models\AuditLog;
use App\Models\WebhookDelivery;
use App\Models\WebhookEndpoint;
use App\Rules\SafeWebhookUrl;
use App\Support\WebhookEndpoints;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Laravel\Passport\Client;
class WebhookController extends Controller
{
/**
* List the webhook endpoints registered for one of the user's own clients.
*/
public function index(Request $request, Client $client)
{
abort_unless($client->owner_id === $request->user()->id, 404);
return response()->json(
WebhookEndpoint::where('client_id', $client->id)
->latest('created_at')
->get()
->map(fn (WebhookEndpoint $endpoint) => WebhookEndpoints::present($endpoint))
);
}
/**
* Register a new webhook endpoint for one of the user's own clients.
*/
public function store(Request $request, Client $client)
{
abort_unless($client->owner_id === $request->user()->id, 404);
$data = $request->validate([
'url' => ['required', 'url', 'max:2048', new SafeWebhookUrl()],
'events' => ['required', 'array', 'min:1'],
'events.*' => [Rule::in(WebhookEndpoints::EVENTS)],
]);
$secret = Str::random(40);
$endpoint = WebhookEndpoint::create([
'client_id' => $client->id,
'url' => $data['url'],
'secret' => $secret,
'events' => $data['events'],
'active' => true,
]);
AuditLog::record($request, 'client.webhook_created', 'client', $client->id, $client->name, [
'url' => $data['url'],
'events' => $data['events'],
]);
return response()->json([
...WebhookEndpoints::present($endpoint),
'secret' => $secret,
], 201);
}
public function update(Request $request, Client $client, WebhookEndpoint $webhook)
{
abort_unless($client->owner_id === $request->user()->id, 404);
abort_unless($webhook->client_id === $client->id, 404);
$data = $request->validate([
'url' => ['sometimes', 'url', 'max:2048', new SafeWebhookUrl()],
'events' => ['sometimes', 'array', 'min:1'],
'events.*' => [Rule::in(WebhookEndpoints::EVENTS)],
'active' => ['sometimes', 'boolean'],
]);
$webhook->update($data);
return response()->json(WebhookEndpoints::present($webhook->fresh()));
}
public function destroy(Request $request, Client $client, WebhookEndpoint $webhook)
{
abort_unless($client->owner_id === $request->user()->id, 404);
abort_unless($webhook->client_id === $client->id, 404);
AuditLog::record($request, 'client.webhook_deleted', 'client', $client->id, $client->name, [
'url' => $webhook->url,
]);
$webhook->delete();
return response()->noContent();
}
/**
* Historial paginado de intentos de entrega de un webhook.
*/
public function deliveries(Request $request, Client $client, WebhookEndpoint $webhook)
{
abort_unless($client->owner_id === $request->user()->id, 404);
abort_unless($webhook->client_id === $client->id, 404);
return response()->json(WebhookDelivery::paginatedFor($webhook, $request));
}
/**
* Reintento manual de una entrega puntual — vuelve a encolar el mismo
* registro (acumula el intento en su historial, no crea uno nuevo).
*/
public function retryDelivery(Request $request, Client $client, WebhookEndpoint $webhook, WebhookDelivery $delivery)
{
abort_unless($client->owner_id === $request->user()->id, 404);
abort_unless($webhook->client_id === $client->id, 404);
abort_unless($delivery->webhook_endpoint_id === $webhook->id, 404);
SendWebhookJob::dispatch($delivery);
return response()->json(['queued' => true]);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Jobs;
use App\Models\WebhookDelivery;
use App\Support\WebhookEndpoints;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class SendWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
/**
* Backoff creciente entre reintentos (segundos) — le da tiempo al
* endpoint del cliente a recuperarse si está caído momentáneamente.
*/
public array $backoff = [10, 60, 300];
public function __construct(public WebhookDelivery $delivery)
{
}
public function handle(): void
{
$endpoint = $this->delivery->endpoint;
// El endpoint pudo haberse borrado o desactivado entre que se
// encoló el job y que se procesó — no tiene sentido reintentar.
if (! $endpoint || ! $endpoint->active) {
return;
}
// Re-chequeo anti-SSRF: la URL ya se validó al crear/editar el
// webhook, pero un dominio público en ese momento puede resolver a
// una IP interna más tarde (DNS rebinding). No reintentamos esto —
// si sigue sin ser seguro en el próximo intento, va a fallar igual.
if (! WebhookEndpoints::isSafeUrl($endpoint->url)) {
$this->delivery->update([
'response_body' => 'Bloqueado: la URL resuelve a una dirección privada/reservada.',
]);
return;
}
$body = json_encode([
'event' => $this->delivery->event,
'data' => $this->delivery->payload,
]);
$signature = hash_hmac('sha256', $body, $endpoint->secret);
$this->delivery->increment('attempts');
try {
$response = Http::timeout(5)
->withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Event' => $this->delivery->event,
'X-Webhook-Signature' => 'sha256='.$signature,
])
->withBody($body, 'application/json')
->post($endpoint->url);
$this->delivery->update([
'response_status' => $response->status(),
'response_body' => Str::limit($response->body(), 1000),
'delivered_at' => $response->successful() ? now() : null,
]);
if (! $response->successful()) {
throw new \RuntimeException("El endpoint del webhook respondió {$response->status()}.");
}
} catch (\Throwable $e) {
$this->delivery->update([
'response_body' => Str::limit($e->getMessage(), 1000),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Listeners;
use App\Events\TokenRevoked;
use App\Jobs\SendWebhookJob;
use App\Models\WebhookDelivery;
use App\Models\WebhookEndpoint;
class DispatchTokenRevokedWebhooks
{
public function handle(TokenRevoked $event): void
{
$token = $event->token;
$endpoints = WebhookEndpoint::where('client_id', $token->client_id)
->where('active', true)
->get()
->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo('token.revoked'));
foreach ($endpoints as $endpoint) {
$delivery = WebhookDelivery::create([
'webhook_endpoint_id' => $endpoint->id,
'event' => 'token.revoked',
'payload' => [
'token_id' => $token->id,
'user_id' => $token->user_id,
'client_id' => $token->client_id,
'revoked_at' => now()->toIso8601String(),
],
'created_at' => now(),
]);
SendWebhookJob::dispatch($delivery);
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Listeners;
use App\Events\UserDeactivated;
use App\Jobs\SendWebhookJob;
use App\Models\WebhookDelivery;
use App\Models\WebhookEndpoint;
use Laravel\Passport\Token;
class DispatchUserDeactivatedWebhooks
{
public function handle(UserDeactivated $event): void
{
$user = $event->user;
// Solo le importa a las apps que todavía tienen un token vigente
// para este usuario — no tiene sentido avisarle a una app a la que
// el usuario nunca le dio acceso, o que ya perdió el suyo.
$clientIds = Token::where('user_id', $user->id)
->where('revoked', false)
->distinct()
->pluck('client_id');
if ($clientIds->isEmpty()) {
return;
}
$endpoints = WebhookEndpoint::whereIn('client_id', $clientIds)
->where('active', true)
->get()
->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo('user.deactivated'));
foreach ($endpoints as $endpoint) {
$delivery = WebhookDelivery::create([
'webhook_endpoint_id' => $endpoint->id,
'event' => 'user.deactivated',
'payload' => [
'user_id' => $user->id,
'email' => $user->email,
'deactivated_at' => now()->toIso8601String(),
],
'created_at' => now(),
]);
SendWebhookJob::dispatch($delivery);
}
}
}

77
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Http\Request;
#[Fillable(['actor_id', 'action', 'subject_type', 'subject_id', 'subject_label', 'metadata', 'ip_address', 'user_agent', 'created_at'])]
class AuditLog extends Model
{
const UPDATED_AT = null;
protected $casts = [
'metadata' => 'array',
];
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'actor_id');
}
/**
* Listado paginado y filtrable — compartido por Admin\AuditLogController
* (sesión) y Api\AuditLogController (token), igual que AnalyticsSummary.
*/
public static function paginatedFor(Request $request)
{
$perPage = min(max((int) $request->integer('per_page', 25), 5), 100);
return self::query()
->with('actor:id,name,email')
->when($request->filled('action'), fn ($q) => $q->where('action', $request->string('action')))
->when($request->filled('subject_type'), fn ($q) => $q->where('subject_type', $request->string('subject_type')))
->latest('created_at')
->paginate($perPage)
->through(fn ($log) => [
'id' => $log->id,
'actor_name' => $log->actor?->name,
'actor_email' => $log->actor?->email,
'action' => $log->action,
'subject_type' => $log->subject_type,
'subject_id' => $log->subject_id,
'subject_label' => $log->subject_label,
'metadata' => $log->metadata,
'ip_address' => $log->ip_address,
'created_at' => $log->created_at,
]);
}
/**
* Registra un evento de auditoría. $subjectId se castea a string a propósito:
* cubre tanto ids autoincrementales (User) como uuid (Client) sin necesitar
* una relación polimórfica real (que mezclaría tipos de clave entre modelos).
*/
public static function record(
Request $request,
string $action,
string $subjectType,
int|string $subjectId,
?string $subjectLabel = null,
?array $metadata = null,
): void {
self::create([
'actor_id' => $request->user()?->id,
'action' => $action,
'subject_type' => $subjectType,
'subject_id' => (string) $subjectId,
'subject_label' => $subjectLabel,
'metadata' => $metadata,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => now(),
]);
}
}

View File

@@ -3,11 +3,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Passport\Client;
#[Fillable(['user_id', 'client_id', 'type', 'ip_address', 'user_agent', 'created_at'])]
#[Fillable(['user_id', 'client_id', 'type', 'outcome', 'ip_address', 'user_agent', 'created_at'])]
class LoginEvent extends Model
{
const UPDATED_AT = null;
@@ -21,4 +22,9 @@ class LoginEvent extends Model
{
return $this->belongsTo(Client::class);
}
public function scopeSuccessful(Builder $query): Builder
{
return $query->where('outcome', 'success');
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Passport\Client;
#[Fillable(['user_id', 'client_id', 'outcome', 'scopes', 'ip_address', 'user_agent', 'created_at'])]
class OAuthAuthorizationEvent extends Model
{
const UPDATED_AT = null;
protected $table = 'oauth_authorization_events';
protected $casts = [
'scopes' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Notifications\ResetPasswordNotification;
use App\Support\TwoFactorVerification;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -10,11 +12,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Hash;
use Laravel\Passport\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password', 'is_active', 'approved_at'])]
#[Hidden(['password', 'remember_token'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
@@ -33,6 +37,9 @@ class User extends Authenticatable
'is_active' => 'boolean',
'deleted_at' => 'datetime',
'approved_at' => 'datetime',
'two_factor_secret' => 'encrypted',
'two_factor_recovery_codes' => 'encrypted:array',
'two_factor_confirmed_at' => 'datetime',
];
}
@@ -40,4 +47,41 @@ class User extends Authenticatable
{
return $this->approved_at === null;
}
public function hasTwoFactorEnabled(): bool
{
return $this->two_factor_confirmed_at !== null;
}
/**
* Hook que reconoce Passport\Bridge\UserRepository para el Password Grant.
* Sin esto, POST /oauth/token con grant_type=password sólo chequeaba la
* contraseña — un bypass real de 2FA si el usuario lo tenía activo para
* el login de sesión. Con 2FA activo exige además un campo "otp" (código
* TOTP o de recuperación) en el mismo request.
*/
public function validateForPassportPasswordGrant(string $password): bool
{
if (! Hash::check($password, $this->password)) {
return false;
}
if (! $this->hasTwoFactorEnabled()) {
return true;
}
$otp = request()->input('otp');
return is_string($otp) && $otp !== '' && TwoFactorVerification::verify($this, $otp);
}
/**
* Override del notification default de Laravel (en inglés, sin plantilla
* propia) — manda el email en el idioma de la sesión que originó el
* pedido, ver App\Notifications\ResetPasswordNotification.
*/
public function sendPasswordResetNotification($token): void
{
$this->notify(new ResetPasswordNotification($token, App::getLocale()));
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Http\Request;
#[Fillable(['webhook_endpoint_id', 'event', 'payload', 'response_status', 'response_body', 'attempts', 'delivered_at', 'created_at'])]
class WebhookDelivery extends Model
{
const UPDATED_AT = null;
protected $casts = [
'payload' => 'array',
'delivered_at' => 'datetime',
];
public function endpoint(): BelongsTo
{
return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id');
}
/**
* Historial paginado de intentos de entrega de un webhook — compartido
* por OAuth\WebhookController (sesión) y Api\WebhookController (token),
* mismo patrón que AuditLog::paginatedFor.
*/
public static function paginatedFor(WebhookEndpoint $endpoint, Request $request)
{
$perPage = min(max((int) $request->integer('per_page', 15), 5), 100);
return self::where('webhook_endpoint_id', $endpoint->id)
->latest('created_at')
->paginate($perPage)
->through(fn (self $delivery) => [
'id' => $delivery->id,
'event' => $delivery->event,
'response_status' => $delivery->response_status,
'response_body' => $delivery->response_body,
'attempts' => $delivery->attempts,
'delivered_at' => $delivery->delivered_at,
'created_at' => $delivery->created_at,
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Passport\Client;
#[Fillable(['client_id', 'url', 'secret', 'events', 'active'])]
#[Hidden(['secret'])]
class WebhookEndpoint extends Model
{
protected $casts = [
'secret' => 'encrypted',
'events' => 'array',
'active' => 'boolean',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function deliveries(): HasMany
{
return $this->hasMany(WebhookDelivery::class);
}
public function subscribesTo(string $event): bool
{
return $this->active && in_array($event, $this->events ?? [], true);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\App;
class ResetPasswordNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public readonly string $token, private readonly string $emailLocale)
{
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
// Seteamos el locale de la app explícitamente antes de construir los
// strings: los __() de acá abajo salen en el idioma de la sesión que
// pidió el reset (no el locale por defecto del worker de la cola).
App::setLocale($this->emailLocale);
$url = url('/reset-password').'?'.http_build_query([
'token' => $this->token,
'email' => $notifiable->getEmailForPasswordReset(),
]);
return (new MailMessage)
->subject(__('messages.reset_password_subject'))
->line(__('messages.reset_password_intro'))
->action(__('messages.reset_password_action'), $url)
->line(__('messages.reset_password_expire', ['count' => config('auth.passwords.users.expire')]))
->line(__('messages.reset_password_no_action'));
}
}

View File

@@ -2,10 +2,11 @@
namespace App\Providers;
use App\Listeners\RecordOAuthLoginEvent;
use Illuminate\Support\Facades\Event;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Passport\Events\AccessTokenCreated;
use Laravel\Passport\Bridge\ClientRepository as PassportClientRepository;
use Laravel\Passport\Passport;
class AppServiceProvider extends ServiceProvider
@@ -15,7 +16,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
// Acepta también el client_secret anterior durante su periodo de
// gracia al rotar — ver App\Services\Passport\ClientRepository.
$this->app->bind(PassportClientRepository::class, \App\Services\Passport\ClientRepository::class);
}
/**
@@ -45,6 +48,44 @@ class AppServiceProvider extends ServiceProvider
// como JSON para que el JS los lea (ver resources/views/app.blade.php).
Passport::authorizationView('app');
Event::listen(AccessTokenCreated::class, RecordOAuthLoginEvent::class);
// Los listeners de app/Listeners se auto-descubren por su handle()
// tipado (Laravel 11+) — no hace falta Event::listen() acá. Registrarlo
// a mano ADEMÁS del auto-discovery duplica cada evento (se vio con
// event:list: cada listener aparecía dos veces), lo que duplicaba
// filas en login_events y disparaba cada webhook dos veces.
$this->configureRateLimiting();
}
/**
* Límites para endpoints sensibles a fuerza bruta: login, registro y
* emisión de tokens OAuth. Password grant vive en el mismo endpoint que
* refresh/client_credentials, así que el límite de /oauth/token se aplica
* por (ip, client_id) — no penaliza a todos los clientes por igual si uno
* solo está siendo atacado.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('login', function (Request $request) {
$key = strtolower((string) $request->input('email')).'|'.$request->ip();
return Limit::perMinute(5)->by($key);
});
RateLimiter::for('register', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
RateLimiter::for('oauth-token', function (Request $request) {
$key = $request->ip().'|'.$request->input('client_id');
return Limit::perMinute(20)->by($key);
});
RateLimiter::for('password-reset', function (Request $request) {
$key = strtolower((string) $request->input('email')).'|'.$request->ip();
return Limit::perMinute(5)->by($key);
});
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Rules;
use App\Support\WebhookEndpoints;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class SafeWebhookUrl implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! WebhookEndpoints::isSafeUrl($value)) {
$fail(__('messages.webhook_url_not_allowed'));
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Services\Passport;
use Illuminate\Support\Carbon;
use Laravel\Passport\Bridge\ClientRepository as BaseClientRepository;
/**
* Extiende la validación de client_secret de Passport para aceptar, además
* del secret actual, el secret anterior si todavía no venció su periodo de
* gracia (ver App\Support\ClientSecretRotation). Sin esto, rotar un secret
* cortaría cualquier integración desplegada que todavía no se actualizó.
*
* Se bindea en AppServiceProvider::register() en vez del ClientRepository
* de base — Passport lo resuelve vía el contenedor (no como singleton
* explícito), así que un bind alcanza para reemplazarlo en todos lados
* donde se arma el AuthorizationServer.
*/
class ClientRepository extends BaseClientRepository
{
public function validateClient(string $clientIdentifier, ?string $clientSecret, ?string $grantType): bool
{
if (parent::validateClient($clientIdentifier, $clientSecret, $grantType)) {
return true;
}
if (empty($clientSecret)) {
return false;
}
$record = $this->clients->findActive($clientIdentifier);
if (! $record || ! $record->previous_secret || ! $record->previous_secret_expires_at) {
return false;
}
if (Carbon::parse($record->previous_secret_expires_at)->isPast()) {
return false;
}
return $this->hasher->check($clientSecret, $record->previous_secret);
}
}

View File

@@ -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()

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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();
}
}

View 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,
];
}
}