From c5ce8a7a36e32c27412f9208fa742d604c14ce60 Mon Sep 17 00:00:00 2001
From: Josep
Date: Mon, 27 Jul 2026 17:20:02 +0200
Subject: [PATCH] new funcionality
---
app/Events/TokenRevoked.php | 15 +
app/Events/UserDeactivated.php | 15 +
.../Controllers/Admin/AuditLogController.php | 15 +
app/Http/Controllers/Admin/UserController.php | 73 ++-
.../Admin/UserSessionController.php | 37 ++
.../Controllers/Api/AuditLogController.php | 43 ++
app/Http/Controllers/Api/ClientController.php | 51 ++-
app/Http/Controllers/Api/UserController.php | 11 +-
.../Controllers/Api/UserSessionController.php | 98 ++++
.../Controllers/Api/WebhookController.php | 220 +++++++++
app/Http/Controllers/Auth/AuthController.php | 78 +++-
.../Auth/PasswordResetController.php | 63 +++
.../Controllers/Auth/TwoFactorController.php | 131 ++++++
.../OAuth/AuthorizationEventController.php | 39 ++
.../OAuth/AuthorizedAppController.php | 4 +-
.../Controllers/OAuth/ClientController.php | 41 +-
.../Controllers/OAuth/WebhookController.php | 124 +++++
app/Jobs/SendWebhookJob.php | 89 ++++
.../DispatchTokenRevokedWebhooks.php | 37 ++
.../DispatchUserDeactivatedWebhooks.php | 49 ++
app/Models/AuditLog.php | 77 ++++
app/Models/LoginEvent.php | 8 +-
app/Models/OAuthAuthorizationEvent.php | 30 ++
app/Models/User.php | 46 +-
app/Models/WebhookDelivery.php | 47 ++
app/Models/WebhookEndpoint.php | 36 ++
.../ResetPasswordNotification.php | 43 ++
app/Providers/AppServiceProvider.php | 51 ++-
app/Rules/SafeWebhookUrl.php | 17 +
app/Services/Passport/ClientRepository.php | 43 ++
app/Support/AnalyticsSummary.php | 111 ++++-
app/Support/ClientSecretRotation.php | 37 ++
app/Support/TokenRevocation.php | 28 ++
app/Support/TwoFactorVerification.php | 33 ++
app/Support/UserSessions.php | 58 +++
app/Support/WebhookEndpoints.php | 81 ++++
composer.json | 1 +
composer.lock | 54 ++-
config/passport.php | 6 +-
database/factories/UserFactory.php | 12 +
...0000_add_outcome_to_login_events_table.php | 32 ++
...reate_oauth_authorization_events_table.php | 37 ++
...6_07_26_140000_create_audit_logs_table.php | 47 ++
...07_26_140001_add_audit_view_permission.php | 34 ++
..._add_two_factor_columns_to_users_table.php | 35 ++
..._100000_create_webhook_endpoints_table.php | 38 ++
...100001_create_webhook_deliveries_table.php | 36 ++
...secret_rotation_to_oauth_clients_table.php | 33 ++
.../seeders/RolesAndPermissionsSeeder.php | 2 +-
docs/manual-usuario.html | 338 ++++++++++++--
docs/manual-usuario.pdf | Bin 496612 -> 455224 bytes
lang/ca/messages.php | 13 +
lang/en/messages.php | 13 +
lang/es/messages.php | 13 +
package-lock.json | 226 +++++++++-
package.json | 2 +
public/images/favicon.png | Bin 0 -> 17349 bytes
resources/images/logo-black.png | Bin 0 -> 10757 bytes
resources/images/logo-symbol.png | Bin 0 -> 37742 bytes
resources/images/logo-white.png | Bin 0 -> 11494 bytes
resources/js/App.vue | 73 +--
resources/js/components/AppLogo.vue | 7 +
resources/js/i18n/ca.ts | 147 ++++++
resources/js/i18n/en.ts | 147 ++++++
resources/js/i18n/es.ts | 147 ++++++
resources/js/pages/Authorize.vue | 36 +-
resources/js/pages/Clients.vue | 358 ++++++++++++++-
resources/js/pages/Dashboard.vue | 5 +
resources/js/pages/ForgotPassword.vue | 63 +++
resources/js/pages/Login.vue | 107 +++--
resources/js/pages/Register.vue | 5 +-
resources/js/pages/ResetPassword.vue | 81 ++++
resources/js/pages/Security.vue | 259 +++++++++++
resources/js/pages/admin/Analytics.vue | 93 +++-
resources/js/pages/admin/AuditLog.vue | 211 +++++++++
resources/js/pages/admin/Users.vue | 84 +++-
resources/js/router/index.ts | 24 +
resources/js/stores/auth.ts | 17 +-
resources/js/types/models.ts | 68 +++
resources/scss/app.scss | 1 +
resources/scss/base/_print.scss | 29 ++
resources/scss/components/_app-nav.scss | 22 +-
resources/scss/components/_auth-card.scss | 11 +-
resources/scss/components/_badge.scss | 4 +
resources/scss/components/_field.scss | 1 +
resources/views/app.blade.php | 1 +
routes/api.php | 20 +
routes/web.php | 48 +-
tests/Feature/Admin/UserCrudTest.php | 207 +++++++++
tests/Feature/Admin/UserSessionTest.php | 136 ++++++
tests/Feature/Api/ClientCrudTest.php | 150 +++++++
tests/Feature/Auth/LoginTest.php | 169 +++++++
.../Auth/PasswordGrantTwoFactorTest.php | 115 +++++
tests/Feature/Auth/PasswordResetTest.php | 190 ++++++++
tests/Feature/Auth/RegistrationTest.php | 83 ++++
tests/Feature/Auth/TwoFactorTest.php | 129 ++++++
tests/Feature/ClientCrudTest.php | 150 +++++++
tests/Feature/ClientSecretRotationTest.php | 123 +++++
tests/Feature/EventRegistrationTest.php | 45 ++
tests/Feature/RateLimitingTest.php | 125 ++++++
tests/Feature/WebhookTest.php | 423 ++++++++++++++++++
101 files changed, 6889 insertions(+), 176 deletions(-)
create mode 100644 app/Events/TokenRevoked.php
create mode 100644 app/Events/UserDeactivated.php
create mode 100644 app/Http/Controllers/Admin/AuditLogController.php
create mode 100644 app/Http/Controllers/Admin/UserSessionController.php
create mode 100644 app/Http/Controllers/Api/AuditLogController.php
create mode 100644 app/Http/Controllers/Api/UserSessionController.php
create mode 100644 app/Http/Controllers/Api/WebhookController.php
create mode 100644 app/Http/Controllers/Auth/PasswordResetController.php
create mode 100644 app/Http/Controllers/Auth/TwoFactorController.php
create mode 100644 app/Http/Controllers/OAuth/AuthorizationEventController.php
create mode 100644 app/Http/Controllers/OAuth/WebhookController.php
create mode 100644 app/Jobs/SendWebhookJob.php
create mode 100644 app/Listeners/DispatchTokenRevokedWebhooks.php
create mode 100644 app/Listeners/DispatchUserDeactivatedWebhooks.php
create mode 100644 app/Models/AuditLog.php
create mode 100644 app/Models/OAuthAuthorizationEvent.php
create mode 100644 app/Models/WebhookDelivery.php
create mode 100644 app/Models/WebhookEndpoint.php
create mode 100644 app/Notifications/ResetPasswordNotification.php
create mode 100644 app/Rules/SafeWebhookUrl.php
create mode 100644 app/Services/Passport/ClientRepository.php
create mode 100644 app/Support/ClientSecretRotation.php
create mode 100644 app/Support/TokenRevocation.php
create mode 100644 app/Support/TwoFactorVerification.php
create mode 100644 app/Support/UserSessions.php
create mode 100644 app/Support/WebhookEndpoints.php
create mode 100644 database/migrations/2026_07_26_130000_add_outcome_to_login_events_table.php
create mode 100644 database/migrations/2026_07_26_130001_create_oauth_authorization_events_table.php
create mode 100644 database/migrations/2026_07_26_140000_create_audit_logs_table.php
create mode 100644 database/migrations/2026_07_26_140001_add_audit_view_permission.php
create mode 100644 database/migrations/2026_07_26_150000_add_two_factor_columns_to_users_table.php
create mode 100644 database/migrations/2026_07_27_100000_create_webhook_endpoints_table.php
create mode 100644 database/migrations/2026_07_27_100001_create_webhook_deliveries_table.php
create mode 100644 database/migrations/2026_07_27_120000_add_secret_rotation_to_oauth_clients_table.php
create mode 100644 public/images/favicon.png
create mode 100644 resources/images/logo-black.png
create mode 100644 resources/images/logo-symbol.png
create mode 100644 resources/images/logo-white.png
create mode 100644 resources/js/components/AppLogo.vue
create mode 100644 resources/js/pages/ForgotPassword.vue
create mode 100644 resources/js/pages/ResetPassword.vue
create mode 100644 resources/js/pages/Security.vue
create mode 100644 resources/js/pages/admin/AuditLog.vue
create mode 100644 resources/scss/base/_print.scss
create mode 100644 tests/Feature/Admin/UserCrudTest.php
create mode 100644 tests/Feature/Admin/UserSessionTest.php
create mode 100644 tests/Feature/Api/ClientCrudTest.php
create mode 100644 tests/Feature/Auth/LoginTest.php
create mode 100644 tests/Feature/Auth/PasswordGrantTwoFactorTest.php
create mode 100644 tests/Feature/Auth/PasswordResetTest.php
create mode 100644 tests/Feature/Auth/RegistrationTest.php
create mode 100644 tests/Feature/Auth/TwoFactorTest.php
create mode 100644 tests/Feature/ClientCrudTest.php
create mode 100644 tests/Feature/ClientSecretRotationTest.php
create mode 100644 tests/Feature/EventRegistrationTest.php
create mode 100644 tests/Feature/RateLimitingTest.php
create mode 100644 tests/Feature/WebhookTest.php
diff --git a/app/Events/TokenRevoked.php b/app/Events/TokenRevoked.php
new file mode 100644
index 0000000..339937f
--- /dev/null
+++ b/app/Events/TokenRevoked.php
@@ -0,0 +1,15 @@
+json(AuditLog::paginatedFor($request));
+ }
+}
diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php
index ed26264..a931866 100644
--- a/app/Http/Controllers/Admin/UserController.php
+++ b/app/Http/Controllers/Admin/UserController.php
@@ -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 [
diff --git a/app/Http/Controllers/Admin/UserSessionController.php b/app/Http/Controllers/Admin/UserSessionController.php
new file mode 100644
index 0000000..915335e
--- /dev/null
+++ b/app/Http/Controllers/Admin/UserSessionController.php
@@ -0,0 +1,37 @@
+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();
+ }
+}
diff --git a/app/Http/Controllers/Api/AuditLogController.php b/app/Http/Controllers/Api/AuditLogController.php
new file mode 100644
index 0000000..6873b75
--- /dev/null
+++ b/app/Http/Controllers/Api/AuditLogController.php
@@ -0,0 +1,43 @@
+ ['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));
+ }
+}
diff --git a/app/Http/Controllers/Api/ClientController.php b/app/Http/Controllers/Api/ClientController.php
index 0f1d171..f2df5b4 100644
--- a/app/Http/Controllers/Api/ClientController.php
+++ b/app/Http/Controllers/Api/ClientController.php
@@ -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();
}
diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php
index fcfb95f..378ef1d 100644
--- a/app/Http/Controllers/Api/UserController.php
+++ b/app/Http/Controllers/Api/UserController.php
@@ -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(
diff --git a/app/Http/Controllers/Api/UserSessionController.php b/app/Http/Controllers/Api/UserSessionController.php
new file mode 100644
index 0000000..604b8b5
--- /dev/null
+++ b/app/Http/Controllers/Api/UserSessionController.php
@@ -0,0 +1,98 @@
+ ['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();
+ }
+}
diff --git a/app/Http/Controllers/Api/WebhookController.php b/app/Http/Controllers/Api/WebhookController.php
new file mode 100644
index 0000000..f6462b4
--- /dev/null
+++ b/app/Http/Controllers/Api/WebhookController.php
@@ -0,0 +1,220 @@
+ ['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'));
+ }
+}
diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php
index d221bdb..65769f4 100644
--- a/app/Http/Controllers/Auth/AuthController.php
+++ b/app/Http/Controllers/Auth/AuthController.php
@@ -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(),
];
}
}
diff --git a/app/Http/Controllers/Auth/PasswordResetController.php b/app/Http/Controllers/Auth/PasswordResetController.php
new file mode 100644
index 0000000..561210a
--- /dev/null
+++ b/app/Http/Controllers/Auth/PasswordResetController.php
@@ -0,0 +1,63 @@
+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();
+ }
+}
diff --git a/app/Http/Controllers/Auth/TwoFactorController.php b/app/Http/Controllers/Auth/TwoFactorController.php
new file mode 100644
index 0000000..6aebd29
--- /dev/null
+++ b/app/Http/Controllers/Auth/TwoFactorController.php
@@ -0,0 +1,131 @@
+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();
+ }
+}
diff --git a/app/Http/Controllers/OAuth/AuthorizationEventController.php b/app/Http/Controllers/OAuth/AuthorizationEventController.php
new file mode 100644
index 0000000..94119a0
--- /dev/null
+++ b/app/Http/Controllers/OAuth/AuthorizationEventController.php
@@ -0,0 +1,39 @@
+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();
+ }
+}
diff --git a/app/Http/Controllers/OAuth/AuthorizedAppController.php b/app/Http/Controllers/OAuth/AuthorizedAppController.php
index 4aad1a3..b7c59ca 100644
--- a/app/Http/Controllers/OAuth/AuthorizedAppController.php
+++ b/app/Http/Controllers/OAuth/AuthorizedAppController.php
@@ -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();
}
diff --git a/app/Http/Controllers/OAuth/ClientController.php b/app/Http/Controllers/OAuth/ClientController.php
index 3b29318..23ef009 100644
--- a/app/Http/Controllers/OAuth/ClientController.php
+++ b/app/Http/Controllers/OAuth/ClientController.php
@@ -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();
}
diff --git a/app/Http/Controllers/OAuth/WebhookController.php b/app/Http/Controllers/OAuth/WebhookController.php
new file mode 100644
index 0000000..fe88c96
--- /dev/null
+++ b/app/Http/Controllers/OAuth/WebhookController.php
@@ -0,0 +1,124 @@
+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]);
+ }
+}
diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php
new file mode 100644
index 0000000..db9d2d7
--- /dev/null
+++ b/app/Jobs/SendWebhookJob.php
@@ -0,0 +1,89 @@
+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;
+ }
+ }
+}
diff --git a/app/Listeners/DispatchTokenRevokedWebhooks.php b/app/Listeners/DispatchTokenRevokedWebhooks.php
new file mode 100644
index 0000000..4d26280
--- /dev/null
+++ b/app/Listeners/DispatchTokenRevokedWebhooks.php
@@ -0,0 +1,37 @@
+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);
+ }
+ }
+}
diff --git a/app/Listeners/DispatchUserDeactivatedWebhooks.php b/app/Listeners/DispatchUserDeactivatedWebhooks.php
new file mode 100644
index 0000000..6e20c99
--- /dev/null
+++ b/app/Listeners/DispatchUserDeactivatedWebhooks.php
@@ -0,0 +1,49 @@
+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);
+ }
+ }
+}
diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php
new file mode 100644
index 0000000..3030a10
--- /dev/null
+++ b/app/Models/AuditLog.php
@@ -0,0 +1,77 @@
+ '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(),
+ ]);
+ }
+}
diff --git a/app/Models/LoginEvent.php b/app/Models/LoginEvent.php
index 99319fa..fad9969 100644
--- a/app/Models/LoginEvent.php
+++ b/app/Models/LoginEvent.php
@@ -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');
+ }
}
diff --git a/app/Models/OAuthAuthorizationEvent.php b/app/Models/OAuthAuthorizationEvent.php
new file mode 100644
index 0000000..12bdaaf
--- /dev/null
+++ b/app/Models/OAuthAuthorizationEvent.php
@@ -0,0 +1,30 @@
+ 'array',
+ ];
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function client(): BelongsTo
+ {
+ return $this->belongsTo(Client::class);
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index fac7787..49d5430 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -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 */
@@ -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()));
+ }
}
diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php
new file mode 100644
index 0000000..af7173a
--- /dev/null
+++ b/app/Models/WebhookDelivery.php
@@ -0,0 +1,47 @@
+ '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,
+ ]);
+ }
+}
diff --git a/app/Models/WebhookEndpoint.php b/app/Models/WebhookEndpoint.php
new file mode 100644
index 0000000..61a084a
--- /dev/null
+++ b/app/Models/WebhookEndpoint.php
@@ -0,0 +1,36 @@
+ '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);
+ }
+}
diff --git a/app/Notifications/ResetPasswordNotification.php b/app/Notifications/ResetPasswordNotification.php
new file mode 100644
index 0000000..fb5c341
--- /dev/null
+++ b/app/Notifications/ResetPasswordNotification.php
@@ -0,0 +1,43 @@
+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'));
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 93655d9..777670e 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -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);
+ });
}
}
diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php
new file mode 100644
index 0000000..4688af2
--- /dev/null
+++ b/app/Rules/SafeWebhookUrl.php
@@ -0,0 +1,17 @@
+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);
+ }
+}
diff --git a/app/Support/AnalyticsSummary.php b/app/Support/AnalyticsSummary.php
index 395ec72..31e7cb7 100644
--- a/app/Support/AnalyticsSummary.php
+++ b/app/Support/AnalyticsSummary.php
@@ -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()
diff --git a/app/Support/ClientSecretRotation.php b/app/Support/ClientSecretRotation.php
new file mode 100644
index 0000000..4f4e419
--- /dev/null
+++ b/app/Support/ClientSecretRotation.php
@@ -0,0 +1,37 @@
+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;
+ }
+}
diff --git a/app/Support/TokenRevocation.php b/app/Support/TokenRevocation.php
new file mode 100644
index 0000000..57f983e
--- /dev/null
+++ b/app/Support/TokenRevocation.php
@@ -0,0 +1,28 @@
+revoked) {
+ return;
+ }
+
+ $token->revoke();
+ $token->refreshToken?->revoke();
+
+ TokenRevoked::dispatch($token);
+ }
+}
diff --git a/app/Support/TwoFactorVerification.php b/app/Support/TwoFactorVerification.php
new file mode 100644
index 0000000..285f1cc
--- /dev/null
+++ b/app/Support/TwoFactorVerification.php
@@ -0,0 +1,33 @@
+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;
+ }
+}
diff --git a/app/Support/UserSessions.php b/app/Support/UserSessions.php
new file mode 100644
index 0000000..b7b4433
--- /dev/null
+++ b/app/Support/UserSessions.php
@@ -0,0 +1,58 @@
+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();
+ }
+}
diff --git a/app/Support/WebhookEndpoints.php b/app/Support/WebhookEndpoints.php
new file mode 100644
index 0000000..3835332
--- /dev/null
+++ b/app/Support/WebhookEndpoints.php
@@ -0,0 +1,81 @@
+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,
+ ];
+ }
+}
diff --git a/composer.json b/composer.json
index 9159e33..9459896 100644
--- a/composer.json
+++ b/composer.json
@@ -11,6 +11,7 @@
"laravel/framework": "^13.8",
"laravel/passport": "^13.0",
"laravel/tinker": "^3.0",
+ "pragmarx/google2fa": "^9.0",
"spatie/laravel-permission": "^8.3"
},
"require-dev": {
diff --git a/composer.lock b/composer.lock
index cc9089f..f09cba6 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "020ec5a0def24e12e3226eb848009f38",
+ "content-hash": "f0dc0266f5d459fae88ba4daaa34009e",
"packages": [
{
"name": "brick/math",
@@ -3489,6 +3489,58 @@
},
"time": "2026-07-08T07:01:06+00:00"
},
+ {
+ "name": "pragmarx/google2fa",
+ "version": "v9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/antonioribeiro/google2fa.git",
+ "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
+ "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
+ "shasum": ""
+ },
+ "require": {
+ "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
+ "php": "^7.1|^8.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.9",
+ "phpunit/phpunit": "^7.5.15|^8.5|^9.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PragmaRX\\Google2FA\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Antonio Carlos Ribeiro",
+ "email": "acr@antoniocarlosribeiro.com",
+ "role": "Creator & Designer"
+ }
+ ],
+ "description": "A One Time Password Authentication package, compatible with Google Authenticator.",
+ "keywords": [
+ "2fa",
+ "Authentication",
+ "Two Factor Authentication",
+ "google2fa"
+ ],
+ "support": {
+ "issues": "https://github.com/antonioribeiro/google2fa/issues",
+ "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
+ },
+ "time": "2025-09-19T22:51:08+00:00"
+ },
{
"name": "psr/clock",
"version": "1.0.0",
diff --git a/config/passport.php b/config/passport.php
index aed4358..8f305f6 100644
--- a/config/passport.php
+++ b/config/passport.php
@@ -15,7 +15,11 @@ return [
'guard' => 'web',
- 'middleware' => [],
+ // Limitador propio ADEMÁS del "throttle" (60/min por IP) que Passport ya
+ // trae hardcodeado en /oauth/token — este es más estricto y va por
+ // (ip, client_id), así que un ataque de fuerza bruta contra un client no
+ // le come el cupo a los demás. Ver AppServiceProvider::configureRateLimiting().
+ 'middleware' => ['throttle:oauth-token'],
/*
|--------------------------------------------------------------------------
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
index c4ceb07..b59fba8 100644
--- a/database/factories/UserFactory.php
+++ b/database/factories/UserFactory.php
@@ -42,4 +42,16 @@ class UserFactory extends Factory
'email_verified_at' => null,
]);
}
+
+ /**
+ * approved_at es nullable sin default (ver migración add_approved_at):
+ * un usuario recién creado por el factory queda "pendiente" (isPending()
+ * === true) salvo que se pida explícitamente este estado.
+ */
+ public function approved(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'approved_at' => now(),
+ ]);
+ }
}
diff --git a/database/migrations/2026_07_26_130000_add_outcome_to_login_events_table.php b/database/migrations/2026_07_26_130000_add_outcome_to_login_events_table.php
new file mode 100644
index 0000000..99c12d1
--- /dev/null
+++ b/database/migrations/2026_07_26_130000_add_outcome_to_login_events_table.php
@@ -0,0 +1,32 @@
+string('outcome')->default('success')->after('type');
+ $table->index(['type', 'outcome', 'created_at']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('login_events', function (Blueprint $table) {
+ $table->dropIndex(['type', 'outcome', 'created_at']);
+ $table->dropColumn('outcome');
+ });
+ }
+};
diff --git a/database/migrations/2026_07_26_130001_create_oauth_authorization_events_table.php b/database/migrations/2026_07_26_130001_create_oauth_authorization_events_table.php
new file mode 100644
index 0000000..fe86bb8
--- /dev/null
+++ b/database/migrations/2026_07_26_130001_create_oauth_authorization_events_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->foreignUuid('client_id')->nullable()->constrained('oauth_clients')->nullOnDelete();
+ $table->string('outcome'); // 'attempted' | 'approved' | 'denied'
+ $table->json('scopes')->nullable();
+ $table->string('ip_address', 45)->nullable();
+ $table->string('user_agent')->nullable();
+ $table->timestamp('created_at')->nullable();
+
+ $table->index(['created_at']);
+ $table->index(['client_id', 'created_at']);
+ $table->index(['outcome']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('oauth_authorization_events');
+ }
+};
diff --git a/database/migrations/2026_07_26_140000_create_audit_logs_table.php b/database/migrations/2026_07_26_140000_create_audit_logs_table.php
new file mode 100644
index 0000000..a77ceda
--- /dev/null
+++ b/database/migrations/2026_07_26_140000_create_audit_logs_table.php
@@ -0,0 +1,47 @@
+id();
+ $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->string('action'); // 'user.created', 'client.revoked', etc.
+ // subject_id es string (no FK/morphs): cubre tanto ids autoincrementales
+ // (User) como uuid (Client) sin mezclar tipos en una sola columna.
+ $table->string('subject_type'); // 'user' | 'client'
+ $table->string('subject_id');
+ // Snapshot de texto (email del usuario, nombre del client) al momento
+ // del evento — sigue siendo legible aunque el sujeto se borre después.
+ $table->string('subject_label')->nullable();
+ // Se llama "metadata" y no "changes": ese nombre colisiona con la
+ // propiedad protegida $changes que Eloquent usa internamente para
+ // dirty-tracking (HasAttributes), y el choque solo se manifiesta
+ // cuando se accede al atributo desde dentro de esta misma clase.
+ $table->json('metadata')->nullable();
+ $table->string('ip_address', 45)->nullable();
+ $table->string('user_agent')->nullable();
+ $table->timestamp('created_at')->nullable();
+
+ $table->index(['created_at']);
+ $table->index(['action', 'created_at']);
+ $table->index(['subject_type', 'subject_id']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('audit_logs');
+ }
+};
diff --git a/database/migrations/2026_07_26_140001_add_audit_view_permission.php b/database/migrations/2026_07_26_140001_add_audit_view_permission.php
new file mode 100644
index 0000000..6a1378a
--- /dev/null
+++ b/database/migrations/2026_07_26_140001_add_audit_view_permission.php
@@ -0,0 +1,34 @@
+first();
+
+ if ($admin && ! $admin->hasPermissionTo($permission)) {
+ $admin->givePermissionTo($permission);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Permission::where('name', 'audit.view')->delete();
+ }
+};
diff --git a/database/migrations/2026_07_26_150000_add_two_factor_columns_to_users_table.php b/database/migrations/2026_07_26_150000_add_two_factor_columns_to_users_table.php
new file mode 100644
index 0000000..ae623b3
--- /dev/null
+++ b/database/migrations/2026_07_26_150000_add_two_factor_columns_to_users_table.php
@@ -0,0 +1,35 @@
+text('two_factor_secret')->nullable()->after('password');
+ $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret');
+ $table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_recovery_codes');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $table) {
+ $table->dropColumn(['two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at']);
+ });
+ }
+};
diff --git a/database/migrations/2026_07_27_100000_create_webhook_endpoints_table.php b/database/migrations/2026_07_27_100000_create_webhook_endpoints_table.php
new file mode 100644
index 0000000..25a89a9
--- /dev/null
+++ b/database/migrations/2026_07_27_100000_create_webhook_endpoints_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->foreignUuid('client_id')->constrained('oauth_clients')->cascadeOnDelete();
+ $table->string('url');
+ // Encriptado (cast 'encrypted' en el modelo) — a diferencia del
+ // secret de un client OAuth, este SÍ hay que poder recuperarlo en
+ // texto plano después para firmar cada entrega (HMAC), no solo
+ // verificarlo una vez.
+ $table->text('secret');
+ $table->json('events'); // ej. ['token.revoked', 'user.deactivated']
+ $table->boolean('active')->default(true);
+ $table->timestamps();
+
+ $table->index(['client_id', 'active']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('webhook_endpoints');
+ }
+};
diff --git a/database/migrations/2026_07_27_100001_create_webhook_deliveries_table.php b/database/migrations/2026_07_27_100001_create_webhook_deliveries_table.php
new file mode 100644
index 0000000..c97f45a
--- /dev/null
+++ b/database/migrations/2026_07_27_100001_create_webhook_deliveries_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->foreignId('webhook_endpoint_id')->constrained()->cascadeOnDelete();
+ $table->string('event');
+ $table->json('payload');
+ $table->unsignedSmallInteger('response_status')->nullable();
+ $table->text('response_body')->nullable();
+ $table->unsignedTinyInteger('attempts')->default(0);
+ $table->timestamp('delivered_at')->nullable();
+ $table->timestamp('created_at')->nullable();
+
+ $table->index(['webhook_endpoint_id', 'created_at']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('webhook_deliveries');
+ }
+};
diff --git a/database/migrations/2026_07_27_120000_add_secret_rotation_to_oauth_clients_table.php b/database/migrations/2026_07_27_120000_add_secret_rotation_to_oauth_clients_table.php
new file mode 100644
index 0000000..bc28140
--- /dev/null
+++ b/database/migrations/2026_07_27_120000_add_secret_rotation_to_oauth_clients_table.php
@@ -0,0 +1,33 @@
+text('previous_secret')->nullable()->after('secret');
+ $table->timestamp('previous_secret_expires_at')->nullable()->after('previous_secret');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('oauth_clients', function (Blueprint $table) {
+ $table->dropColumn(['previous_secret', 'previous_secret_expires_at']);
+ });
+ }
+};
diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php
index 3b66af0..f8d1f92 100644
--- a/database/seeders/RolesAndPermissionsSeeder.php
+++ b/database/seeders/RolesAndPermissionsSeeder.php
@@ -16,7 +16,7 @@ class RolesAndPermissionsSeeder extends Seeder
{
public function run(): void
{
- foreach (['users.view', 'users.create', 'users.update', 'users.delete'] as $permission) {
+ foreach (['users.view', 'users.create', 'users.update', 'users.delete', 'audit.view'] as $permission) {
Permission::findOrCreate($permission);
}
diff --git a/docs/manual-usuario.html b/docs/manual-usuario.html
index da437ff..64fa954 100644
--- a/docs/manual-usuario.html
+++ b/docs/manual-usuario.html
@@ -178,7 +178,7 @@
Manual de uso: puesta en marcha, panel de administración, flujos OAuth2 y API REST completa para construir cualquier cliente propio (Angular, Flutter, NestJS, etc.).
Laravel 12 · Passport 13 · Vue 3 + TypeScript · SCSS/BEM · spatie/laravel-permission · L5-Swagger
- Generado el 25 de julio de 2026
+ Generado el 27 de julio de 2026 (actualizado: webhooks salientes + protección anti-SSRF)
@@ -189,13 +189,16 @@
2.Puesta en marcha
3.Usuarios por defecto (seed)
4.Uso de la interfaz web (panel de administración)
- 5.Analíticas: sesiones, logins y actividad
- 6.Roles, permisos y roles por app
- 7.Idioma (español / inglés / catalán)
- 8.Cómo conectar tu app (Angular, NestJS, Flutter, etc.)
- 9.API REST completa: construí tu propio panel admin
- 10.Documentación interactiva (Swagger)
- 11.Referencia rápida de rutas
+ 5.Verificación en dos pasos (2FA)
+ 6.Analíticas: sesiones, logins y actividad
+ 7.Audit log: quién cambió qué
+ 8.Roles, permisos y roles por app
+ 9.Idioma (español / inglés / catalán)
+ 10.Cómo conectar tu app (Angular, NestJS, Flutter, etc.)
+ 11.API REST completa: construí tu propio panel admin
+ 12.Webhooks: enterate sin hacer polling
+ 13.Documentación interactiva (Swagger)
+ 14.Referencia rápida de rutas
@@ -259,6 +262,14 @@ cola de trabajos y los logs. La app queda disponible en http://localhost:8
vuelve a crear los usuarios/clientes de ejemplo descriptos en la sección 3. Los client_secret
de los clientes OAuth de ejemplo se imprimen en la consola en ese momento — solo se muestran una vez.
+
+ Email: por defecto MAIL_MAILER=log — los emails (hoy, solo el de
+ recuperación de contraseña, ver sección 4) se escriben en storage/logs/laravel.log en vez de
+ enviarse de verdad, para no necesitar SMTP en desarrollo. Para producción, configurá un mailer real en
+ .env (MAIL_MAILER, MAIL_HOST, etc. — cualquiera soportado por
+ Laravel). El envío va encolado (ShouldQueue), así que también necesitás un worker corriendo
+ (php artisan queue:work, o composer run dev en desarrollo).
+
@@ -325,6 +336,33 @@ automáticamente, pero no loguea al usuario: queda pendiente de
un mensaje de "Cuenta creada" y el login devuelve error hasta que un admin la aprueba desde el panel de
usuarios (sección 4). Excepción: si la base de usuarios está vacía, esa primera cuenta se
crea como admin y queda logueada de una (ver sección 3).
+
+ Rate limiting: POST /web-api/login admite 5 intentos por minuto por
+ combinación de email + IP (frena fuerza bruta de contraseña sin afectar a otros usuarios que compartan
+ IP), y POST /web-api/register 5 por minuto por IP. Al superar el límite, la respuesta es
+ 429 Too Many Requests con un header Retry-After.
+
+
+Recuperación de contraseña
+
+Desde /login → "¿Olvidaste tu contraseña?" (/forgot-password), cualquiera puede pedir
+un enlace de restablecimiento con solo su email. La respuesta es siempre la misma, exista o
+no una cuenta con ese email — lo contrario permitiría enumerar qué emails están registrados probando uno por
+uno. Si la cuenta existe, se manda un email (ver la nota sobre MAIL_MAILER en la sección 2) con
+un enlace a /reset-password?token=...&email=..., válido durante 60 minutos y
+de un solo uso.
+
+
+ Al restablecer la contraseña se cierran todas las sesiones activas (los tokens OAuth
+ vigentes de esa cuenta, igual que "Cerrar todas" en Sesiones activas — sección 4) y queda un
+ user.password_reset en el audit log (sección 7). 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.
+
+
+ Rate limiting: tanto pedir el enlace como confirmar el restablecimiento admiten 5
+ intentos por minuto por combinación de email + IP.
+
Dashboard
/ — accesos rápidos a Clientes OAuth, Personal Access Tokens, Apps autorizadas y (si sos admin)
@@ -341,6 +379,17 @@ crear uno se elige:
El client_secret se muestra una sola vez, justo después de
crear el cliente. Copiarlo en ese momento.
+Cada cliente tiene, además, un botón "Webhooks" para registrar endpoints que reciben avisos
+cuando pasa algo relevante para esa app (ver sección 12) — sin tener que hacer polling.
+
+ Rotar secret. Los clientes confidenciales (con client_secret) tienen un botón
+ "Rotar secret". Genera un secret nuevo y lo muestra una sola vez, igual que al crear el
+ cliente — pero el secret anterior sigue siendo válido durante 48 horas (grace period), así
+ que podés actualizar la config de tu app sin downtime: mientras dure el grace period, ambos secrets
+ (viejo y nuevo) funcionan contra POST /oauth/token. Pasadas las 48 horas, o si rotás de
+ nuevo antes, el secret anterior deja de servir. No aplica a clientes públicos (Authorization Code + PKCE sin
+ secret) — no hay nada que rotar.
+
Personal Access Tokens
/tokens — tokens tipo "API key" para el propio usuario, sin pasar por el flujo de consentimiento.
@@ -370,41 +419,167 @@ sesión.
igual que un borrado normal — se puede restaurar). Un usuario dado de alta directamente por un admin
(el formulario "Nuevo usuario" de esta misma pantalla) queda aprobado automáticamente.
+
+ Sesiones activas. Dentro de "Editar" (junto a "Roles por app") hay una sección "Sesiones
+ activas" con los tokens OAuth vigentes de ese usuario — qué apps tienen acceso a su cuenta ahora mismo, con
+ scope y fecha de emisión. Cada sesión se puede revocar individualmente, o todas de una con "Cerrar todas".
+ Pensado para incident response: si un usuario reporta una cuenta comprometida (o algo raro aparece en
+ Analíticas/Audit log), un admin puede cortarle el acceso a todo sin esperar a que expire solo.
+
+
+Seguridad: verificación en dos pasos
+/security — cualquier usuario (no solo admins) puede activar 2FA acá. Ver sección 5.
Analíticas (solo admins)
-/admin/analytics — ver sección 5.
+/admin/analytics — ver sección 6.
+
+Audit log (solo admins)
+/admin/audit-log — ver sección 7.
-5. Analíticas: sesiones, logins y actividad
+5. Verificación en dos pasos (2FA)
+
+Cualquier usuario (no hace falta ser admin) puede activar 2FA con TOTP desde /security —
+compatible con Google Authenticator, Authy, 1Password, o cualquier app que hable el estándar TOTP
+(RFC 6238). Es una capa extra sobre la contraseña: una vez activo, el login pide además un código de 6
+dígitos.
+
+Activación
+
+ - El servidor genera un secreto (todavía no activo) y un código QR para escanear.
+ - El usuario escanea el QR con su app autenticadora (o copia el secreto a mano si no puede escanear).
+ - Confirma con un código real generado por la app — recién en ese momento queda activo de verdad. Un
+ secreto generado pero nunca confirmado no habilita nada.
+ - El servidor muestra 8 códigos de recuperación, una sola vez. Cada uno sirve para un
+ único login — son la salida de emergencia si se pierde el teléfono con la app autenticadora.
+
+Login con 2FA activo
+
+El login pasa a ser de dos pasos: primero email + contraseña (esto no crea sesión todavía);
+si el usuario tiene 2FA activo, el servidor responde pidiendo el código en vez de loguear directo. Recién con
+un código válido (TOTP o de recuperación) se crea la sesión. Un código de recuperación usado se invalida al
+instante — no sirve una segunda vez.
+
+
+ Rate limiting: el segundo paso del login (/web-api/login/2fa), confirmar la
+ activación (/2fa/confirm), desactivar (/2fa/disable) y regenerar códigos de
+ recuperación (/2fa/recovery-codes) admiten 6 intentos por minuto — el espacio de un código
+ TOTP de 6 dígitos es chico como para no limitarlo.
+
+
+ Desactivar 2FA o regenerar códigos de recuperación: ambas acciones, desde
+ /security, piden confirmar la contraseña actual. Desactivar borra el secreto y los códigos de
+ recuperación por completo; para volver a activarlo hay que escanear un QR nuevo (no se reutiliza el
+ secreto anterior). Regenerar invalida los 8 códigos viejos y muestra 8 nuevos.
+
+
+
Password Grant y 2FA: si el usuario tiene 2FA activo,
POST /oauth/token con
+
grant_type=password exige además un campo
otp con un código TOTP o de
+ recuperación válido, en el mismo request — sin él, o con uno inválido, el servidor responde
+
400 invalid_grant igual que con una contraseña incorrecta (no delata si el problema fue la
+ contraseña o el 2FA). Los usuarios sin 2FA no notan ningún cambio.
+
curl -X POST /oauth/token -d grant_type=password \
+ -d client_id=... -d client_secret=... \
+ -d username=ana@example.com -d password=... \
+ -d otp=123456 -d scope="users:read"
+
+Cada activación, desactivación y regeneración de códigos queda registrada en el audit log (sección 7).
+
+
+
+6. Analíticas: sesiones, logins y actividad
Pantalla de monitoreo para el admin: cada vez que Passport emite un access token (cualquier grant type) o alguien
inicia sesión en el panel, queda un registro en login_events. Con eso se arman:
- KPIs: usuarios totales, sesiones activas (tokens OAuth vigentes, no revocados ni
- expirados), logins de hoy y de los últimos 7 días.
+ expirados), logins de hoy, logins de los últimos 7 días, tasa de fallos de login y tasa de aprobación
+ OAuth (últimos 7 días).
- Logins por día (últimos 7/30/90 días, elegible arriba a la derecha): barras apiladas —
logins de apps (OAuth) vs. logins al panel admin (sesión) — con tooltip al pasar el mouse sobre cada día.
- Logins por app: qué apps se usan más.
- Usuarios más activos: ranking por cantidad de logins en el período.
+ - Funnel de autorización OAuth: por cada cliente, cuántas veces se mostró la pantalla de
+ consentimiento (
/oauth/authorize), cuántas se aprobaron y cuántas se denegaron, con la tasa de
+ aprobación resultante.
+ - Tokens activos por cliente: snapshot en vivo de tokens OAuth vigentes (no revocados,
+ no expirados), agrupados por app.
+ - Uso de scopes: qué scopes (
users:read, account:write, etc.)
+ aparecen más entre los tokens emitidos en el período.
- Actividad reciente: tabla con los últimos eventos (usuario, app, tipo, IP, cuándo).
+
+ Exportar PDF: el botón "Exportar PDF" arriba a la derecha abre el diálogo de impresión del
+ navegador con una hoja de estilos dedicada — oculta la barra de navegación y los controles, fuerza tema
+ claro y expande la tabla de actividad reciente completa. No genera el PDF en el servidor: es el navegador
+ el que lo arma ("Guardar como PDF" en el diálogo de impresión), así que no depende de ninguna librería ni
+ servicio adicional.
+
+
+ Tasa de fallos de login solo cuenta credenciales inválidas en el login de sesión (panel
+ admin) — no cuenta intentos contra una cuenta desactivada o pendiente de aprobación, porque eso es un
+ estado de cuenta, no una señal de ataque de fuerza bruta.
+
El seeder de demo genera 30 días de actividad sintética para que estos gráficos no arranquen vacíos. En un
- despliegue real, los datos son 100% eventos reales — no hay nada que "activar".
+ despliegue real, los datos son 100% eventos reales — no hay nada que "activar". El funnel de autorización y
+ la tasa de fallos de login sí arrancan vacíos incluso con el seed (son eventos nuevos, no incluidos en la
+ actividad sintética) hasta que ocurran de verdad.
Misma info disponible por API: GET /api/analytics/summary?days=30 (scope
users:read, requiere el permiso users.view — igual que el resto del panel admin).
-6. Roles, permisos y roles por app
+7. Audit log: quién cambió qué
+
+Pantalla de auditoría para el admin (/admin/audit-log): registra automáticamente los cambios que
+importan desde el punto de vista de seguridad/gobernanza — no hace falta activar nada, queda un renglón cada vez
+que ocurre alguno de estos eventos:
+
+
+| Acción | Cuándo se registra | Qué guarda en "Detalles" |
+user.created | Un admin da de alta un usuario desde el panel. | Nombre, email y rol asignado. |
+user.updated | Un admin edita nombre, email, rol, estado activo/inactivo o contraseña de un usuario. | Solo los campos que efectivamente cambiaron (antes → después). La contraseña nunca se guarda en texto plano, solo si cambió o no. |
+user.deleted | Un admin borra (soft delete) un usuario. | — |
+user.restored | Un admin restaura un usuario desde la papelera. | — |
+user.force_deleted | Un admin borra definitivamente un usuario de la papelera. | — |
+user.approved | Un admin aprueba un registro pendiente. | — |
+user.app_role_assigned | Un admin asigna un rol por-app a un usuario. | Client y rol asignado. |
+user.app_role_revoked | Un admin quita un rol por-app a un usuario. | Client y rol quitado. |
+client.created | Se registra un cliente OAuth nuevo (panel o API). | Grant type y si es confidencial. |
+client.revoked | Se revoca un cliente OAuth (panel o API) — cascada a sus tokens. | Cuántos tokens quedaron revocados. |
+user.2fa_enabled | Un usuario activa 2FA. | — |
+user.2fa_disabled | Un usuario desactiva 2FA. | — |
+user.2fa_recovery_regenerated | Un usuario regenera sus códigos de recuperación. | — |
+user.session_revoked | Un admin revoca un token OAuth ("sesión") de un usuario. | — |
+user.sessions_revoked_all | Un admin cierra todas las sesiones activas de un usuario de una vez. | Cuántos tokens quedaron revocados. |
+client.webhook_created | Se registra un webhook nuevo en un cliente OAuth. | URL y eventos suscritos. |
+client.webhook_deleted | Se borra un webhook. | URL. |
+client.secret_rotated | El dueño de un cliente confidencial rota su client_secret. | Hasta cuándo sigue valiendo el secret anterior. |
+user.password_reset | Un usuario restablece su contraseña vía "¿Olvidaste tu contraseña?". | Cuántas sesiones quedaron revocadas. |
+
+Cada evento guarda además quién lo hizo (o "(sistema)" si no hay actor), IP, y cuándo. Es filtrable por
+sujeto (Usuarios / Clientes OAuth) y por acción, y está paginado.
+
+ Qué NO cubre todavía: cambios de name/redirect_uris en un cliente
+ OAuth existente (solo se audita creación y revocación), y acciones de auto-servicio de un usuario sobre sus
+ propios clientes/tokens/apps autorizadas (ese flujo no pasa por rutas de admin).
+
+Requiere el permiso audit.view (nuevo — separado de users.* porque también cubre
+clientes OAuth, no solo usuarios). Se lo asigna automáticamente al rol admin. Misma info por API:
+GET /api/audit-log (scope users:read).
+
+
+
+8. Roles, permisos y roles por app
Roles globales
Gobiernan el panel de administración de esta app (spatie/laravel-permission):
| Rol | Permisos |
-| admin | users.view, users.create, users.update, users.delete |
+| admin | users.view, users.create, users.update, users.delete, audit.view |
| user | Ninguno extra — gestiona su propio perfil, sus clientes OAuth y sus tokens. |
@@ -413,13 +588,13 @@ inicia sesión en el panel, queda un registro en login_events. Con
user) específico de una app registrada, desde el panel de edición de usuario
("Roles por app"). Sirve para que, por ejemplo, un usuario sea admin dentro de la app Angular
pero solo user dentro de la app NestJS — sin tocar su rol global.
-Esa información viaja en la respuesta de la API (app_roles, ver sección 8): cada app ve
+
Esa información viaja en la respuesta de la API (app_roles, ver sección 11): cada app ve
únicamente el rol que ese usuario tiene en ella, resuelto automáticamente según el client_id
del token usado en la request.
-7. Idioma (español / inglés / catalán)
+9. Idioma (español / inglés / catalán)
El selector está arriba a la derecha en la barra de navegación (ES / EN / CA). Cambiarlo
hace un POST a /web-api/locale, guarda el idioma en la sesión y recarga la página — el servidor
decide el idioma con el que renderiza esa carga (mensajes de validación/autenticación vía
@@ -437,7 +612,7 @@ sin parpadeo.
-8. Cómo conectar tu app (Angular, NestJS, Flutter, etc.)
+10. Cómo conectar tu app (Angular, NestJS, Flutter, etc.)
Paso 1 — Registrar la app
Desde /clients, crear un cliente OAuth eligiendo el grant type según el tipo de app:
@@ -479,7 +654,7 @@ con ?code=...&state=....
-9. API REST completa: construí tu propio panel admin
+11. API REST completa: construí tu propio panel admin
Todo lo que hace la SPA Vue por sesión también está disponible por token OAuth2. Esto significa que un cliente
Angular, Flutter, o lo que sea, puede ser un admin completo — no solo consultar usuarios —
@@ -503,8 +678,12 @@ sin depender de la sesión de Laravel. Todos los endpoints requieren Autho
| GET | /api/users/{id}/app-roles | Roles del usuario por app (propio siempre permitido; otros requieren users.view). |
| POST | /api/users/{id}/app-roles | Asignar un rol en una app (requiere users.update, sin excepción para uno mismo). |
| DELETE | /api/users/{id}/app-roles/{clientId}/{role} | Quitar un rol en una app (requiere users.update). |
+| GET | /api/users/{id}/sessions | Tokens OAuth activos de ese usuario ("sesiones") (requiere users.view). |
+| DELETE | /api/users/{id}/sessions/{tokenId} | Revocar una sesión puntual (requiere users.update). |
+| DELETE | /api/users/{id}/sessions | Revocar todas las sesiones activas de ese usuario (requiere users.update). |
| GET | /api/apps | Listar todas las apps registradas (requiere users.view) — para armar el selector de roles-por-app. |
-| GET | /api/analytics/summary?days=30 | KPIs, logins por día/app, usuarios más activos y actividad reciente (requiere users.view). |
+| GET | /api/analytics/summary?days=30 | KPIs, logins por día/app, usuarios más activos, funnel de autorización OAuth, tokens activos por cliente, uso de scopes y actividad reciente (requiere users.view). |
+| GET | /api/audit-log?action=&subject_type=&per_page=25 | Historial de auditoría paginado: altas/bajas de usuarios, cambios de rol, clientes OAuth creados/revocados (requiere audit.view). |
Ejemplo de respuesta de usuario
@@ -532,11 +711,18 @@ client_credentials, que no tiene usuario asociado):
| POST | /api/clients | Registrar un cliente nuevo (devuelve plain_secret una sola vez). |
| PUT | /api/clients/{id} | Editar nombre/redirect URIs. |
| DELETE | /api/clients/{id} | Revocar el cliente y sus tokens. |
+| POST | /api/clients/{id}/rotate-secret | Rotar el client_secret (solo clientes confidenciales). Devuelve plain_secret una sola vez; el secret anterior sigue valiendo 48h. |
| GET | /api/personal-access-tokens | Listar tus Personal Access Tokens. |
| POST | /api/personal-access-tokens | Crear uno (el token se muestra una sola vez). |
| DELETE | /api/personal-access-tokens/{id} | Revocarlo. |
| GET | /api/authorized-apps | Apps de terceros a las que le diste acceso. |
| DELETE | /api/authorized-apps/{tokenId} | Revocarle el acceso a una. |
+| GET | /api/clients/{id}/webhooks | Listar los webhooks de uno de tus clientes (ver sección 12). |
+| POST | /api/clients/{id}/webhooks | Registrar un webhook nuevo (devuelve secret una sola vez). |
+| PUT | /api/clients/{id}/webhooks/{webhookId} | Editar URL, eventos o pausar/reanudar. |
+| DELETE | /api/clients/{id}/webhooks/{webhookId} | Borrar un webhook. |
+| GET | /api/clients/{id}/webhooks/{webhookId}/deliveries | Historial paginado de intentos de entrega. |
+| POST | /api/clients/{id}/webhooks/{webhookId}/deliveries/{deliveryId}/retry | Reintentar manualmente una entrega puntual. |
Scopes disponibles
@@ -558,7 +744,80 @@ client_credentials, que no tiene usuario asociado):
-10. Documentación interactiva (Swagger)
+12. Webhooks: enterate sin hacer polling
+
+Cada cliente OAuth puede registrar uno o más endpoints webhook desde /clients → botón
+"Webhooks" (o por API, sección 11). Cuando pasa algo relevante para ese cliente, este servidor le manda un
+POST con el evento — no hace falta consultar la API cada tanto para enterarse.
+
+Eventos disponibles
+
+| Evento | Cuándo se dispara | A quién le llega |
+token.revoked | Se revoca un token de ese cliente — por el usuario dueño (self-service), por un admin (Sesiones activas, sección 4), o en cascada al revocar el cliente entero. | Los webhooks de ese cliente. |
+user.deactivated | Un admin desactiva un usuario. | Los webhooks de todo cliente que tenga al menos un token vigente para ese usuario en ese momento. |
+
+Con token.revoked tu app se entera al instante de que ya no tiene acceso a la cuenta de ese
+usuario (por ejemplo, para cerrarle la sesión local), en vez de esperar a que el token expire solo.
+
+URLs permitidas
+
+ Protección anti-SSRF: la URL de un webhook tiene que resolver a una dirección pública.
+ Se rechazan localhost, IPs loopback (127.0.0.0/8), privadas
+ (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) y de link-local
+ (169.254.0.0/16 — incluye el endpoint de metadata de instancia en AWS/GCP/Azure,
+ 169.254.169.254). También esquemas que no sean http/https.
+ Sin esto, cualquiera que registre un client podría hacer que este servidor le pegue a servicios internos
+ (Redis, la propia API, metadata de la nube) y leer la respuesta en el panel — el clásico ataque SSRF.
+ La validación se repite justo antes de cada envío, no solo al crear el webhook: un dominio público hoy
+ podría resolver a una IP interna mañana (DNS rebinding).
+
+
+Verificar la firma
+
+Cada entrega incluye el header X-Webhook-Signature: sha256={firma}, un HMAC-SHA256 del body crudo
+(JSON) usando el secret que se te mostró al crear el webhook (una sola vez, igual que un
+client_secret — si lo perdiste, borrá el webhook y creá uno nuevo). Verificalo antes de confiar en
+el payload:
+
+// Node.js
+import crypto from 'crypto';
+
+function isValid(rawBody: string, signatureHeader: string, secret: string): boolean {
+ const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
+ return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));
+}
+El body tiene forma {"event": "token.revoked", "data": {...}}. El header
+X-Webhook-Event repite el nombre del evento, por si preferís ramificar por header sin parsear el body.
+
+Reintentos
+
+ Si tu endpoint no responde 2xx (o no responde en 5 segundos), se reintenta hasta 2 veces más
+ con backoff creciente (10s, 60s, 300s). Después de 3 intentos fallidos, se abandona esa entrega — no hay
+ cola de reintento infinita. Cada intento (éxito o fracaso) queda registrado; el panel muestra el estado del
+ último envío junto a cada webhook (código de respuesta o "pendiente").
+
+
+ Pausar en vez de borrar: si tu endpoint va a estar caído un rato (deploy, mantenimiento),
+ pausalo desde el panel en vez de dejar que fallen los reintentos — un webhook pausado no genera intentos de
+ entrega hasta que lo reactivés.
+
+
+Historial de entregas y reintento manual
+
+El botón "Ver historial" de cada webhook (junto a Pausar/Borrar) abre el listado paginado de
+todas las entregas — no solo la última. Cada fila muestra el código de respuesta (o "pendiente" si nunca
+respondió), el evento, cuándo se creó y cuántos intentos acumula. Las entregas que no llegaron a un
+2xx tienen un botón "Reintentar": vuelve a encolar el mismo registro (no crea
+uno nuevo — el contador de intentos sigue acumulando sobre esa entrega) para forzar un envío inmediato sin
+esperar al backoff automático. Útil después de arreglar algo en tu endpoint y no querer esperar los 5 minutos
+del backoff.
+
+Cada alta y baja de un webhook queda en el audit log (sección 7): client.webhook_created /
+client.webhook_deleted.
+
+
+
+13. Documentación interactiva (Swagger)
Disponible en http://localhost:8000/api/documentation. Incluye el botón Authorize
para probar los tres flujos (Authorization Code, Client Credentials, Password) directamente desde el navegador,
usando el cliente de ejemplo "Swagger UI" sembrado (sección 3).
@@ -566,16 +825,27 @@ usando el cliente de ejemplo "Swagger UI" sembrado (sección 3).
-11. Referencia rápida de rutas
+14. Referencia rápida de rutas
Interfaz web (sesión, uso interno de esta app)
| Ruta | Qué hace |
POST /web-api/register | Registro — crea la cuenta pendiente, no loguea. |
-POST /web-api/login / /logout | Autenticación por sesión. |
-GET /web-api/me | Usuario logueado (con roles/permisos). |
+POST /web-api/login / /logout | Autenticación por sesión. Si el usuario tiene 2FA, /login devuelve {"two_factor":true} en vez de loguear. |
+POST /web-api/login/2fa | Segundo paso del login con 2FA activo (código TOTP o de recuperación). |
+POST /web-api/forgot-password | Pedir el enlace de restablecimiento (respuesta genérica, exista o no la cuenta). |
+POST /web-api/reset-password | Confirmar el restablecimiento con el token del email — revoca todas las sesiones activas. |
+GET /web-api/me | Usuario logueado (con roles/permisos y two_factor_enabled). |
+POST /web-api/2fa/setup | Genera secreto pendiente + QR para activar 2FA. |
+POST /web-api/2fa/confirm | Confirma la activación con un código real — devuelve los códigos de recuperación una vez. |
+POST /web-api/2fa/disable | Desactiva 2FA (requiere contraseña actual). |
+POST /web-api/2fa/recovery-codes | Regenera los códigos de recuperación (requiere contraseña actual). |
GET/POST/PUT/DELETE /web-api/clients | Clientes OAuth propios. |
+POST /web-api/clients/{id}/rotate-secret | Rotar el client_secret de un cliente confidencial propio (secret anterior válido 48h más). |
GET/POST/DELETE /web-api/personal-access-tokens | Personal Access Tokens propios. |
GET/DELETE /web-api/authorized-apps | Apps de terceros autorizadas. |
+GET/POST/PUT/DELETE /web-api/clients/{id}/webhooks | Webhooks de un cliente propio. |
+GET /web-api/clients/{id}/webhooks/{webhookId}/deliveries | Historial paginado de entregas de un webhook propio. |
+POST /web-api/clients/{id}/webhooks/{webhookId}/deliveries/{deliveryId}/retry | Reintentar manualmente una entrega. |
GET/POST/PUT/DELETE /web-api/admin/users | Administración de usuarios (admin). |
GET /web-api/admin/users/trashed | Usuarios en la papelera (admin). |
POST /web-api/admin/users/{id}/restore | Restaurar un usuario borrado (admin). |
@@ -584,7 +854,12 @@ usando el cliente de ejemplo "Swagger UI" sembrado (sección 3).
POST /web-api/admin/users/{id}/approve | Aprobar un registro pendiente (admin). |
GET /web-api/admin/apps | Listado de todas las apps registradas (admin). |
GET/POST/DELETE /web-api/admin/users/{id}/app-roles | Roles por app de un usuario (admin). |
-GET /web-api/admin/analytics/summary | Sesiones, logins y actividad (admin). |
+GET /web-api/admin/users/{id}/sessions | Tokens OAuth activos de un usuario (admin). |
+DELETE /web-api/admin/users/{id}/sessions/{tokenId} | Revocar una sesión puntual (admin). |
+DELETE /web-api/admin/users/{id}/sessions | Revocar todas las sesiones activas de un usuario (admin). |
+GET /web-api/admin/analytics/summary | Sesiones, logins, funnel de autorización, tokens activos, uso de scopes y actividad (admin). |
+GET /web-api/admin/audit-log | Historial de auditoría paginado y filtrable (admin). |
+POST /web-api/oauth/authorize-events | Registro interno del funnel de autorización (llamado por la pantalla de consentimiento, no pensado para uso directo). |
POST /web-api/locale | Cambiar idioma (es/en/ca). |
@@ -594,6 +869,13 @@ usando el cliente de ejemplo "Swagger UI" sembrado (sección 3).
GET /oauth/authorize | Inicio del flujo Authorization Code (pantalla de consentimiento). |
POST /oauth/token | Emisión/renovación de tokens (todos los grant types). |
+
+ Rate limiting: todas las rutas /oauth/* admiten 20 requests por minuto por
+ combinación de IP + client_id — protege especialmente al Password Grant (usuario/contraseña
+ directos contra /oauth/token) de fuerza bruta, sin penalizar a otros clientes si uno solo
+ está siendo atacado. Va además del throttle genérico (60/min por IP) que Passport ya trae
+ por defecto en /oauth/token.
+
API REST (Bearer token, para apps externas — Angular, Flutter, lo que sea)
@@ -606,11 +888,17 @@ usando el cliente de ejemplo "Swagger UI" sembrado (sección 3).
GET /api/users/pending | Registros pendientes de aprobación. |
POST /api/users/{id}/approve | Aprobar un registro pendiente. |
GET/POST/DELETE /api/users/{id}/app-roles | Roles por app de un usuario. |
+GET/DELETE /api/users/{id}/sessions | Tokens OAuth activos de un usuario; revocar uno o todos. |
GET /api/apps | Listado de apps registradas. |
-GET /api/analytics/summary | Sesiones, logins y actividad. |
+GET /api/analytics/summary | Sesiones, logins, funnel de autorización, tokens activos, uso de scopes y actividad. |
+GET /api/audit-log | Historial de auditoría paginado y filtrable. |
GET/POST/PUT/DELETE /api/clients | Tus clientes OAuth. |
+POST /api/clients/{id}/rotate-secret | Rotar el client_secret de un cliente confidencial (secret anterior válido 48h más). |
GET/POST/DELETE /api/personal-access-tokens | Tus Personal Access Tokens. |
GET/DELETE /api/authorized-apps | Apps de terceros que autorizaste. |
+GET/POST/PUT/DELETE /api/clients/{id}/webhooks | Webhooks de uno de tus clientes. |
+GET /api/clients/{id}/webhooks/{webhookId}/deliveries | Historial paginado de entregas de un webhook. |
+POST /api/clients/{id}/webhooks/{webhookId}/deliveries/{deliveryId}/retry | Reintentar manualmente una entrega. |
diff --git a/docs/manual-usuario.pdf b/docs/manual-usuario.pdf
index f1ed30c6d92de4f828eab7596f8b9280e2167d81..acafcba5a59dc77c205de34843f20caba62fdab0 100644
GIT binary patch
delta 382342
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