Compare commits

...

2 Commits

Author SHA1 Message Date
2b1ef775b4 Merge branch 'develop' 2026-07-27 18:00:06 +02:00
c5ce8a7a36 new funcionality 2026-07-27 17:20:02 +02:00
101 changed files with 6889 additions and 176 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,11 @@
namespace App\Support;
use App\Models\LoginEvent;
use App\Models\OAuthAuthorizationEvent;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Laravel\Passport\Passport;
use Laravel\Passport\Token;
/**
@@ -22,20 +24,122 @@ class AnalyticsSummary
'kpis' => [
'total_users' => User::count(),
'active_sessions' => Token::query()->where('revoked', false)->where('expires_at', '>', now())->count(),
'logins_today' => LoginEvent::whereDate('created_at', today())->count(),
'logins_7d' => LoginEvent::where('created_at', '>=', now()->subDays(7))->count(),
'logins_today' => LoginEvent::successful()->whereDate('created_at', today())->count(),
'logins_7d' => LoginEvent::successful()->where('created_at', '>=', now()->subDays(7))->count(),
'login_failures_7d' => LoginEvent::where('type', 'session')->where('outcome', 'failure')->where('created_at', '>=', now()->subDays(7))->count(),
'login_failure_rate_7d' => self::loginFailureRate(now()->subDays(7)),
],
'logins_by_day' => self::loginsByDay($since, $days),
'logins_by_app' => self::loginsByApp($since),
'top_users' => self::topUsers($since),
'recent' => self::recent(),
'oauth_funnel' => self::oauthFunnel($since),
'oauth_funnel_by_client' => self::oauthFunnelByClient($since),
'active_tokens_by_client' => self::activeTokensByClient(),
'scope_usage' => self::scopeUsage($since),
];
}
private static function loginFailureRate(Carbon $since): float
{
$success = LoginEvent::where('type', 'session')->where('outcome', 'success')->where('created_at', '>=', $since)->count();
$failure = LoginEvent::where('type', 'session')->where('outcome', 'failure')->where('created_at', '>=', $since)->count();
$total = $success + $failure;
return $total === 0 ? 0.0 : round(($failure / $total) * 100, 1);
}
private static function oauthFunnel(Carbon $since): array
{
$counts = OAuthAuthorizationEvent::query()
->where('created_at', '>=', $since)
->selectRaw('outcome, count(*) as count')
->groupBy('outcome')
->pluck('count', 'outcome');
$attempted = (int) ($counts['attempted'] ?? 0);
$approved = (int) ($counts['approved'] ?? 0);
$denied = (int) ($counts['denied'] ?? 0);
return [
'attempted' => $attempted,
'approved' => $approved,
'denied' => $denied,
'approval_rate' => $attempted === 0 ? 0.0 : round(($approved / $attempted) * 100, 1),
];
}
private static function oauthFunnelByClient(Carbon $since): array
{
return OAuthAuthorizationEvent::query()
->join('oauth_clients', 'oauth_clients.id', '=', 'oauth_authorization_events.client_id')
->where('oauth_authorization_events.created_at', '>=', $since)
->groupBy('oauth_clients.id', 'oauth_clients.name')
->orderByDesc('attempted')
->limit(8)
->get([
'oauth_clients.name as client_name',
DB::raw("sum(case when outcome = 'attempted' then 1 else 0 end) as attempted"),
DB::raw("sum(case when outcome = 'approved' then 1 else 0 end) as approved"),
DB::raw("sum(case when outcome = 'denied' then 1 else 0 end) as denied"),
])
->map(fn ($row) => [
'client_name' => $row->client_name,
'attempted' => (int) $row->attempted,
'approved' => (int) $row->approved,
'denied' => (int) $row->denied,
])
->toArray();
}
private static function activeTokensByClient(): array
{
return Token::query()
->join('oauth_clients', 'oauth_clients.id', '=', 'oauth_access_tokens.client_id')
->where('oauth_access_tokens.revoked', false)
->where('oauth_access_tokens.expires_at', '>', now())
->groupBy('oauth_clients.id', 'oauth_clients.name')
->orderByDesc('count')
->limit(8)
->get(['oauth_clients.name as client_name', DB::raw('count(*) as count')])
->toArray();
}
private static function scopeUsage(Carbon $since): array
{
$descriptions = Passport::scopes()->keyBy('id');
$counts = [];
Token::query()
->where('oauth_access_tokens.created_at', '>=', $since)
->where('revoked', false)
->pluck('scopes')
->each(function ($raw) use (&$counts) {
$scopes = is_array($raw) ? $raw : (json_decode($raw ?? '[]', true) ?? []);
foreach ($scopes as $scope) {
$counts[$scope] = ($counts[$scope] ?? 0) + 1;
}
});
arsort($counts);
return collect($counts)
->take(8)
->map(fn ($count, $scope) => [
'scope' => $scope,
'description' => $descriptions->get($scope)?->description ?? $scope,
'count' => $count,
])
->values()
->toArray();
}
private static function loginsByDay(Carbon $since, int $days): array
{
$rows = LoginEvent::query()
->selectRaw("date(created_at) as day, type, count(*) as count")
->where('outcome', 'success')
->where('created_at', '>=', $since)
->groupBy('day', 'type')
->get()
@@ -61,6 +165,7 @@ class AnalyticsSummary
return LoginEvent::query()
->join('oauth_clients', 'oauth_clients.id', '=', 'login_events.client_id')
->where('login_events.type', 'oauth')
->where('login_events.outcome', 'success')
->where('login_events.created_at', '>=', $since)
->groupBy('oauth_clients.id', 'oauth_clients.name')
->orderByDesc('count')
@@ -73,6 +178,7 @@ class AnalyticsSummary
{
return LoginEvent::query()
->join('users', 'users.id', '=', 'login_events.user_id')
->where('login_events.outcome', 'success')
->where('login_events.created_at', '>=', $since)
->groupBy('users.id', 'users.name', 'users.email')
->orderByDesc('count')
@@ -84,6 +190,7 @@ class AnalyticsSummary
private static function recent(int $limit = 15): array
{
return LoginEvent::with(['user:id,name,email', 'client:id,name'])
->where('outcome', 'success')
->latest('created_at')
->limit($limit)
->get()

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Support;
use Illuminate\Support\Str;
use Laravel\Passport\Client;
/**
* Compartido por OAuth\ClientController (sesión) y Api\ClientController
* (token) — mismo patrón que el resto de las clases en App\Support.
*/
class ClientSecretRotation
{
public const GRACE_PERIOD_HOURS = 48;
/**
* Genera un secret nuevo y deja el anterior válido por GRACE_PERIOD_HOURS
* más (ver App\Services\Passport\ClientRepository, que es quien
* realmente lo verifica en cada request a /oauth/token). Devuelve el
* secret nuevo en texto plano — se muestra una sola vez, igual que al
* crear el cliente.
*/
public static function rotate(Client $client): string
{
// El atributo "secret" en el modelo Client ya viene hasheado (mutator
// de Passport) — lo copiamos tal cual, no hay que volver a hashearlo.
$client->forceFill([
'previous_secret' => $client->getAttributes()['secret'] ?? null,
'previous_secret_expires_at' => now()->addHours(self::GRACE_PERIOD_HOURS),
]);
$client->secret = Str::random(40);
$client->save();
return $client->plainSecret;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Support;
use App\Events\TokenRevoked;
use Laravel\Passport\Token;
/**
* Único punto de revocación de un token OAuth — usado por el self-service
* (AuthorizedAppController, ClientController), por el admin (UserSessions) y
* por la revocación en cascada al revocar un client. Centralizarlo acá
* garantiza que el evento TokenRevoked (que dispara los webhooks salientes)
* se dispare siempre, sin importar desde dónde se originó la revocación.
*/
class TokenRevocation
{
public static function revoke(Token $token): void
{
if ($token->revoked) {
return;
}
$token->revoke();
$token->refreshToken?->revoke();
TokenRevoked::dispatch($token);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Support;
use App\Models\User;
use PragmaRX\Google2FA\Google2FA;
class TwoFactorVerification
{
/**
* Valida un código TOTP o de recuperación contra el 2FA de un usuario.
* Un código de recuperación válido se consume (no sirve una segunda vez).
*/
public static function verify(User $user, string $code): bool
{
if ($user->two_factor_secret && (new Google2FA())->verifyKey($user->two_factor_secret, $code)) {
return true;
}
$recoveryCodes = $user->two_factor_recovery_codes ?? [];
$normalized = strtoupper(trim($code));
if (in_array($normalized, $recoveryCodes, true)) {
$user->forceFill([
'two_factor_recovery_codes' => array_values(array_diff($recoveryCodes, [$normalized])),
])->save();
return true;
}
return false;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Support;
use App\Models\User;
/**
* Tokens OAuth activos de un usuario ("sesiones") — compartido por
* Admin\UserSessionController (sesión) y Api\UserSessionController (token),
* igual que AnalyticsSummary/AuditLog::paginatedFor.
*/
class UserSessions
{
public static function active(User $user)
{
return $user->tokens()
->where('revoked', false)
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->with('client:id,name,revoked')
->latest('created_at')
->get()
->map(fn ($token) => [
'id' => $token->id,
'client_name' => $token->client?->name,
'client_revoked' => (bool) $token->client?->revoked,
'name' => $token->name,
'scopes' => $token->scopes,
'created_at' => $token->created_at,
'expires_at' => $token->expires_at,
]);
}
public static function revoke(User $user, string $tokenId): bool
{
$token = $user->tokens()->where('id', $tokenId)->first();
if (! $token) {
return false;
}
TokenRevocation::revoke($token);
return true;
}
public static function revokeAll(User $user): int
{
$tokens = $user->tokens()->where('revoked', false)->get();
foreach ($tokens as $token) {
TokenRevocation::revoke($token);
}
return $tokens->count();
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Support;
use App\Models\WebhookEndpoint;
/**
* Compartido por OAuth\WebhookController (sesión) y Api\WebhookController
* (token) — mismo patrón que AnalyticsSummary/AuditLog::paginatedFor/UserSessions.
*/
class WebhookEndpoints
{
public const EVENTS = ['token.revoked', 'user.deactivated'];
/**
* Protección anti-SSRF: rechaza URLs que resuelven a IPs privadas,
* loopback o reservadas (incluye 169.254.169.254, el endpoint de
* metadata de instancia en AWS/GCP/Azure). Se llama tanto al
* crear/editar un webhook como, de nuevo, justo antes de cada envío
* (SendWebhookJob) — un dominio público al registrarse puede resolver
* a una IP interna más tarde (DNS rebinding).
*/
public static function isSafeUrl(string $url): bool
{
$parts = parse_url($url);
if (! $parts || empty($parts['host']) || ! in_array($parts['scheme'] ?? '', ['http', 'https'], true)) {
return false;
}
$host = $parts['host'];
if (filter_var($host, FILTER_VALIDATE_IP)) {
return self::isPublicIp($host);
}
$records = array_merge(
@dns_get_record($host, DNS_A) ?: [],
@dns_get_record($host, DNS_AAAA) ?: [],
);
if (empty($records)) {
return false;
}
foreach ($records as $record) {
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
if (! $ip || ! self::isPublicIp($ip)) {
return false;
}
}
return true;
}
private static function isPublicIp(string $ip): bool
{
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
public static function present(WebhookEndpoint $endpoint): array
{
$last = $endpoint->deliveries()->latest('created_at')->first();
return [
'id' => $endpoint->id,
'client_id' => $endpoint->client_id,
'url' => $endpoint->url,
'events' => $endpoint->events,
'active' => $endpoint->active,
'created_at' => $endpoint->created_at,
'last_delivery' => $last ? [
'event' => $last->event,
'response_status' => $last->response_status,
'delivered_at' => $last->delivered_at,
'created_at' => $last->created_at,
] : null,
];
}
}

View File

@@ -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": {

54
composer.lock generated
View File

@@ -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",

View File

@@ -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'],
/*
|--------------------------------------------------------------------------

View File

@@ -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(),
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('login_events', function (Blueprint $table) {
// Default 'success' backfilla las filas existentes: hasta ahora
// login_events solo registraba logins exitosos.
$table->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');
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_authorization_events', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return new class extends Migration
{
/**
* Run the migrations.
*
* Backfilla el permiso "audit.view" para instalaciones ya sembradas
* (RolesAndPermissionsSeeder solo corre en instalaciones nuevas o al
* registrar el primer usuario) y se lo da al rol admin.
*/
public function up(): void
{
$permission = Permission::findOrCreate('audit.view');
$admin = Role::where('name', 'admin')->first();
if ($admin && ! $admin->hasPermissionTo($permission)) {
$admin->givePermissionTo($permission);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Permission::where('name', 'audit.view')->delete();
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Guardados encriptados (cast 'encrypted'/'encrypted:array' en el
// modelo) — por eso alcanza con "text" acá, no hace falta un tipo
// especial. two_factor_confirmed_at null = activación pendiente
// (secreto generado pero todavía no confirmado con un código) o
// 2FA desactivado; no null = 2FA activo de verdad.
$table->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']);
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('webhook_endpoints', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('webhook_deliveries', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_clients', function (Blueprint $table) {
// Copia del hash del secret anterior (no se re-hashea, ya viene
// hasheado de la columna "secret") — sigue siendo válido hasta
// previous_secret_expires_at, para no cortar integraciones que
// todavía no se actualizaron al secret nuevo.
$table->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']);
});
}
};

View File

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

View File

@@ -178,7 +178,7 @@
<p class="subtitle">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.).</p>
<div class="meta">
Laravel 12 · Passport 13 · Vue 3 + TypeScript · SCSS/BEM · spatie/laravel-permission · L5-Swagger<br>
Generado el 25 de julio de 2026
Generado el 27 de julio de 2026 (actualizado: webhooks salientes + protección anti-SSRF)
</div>
</div>
@@ -189,13 +189,16 @@
<li><span><span class="n">2.</span>Puesta en marcha</span></li>
<li><span><span class="n">3.</span>Usuarios por defecto (seed)</span></li>
<li><span><span class="n">4.</span>Uso de la interfaz web (panel de administración)</span></li>
<li><span><span class="n">5.</span>Analíticas: sesiones, logins y actividad</span></li>
<li><span><span class="n">6.</span>Roles, permisos y roles por app</span></li>
<li><span><span class="n">7.</span>Idioma (español / inglés / catalán)</span></li>
<li><span><span class="n">8.</span>Cómo conectar tu app (Angular, NestJS, Flutter, etc.)</span></li>
<li><span><span class="n">9.</span>API REST completa: construí tu propio panel admin</span></li>
<li><span><span class="n">10.</span>Documentación interactiva (Swagger)</span></li>
<li><span><span class="n">11.</span>Referencia rápida de rutas</span></li>
<li><span><span class="n">5.</span>Verificación en dos pasos (2FA)</span></li>
<li><span><span class="n">6.</span>Analíticas: sesiones, logins y actividad</span></li>
<li><span><span class="n">7.</span>Audit log: quién cambió qué</span></li>
<li><span><span class="n">8.</span>Roles, permisos y roles por app</span></li>
<li><span><span class="n">9.</span>Idioma (español / inglés / catalán)</span></li>
<li><span><span class="n">10.</span>Cómo conectar tu app (Angular, NestJS, Flutter, etc.)</span></li>
<li><span><span class="n">11.</span>API REST completa: construí tu propio panel admin</span></li>
<li><span><span class="n">12.</span>Webhooks: enterate sin hacer polling</span></li>
<li><span><span class="n">13.</span>Documentación interactiva (Swagger)</span></li>
<li><span><span class="n">14.</span>Referencia rápida de rutas</span></li>
</ul>
</section>
@@ -259,6 +262,14 @@ cola de trabajos y los logs. La app queda disponible en <code>http://localhost:8
vuelve a crear los usuarios/clientes de ejemplo descriptos en la sección 3. Los <code>client_secret</code>
de los clientes OAuth de ejemplo se imprimen en la consola en ese momento — solo se muestran una vez.
</div>
<div class="box">
<strong>Email:</strong> por defecto <code>MAIL_MAILER=log</code> — los emails (hoy, solo el de
recuperación de contraseña, ver sección 4) se escriben en <code>storage/logs/laravel.log</code> en vez de
enviarse de verdad, para no necesitar SMTP en desarrollo. Para producción, configurá un mailer real en
<code>.env</code> (<code>MAIL_MAILER</code>, <code>MAIL_HOST</code>, etc. — cualquiera soportado por
Laravel). El envío va encolado (<code>ShouldQueue</code>), así que también necesitás un worker corriendo
(<code>php artisan queue:work</code>, o <code>composer run dev</code> en desarrollo).
</div>
</section>
<section>
@@ -325,6 +336,33 @@ automáticamente, pero <strong>no loguea al usuario</strong>: 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). <strong>Excepción:</strong> si la base de usuarios está vacía, esa primera cuenta se
crea como admin y queda logueada de una (ver sección 3).</p>
<div class="box warn">
<strong>Rate limiting:</strong> <code>POST /web-api/login</code> 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 <code>POST /web-api/register</code> 5 por minuto por IP. Al superar el límite, la respuesta es
<code>429 Too Many Requests</code> con un header <code>Retry-After</code>.
</div>
<h3>Recuperación de contraseña</h3>
<p>
Desde <code>/login</code> → "¿Olvidaste tu contraseña?" (<code>/forgot-password</code>), cualquiera puede pedir
un enlace de restablecimiento con solo su email. La respuesta es <strong>siempre la misma</strong>, 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 <code>MAIL_MAILER</code> en la sección 2) con
un enlace a <code>/reset-password?token=...&amp;email=...</code>, válido durante <strong>60 minutos</strong> y
de un solo uso.
</p>
<div class="box">
<strong>Al restablecer la contraseña se cierran todas las sesiones activas</strong> (los tokens OAuth
vigentes de esa cuenta, igual que "Cerrar todas" en Sesiones activas — sección 4) y queda un
<code>user.password_reset</code> 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.
</div>
<div class="box warn">
<strong>Rate limiting:</strong> tanto pedir el enlace como confirmar el restablecimiento admiten 5
intentos por minuto por combinación de email + IP.
</div>
<h3>Dashboard</h3>
<p><code>/</code> — accesos rápidos a Clientes OAuth, Personal Access Tokens, Apps autorizadas y (si sos admin)
@@ -341,6 +379,17 @@ crear uno se elige:</p>
</ul>
<div class="box warn">El <code>client_secret</code> se muestra <strong>una sola vez</strong>, justo después de
crear el cliente. Copiarlo en ese momento.</div>
<p>Cada cliente tiene, además, un botón <strong>"Webhooks"</strong> para registrar endpoints que reciben avisos
cuando pasa algo relevante para esa app (ver sección 12) — sin tener que hacer polling.</p>
<div class="box">
<strong>Rotar secret.</strong> Los clientes confidenciales (con <code>client_secret</code>) tienen un botón
<strong>"Rotar secret"</strong>. Genera un secret nuevo y lo muestra una sola vez, igual que al crear el
cliente — pero el secret <strong>anterior sigue siendo válido durante 48 horas</strong> (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 <code>POST /oauth/token</code>. 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.
</div>
<h3>Personal Access Tokens</h3>
<p><code>/tokens</code> — tokens tipo "API key" para el propio usuario, sin pasar por el flujo de consentimiento.
@@ -370,41 +419,167 @@ sesión.</p>
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.
</div>
<div class="box">
<strong>Sesiones activas.</strong> 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.
</div>
<h3>Seguridad: verificación en dos pasos</h3>
<p><code>/security</code> — cualquier usuario (no solo admins) puede activar 2FA acá. Ver sección 5.</p>
<h3>Analíticas (solo admins)</h3>
<p><code>/admin/analytics</code> — ver sección 5.</p>
<p><code>/admin/analytics</code> — ver sección 6.</p>
<h3>Audit log (solo admins)</h3>
<p><code>/admin/audit-log</code> — ver sección 7.</p>
</section>
<section>
<h2>5. Analíticas: sesiones, logins y actividad</h2>
<h2>5. Verificación en dos pasos (2FA)</h2>
<p>
Cualquier usuario (no hace falta ser admin) puede activar 2FA con TOTP desde <code>/security</code> —
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.
</p>
<h3>Activación</h3>
<ol>
<li>El servidor genera un secreto (todavía no activo) y un código QR para escanear.</li>
<li>El usuario escanea el QR con su app autenticadora (o copia el secreto a mano si no puede escanear).</li>
<li>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.</li>
<li>El servidor muestra <strong>8 códigos de recuperación</strong>, 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.</li>
</ol>
<h3>Login con 2FA activo</h3>
<p>
El login pasa a ser de dos pasos: primero email + contraseña (esto <strong>no</strong> 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.
</p>
<div class="box warn">
<strong>Rate limiting:</strong> el segundo paso del login (<code>/web-api/login/2fa</code>), confirmar la
activación (<code>/2fa/confirm</code>), desactivar (<code>/2fa/disable</code>) y regenerar códigos de
recuperación (<code>/2fa/recovery-codes</code>) admiten 6 intentos por minuto — el espacio de un código
TOTP de 6 dígitos es chico como para no limitarlo.
</div>
<div class="box">
<strong>Desactivar 2FA o regenerar códigos de recuperación:</strong> ambas acciones, desde
<code>/security</code>, 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.
</div>
<div class="box">
<strong>Password Grant y 2FA:</strong> si el usuario tiene 2FA activo, <code>POST /oauth/token</code> con
<code>grant_type=password</code> exige además un campo <code>otp</code> 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
<code>400 invalid_grant</code> 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.
<pre><code>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"</code></pre>
</div>
<p>Cada activación, desactivación y regeneración de códigos queda registrada en el audit log (sección 7).</p>
</section>
<section>
<h2>6. Analíticas: sesiones, logins y actividad</h2>
<p>
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 <code>login_events</code>. Con eso se arman:
</p>
<ul>
<li><strong>KPIs</strong>: usuarios totales, sesiones activas (tokens OAuth vigentes, no revocados ni
expirados), logins de hoy y de los últimos 7 días.</li>
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).</li>
<li><strong>Logins por día</strong> (ú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.</li>
<li><strong>Logins por app</strong>: qué apps se usan más.</li>
<li><strong>Usuarios más activos</strong>: ranking por cantidad de logins en el período.</li>
<li><strong>Funnel de autorización OAuth</strong>: por cada cliente, cuántas veces se mostró la pantalla de
consentimiento (<code>/oauth/authorize</code>), cuántas se aprobaron y cuántas se denegaron, con la tasa de
aprobación resultante.</li>
<li><strong>Tokens activos por cliente</strong>: snapshot en vivo de tokens OAuth vigentes (no revocados,
no expirados), agrupados por app.</li>
<li><strong>Uso de scopes</strong>: qué scopes (<code>users:read</code>, <code>account:write</code>, etc.)
aparecen más entre los tokens emitidos en el período.</li>
<li><strong>Actividad reciente</strong>: tabla con los últimos eventos (usuario, app, tipo, IP, cuándo).</li>
</ul>
<div class="box">
<strong>Exportar PDF:</strong> 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.
</div>
<div class="box">
<strong>Tasa de fallos de login</strong> 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.
</div>
<div class="box">
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.
</div>
<p>Misma info disponible por API: <code>GET /api/analytics/summary?days=30</code> (scope
<code>users:read</code>, requiere el permiso <code>users.view</code> — igual que el resto del panel admin).</p>
</section>
<section>
<h2>6. Roles, permisos y roles por app</h2>
<h2>7. Audit log: quién cambió qué</h2>
<p>
Pantalla de auditoría para el admin (<code>/admin/audit-log</code>): 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:
</p>
<table>
<tr><th>Acción</th><th>Cuándo se registra</th><th>Qué guarda en "Detalles"</th></tr>
<tr><td><code>user.created</code></td><td>Un admin da de alta un usuario desde el panel.</td><td>Nombre, email y rol asignado.</td></tr>
<tr><td><code>user.updated</code></td><td>Un admin edita nombre, email, rol, estado activo/inactivo o contraseña de un usuario.</td><td>Solo los campos que efectivamente cambiaron (antes → después). La contraseña nunca se guarda en texto plano, solo si cambió o no.</td></tr>
<tr><td><code>user.deleted</code></td><td>Un admin borra (soft delete) un usuario.</td><td>—</td></tr>
<tr><td><code>user.restored</code></td><td>Un admin restaura un usuario desde la papelera.</td><td>—</td></tr>
<tr><td><code>user.force_deleted</code></td><td>Un admin borra definitivamente un usuario de la papelera.</td><td>—</td></tr>
<tr><td><code>user.approved</code></td><td>Un admin aprueba un registro pendiente.</td><td>—</td></tr>
<tr><td><code>user.app_role_assigned</code></td><td>Un admin asigna un rol por-app a un usuario.</td><td>Client y rol asignado.</td></tr>
<tr><td><code>user.app_role_revoked</code></td><td>Un admin quita un rol por-app a un usuario.</td><td>Client y rol quitado.</td></tr>
<tr><td><code>client.created</code></td><td>Se registra un cliente OAuth nuevo (panel o API).</td><td>Grant type y si es confidencial.</td></tr>
<tr><td><code>client.revoked</code></td><td>Se revoca un cliente OAuth (panel o API) — cascada a sus tokens.</td><td>Cuántos tokens quedaron revocados.</td></tr>
<tr><td><code>user.2fa_enabled</code></td><td>Un usuario activa 2FA.</td><td>—</td></tr>
<tr><td><code>user.2fa_disabled</code></td><td>Un usuario desactiva 2FA.</td><td>—</td></tr>
<tr><td><code>user.2fa_recovery_regenerated</code></td><td>Un usuario regenera sus códigos de recuperación.</td><td>—</td></tr>
<tr><td><code>user.session_revoked</code></td><td>Un admin revoca un token OAuth ("sesión") de un usuario.</td><td>—</td></tr>
<tr><td><code>user.sessions_revoked_all</code></td><td>Un admin cierra todas las sesiones activas de un usuario de una vez.</td><td>Cuántos tokens quedaron revocados.</td></tr>
<tr><td><code>client.webhook_created</code></td><td>Se registra un webhook nuevo en un cliente OAuth.</td><td>URL y eventos suscritos.</td></tr>
<tr><td><code>client.webhook_deleted</code></td><td>Se borra un webhook.</td><td>URL.</td></tr>
<tr><td><code>client.secret_rotated</code></td><td>El dueño de un cliente confidencial rota su <code>client_secret</code>.</td><td>Hasta cuándo sigue valiendo el secret anterior.</td></tr>
<tr><td><code>user.password_reset</code></td><td>Un usuario restablece su contraseña vía "¿Olvidaste tu contraseña?".</td><td>Cuántas sesiones quedaron revocadas.</td></tr>
</table>
<p>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.</p>
<div class="box warn">
<strong>Qué NO cubre todavía:</strong> cambios de <code>name</code>/<code>redirect_uris</code> 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).
</div>
<p>Requiere el permiso <code>audit.view</code> (nuevo — separado de <code>users.*</code> porque también cubre
clientes OAuth, no solo usuarios). Se lo asigna automáticamente al rol <code>admin</code>. Misma info por API:
<code>GET /api/audit-log</code> (scope <code>users:read</code>).</p>
</section>
<section>
<h2>8. Roles, permisos y roles por app</h2>
<h3>Roles globales</h3>
<p>Gobiernan el panel de administración de esta app (<code>spatie/laravel-permission</code>):</p>
<table>
<tr><th>Rol</th><th>Permisos</th></tr>
<tr><td><span class="pill">admin</span></td><td><code>users.view</code>, <code>users.create</code>, <code>users.update</code>, <code>users.delete</code></td></tr>
<tr><td><span class="pill">admin</span></td><td><code>users.view</code>, <code>users.create</code>, <code>users.update</code>, <code>users.delete</code>, <code>audit.view</code></td></tr>
<tr><td><span class="pill">user</span></td><td>Ninguno extra — gestiona su propio perfil, sus clientes OAuth y sus tokens.</td></tr>
</table>
@@ -413,13 +588,13 @@ inicia sesión en el panel, queda un registro en <code>login_events</code>. Con
<code>user</code>) <strong>específico de una app registrada</strong>, desde el panel de edición de usuario
("Roles por app"). Sirve para que, por ejemplo, un usuario sea <code>admin</code> dentro de la app Angular
pero solo <code>user</code> dentro de la app NestJS — sin tocar su rol global.</p>
<p>Esa información viaja en la respuesta de la API (<code>app_roles</code>, ver sección 8): cada app ve
<p>Esa información viaja en la respuesta de la API (<code>app_roles</code>, ver sección 11): cada app ve
únicamente el rol que ese usuario tiene <em>en ella</em>, resuelto automáticamente según el <code>client_id</code>
del token usado en la request.</p>
</section>
<section>
<h2>7. Idioma (español / inglés / catalán)</h2>
<h2>9. Idioma (español / inglés / catalán)</h2>
<p>El selector está arriba a la derecha en la barra de navegación (<strong>ES / EN / CA</strong>). Cambiarlo
hace un POST a <code>/web-api/locale</code>, 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.</p>
</section>
<section>
<h2>8. Cómo conectar tu app (Angular, NestJS, Flutter, etc.)</h2>
<h2>10. Cómo conectar tu app (Angular, NestJS, Flutter, etc.)</h2>
<h3>Paso 1 — Registrar la app</h3>
<p>Desde <code>/clients</code>, crear un cliente OAuth eligiendo el grant type según el tipo de app:</p>
<table>
@@ -479,7 +654,7 @@ con <code>?code=...&state=...</code>.</p>
</section>
<section>
<h2>9. API REST completa: construí tu propio panel admin</h2>
<h2>11. API REST completa: construí tu propio panel admin</h2>
<p>
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 <strong>admin completo</strong> — no solo consultar usuarios —
@@ -503,8 +678,12 @@ sin depender de la sesión de Laravel. Todos los endpoints requieren <code>Autho
<tr><td>GET</td><td><code>/api/users/{id}/app-roles</code></td><td>Roles del usuario por app (propio siempre permitido; otros requieren <code>users.view</code>).</td></tr>
<tr><td>POST</td><td><code>/api/users/{id}/app-roles</code></td><td>Asignar un rol en una app (requiere <code>users.update</code>, sin excepción para uno mismo).</td></tr>
<tr><td>DELETE</td><td><code>/api/users/{id}/app-roles/{clientId}/{role}</code></td><td>Quitar un rol en una app (requiere <code>users.update</code>).</td></tr>
<tr><td>GET</td><td><code>/api/users/{id}/sessions</code></td><td>Tokens OAuth activos de ese usuario ("sesiones") (requiere <code>users.view</code>).</td></tr>
<tr><td>DELETE</td><td><code>/api/users/{id}/sessions/{tokenId}</code></td><td>Revocar una sesión puntual (requiere <code>users.update</code>).</td></tr>
<tr><td>DELETE</td><td><code>/api/users/{id}/sessions</code></td><td>Revocar todas las sesiones activas de ese usuario (requiere <code>users.update</code>).</td></tr>
<tr><td>GET</td><td><code>/api/apps</code></td><td>Listar todas las apps registradas (requiere <code>users.view</code>) — para armar el selector de roles-por-app.</td></tr>
<tr><td>GET</td><td><code>/api/analytics/summary?days=30</code></td><td>KPIs, logins por día/app, usuarios más activos y actividad reciente (requiere <code>users.view</code>).</td></tr>
<tr><td>GET</td><td><code>/api/analytics/summary?days=30</code></td><td>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 <code>users.view</code>).</td></tr>
<tr><td>GET</td><td><code>/api/audit-log?action=&subject_type=&per_page=25</code></td><td>Historial de auditoría paginado: altas/bajas de usuarios, cambios de rol, clientes OAuth creados/revocados (requiere <code>audit.view</code>).</td></tr>
</table>
<h3>Ejemplo de respuesta de usuario</h3>
@@ -532,11 +711,18 @@ client_credentials, que no tiene usuario asociado):</p>
<tr><td>POST</td><td><code>/api/clients</code></td><td>Registrar un cliente nuevo (devuelve <code>plain_secret</code> una sola vez).</td></tr>
<tr><td>PUT</td><td><code>/api/clients/{id}</code></td><td>Editar nombre/redirect URIs.</td></tr>
<tr><td>DELETE</td><td><code>/api/clients/{id}</code></td><td>Revocar el cliente y sus tokens.</td></tr>
<tr><td>POST</td><td><code>/api/clients/{id}/rotate-secret</code></td><td>Rotar el <code>client_secret</code> (solo clientes confidenciales). Devuelve <code>plain_secret</code> una sola vez; el secret anterior sigue valiendo 48h.</td></tr>
<tr><td>GET</td><td><code>/api/personal-access-tokens</code></td><td>Listar tus Personal Access Tokens.</td></tr>
<tr><td>POST</td><td><code>/api/personal-access-tokens</code></td><td>Crear uno (el token se muestra una sola vez).</td></tr>
<tr><td>DELETE</td><td><code>/api/personal-access-tokens/{id}</code></td><td>Revocarlo.</td></tr>
<tr><td>GET</td><td><code>/api/authorized-apps</code></td><td>Apps de terceros a las que le diste acceso.</td></tr>
<tr><td>DELETE</td><td><code>/api/authorized-apps/{tokenId}</code></td><td>Revocarle el acceso a una.</td></tr>
<tr><td>GET</td><td><code>/api/clients/{id}/webhooks</code></td><td>Listar los webhooks de uno de tus clientes (ver sección 12).</td></tr>
<tr><td>POST</td><td><code>/api/clients/{id}/webhooks</code></td><td>Registrar un webhook nuevo (devuelve <code>secret</code> una sola vez).</td></tr>
<tr><td>PUT</td><td><code>/api/clients/{id}/webhooks/{webhookId}</code></td><td>Editar URL, eventos o pausar/reanudar.</td></tr>
<tr><td>DELETE</td><td><code>/api/clients/{id}/webhooks/{webhookId}</code></td><td>Borrar un webhook.</td></tr>
<tr><td>GET</td><td><code>/api/clients/{id}/webhooks/{webhookId}/deliveries</code></td><td>Historial paginado de intentos de entrega.</td></tr>
<tr><td>POST</td><td><code>/api/clients/{id}/webhooks/{webhookId}/deliveries/{deliveryId}/retry</code></td><td>Reintentar manualmente una entrega puntual.</td></tr>
</table>
<h3>Scopes disponibles</h3>
@@ -558,7 +744,80 @@ client_credentials, que no tiene usuario asociado):</p>
</section>
<section>
<h2>10. Documentación interactiva (Swagger)</h2>
<h2>12. Webhooks: enterate sin hacer polling</h2>
<p>
Cada cliente OAuth puede registrar uno o más endpoints webhook desde <code>/clients</code> → botón
"Webhooks" (o por API, sección 11). Cuando pasa algo relevante para ese cliente, este servidor le manda un
<code>POST</code> con el evento — no hace falta consultar la API cada tanto para enterarse.
</p>
<h3>Eventos disponibles</h3>
<table>
<tr><th>Evento</th><th>Cuándo se dispara</th><th>A quién le llega</th></tr>
<tr><td><code>token.revoked</code></td><td>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.</td><td>Los webhooks de <strong>ese</strong> cliente.</td></tr>
<tr><td><code>user.deactivated</code></td><td>Un admin desactiva un usuario.</td><td>Los webhooks de todo cliente que tenga al menos un token vigente para ese usuario en ese momento.</td></tr>
</table>
<p>Con <code>token.revoked</code> 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.</p>
<h3>URLs permitidas</h3>
<div class="box warn">
<strong>Protección anti-SSRF:</strong> la URL de un webhook tiene que resolver a una dirección pública.
Se rechazan <code>localhost</code>, IPs loopback (<code>127.0.0.0/8</code>), privadas
(<code>10.0.0.0/8</code>, <code>172.16.0.0/12</code>, <code>192.168.0.0/16</code>) y de link-local
(<code>169.254.0.0/16</code> — incluye el endpoint de metadata de instancia en AWS/GCP/Azure,
<code>169.254.169.254</code>). También esquemas que no sean <code>http</code>/<code>https</code>.
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).
</div>
<h3>Verificar la firma</h3>
<p>
Cada entrega incluye el header <code>X-Webhook-Signature: sha256={firma}</code>, un HMAC-SHA256 del body crudo
(JSON) usando el <code>secret</code> que se te mostró al crear el webhook (una sola vez, igual que un
<code>client_secret</code> — si lo perdiste, borrá el webhook y creá uno nuevo). Verificalo antes de confiar en
el payload:
</p>
<pre><code>// 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));
}</code></pre>
<p>El body tiene forma <code>{"event": "token.revoked", "data": {...}}</code>. El header
<code>X-Webhook-Event</code> repite el nombre del evento, por si preferís ramificar por header sin parsear el body.</p>
<h3>Reintentos</h3>
<div class="box">
Si tu endpoint no responde <code>2xx</code> (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").
</div>
<div class="box warn">
<strong>Pausar en vez de borrar:</strong> 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.
</div>
<h3>Historial de entregas y reintento manual</h3>
<p>
El botón <strong>"Ver historial"</strong> 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
<code>2xx</code> tienen un botón <strong>"Reintentar"</strong>: 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.
</p>
<p>Cada alta y baja de un webhook queda en el audit log (sección 7): <code>client.webhook_created</code> /
<code>client.webhook_deleted</code>.</p>
</section>
<section>
<h2>13. Documentación interactiva (Swagger)</h2>
<p>Disponible en <code>http://localhost:8000/api/documentation</code>. Incluye el botón <strong>Authorize</strong>
para probar los tres flujos (Authorization Code, Client Credentials, Password) directamente desde el navegador,
usando el cliente de ejemplo <em>"Swagger UI"</em> sembrado (sección 3).</p>
@@ -566,16 +825,27 @@ usando el cliente de ejemplo <em>"Swagger UI"</em> sembrado (sección 3).</p>
</section>
<section>
<h2>11. Referencia rápida de rutas</h2>
<h2>14. Referencia rápida de rutas</h2>
<h3>Interfaz web (sesión, uso interno de esta app)</h3>
<table>
<tr><th>Ruta</th><th>Qué hace</th></tr>
<tr><td><code>POST /web-api/register</code></td><td>Registro — crea la cuenta pendiente, no loguea.</td></tr>
<tr><td><code>POST /web-api/login</code> / <code>/logout</code></td><td>Autenticación por sesión.</td></tr>
<tr><td><code>GET /web-api/me</code></td><td>Usuario logueado (con roles/permisos).</td></tr>
<tr><td><code>POST /web-api/login</code> / <code>/logout</code></td><td>Autenticación por sesión. Si el usuario tiene 2FA, <code>/login</code> devuelve <code>{"two_factor":true}</code> en vez de loguear.</td></tr>
<tr><td><code>POST /web-api/login/2fa</code></td><td>Segundo paso del login con 2FA activo (código TOTP o de recuperación).</td></tr>
<tr><td><code>POST /web-api/forgot-password</code></td><td>Pedir el enlace de restablecimiento (respuesta genérica, exista o no la cuenta).</td></tr>
<tr><td><code>POST /web-api/reset-password</code></td><td>Confirmar el restablecimiento con el token del email — revoca todas las sesiones activas.</td></tr>
<tr><td><code>GET /web-api/me</code></td><td>Usuario logueado (con roles/permisos y <code>two_factor_enabled</code>).</td></tr>
<tr><td><code>POST /web-api/2fa/setup</code></td><td>Genera secreto pendiente + QR para activar 2FA.</td></tr>
<tr><td><code>POST /web-api/2fa/confirm</code></td><td>Confirma la activación con un código real — devuelve los códigos de recuperación una vez.</td></tr>
<tr><td><code>POST /web-api/2fa/disable</code></td><td>Desactiva 2FA (requiere contraseña actual).</td></tr>
<tr><td><code>POST /web-api/2fa/recovery-codes</code></td><td>Regenera los códigos de recuperación (requiere contraseña actual).</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /web-api/clients</code></td><td>Clientes OAuth propios.</td></tr>
<tr><td><code>POST /web-api/clients/{id}/rotate-secret</code></td><td>Rotar el <code>client_secret</code> de un cliente confidencial propio (secret anterior válido 48h más).</td></tr>
<tr><td><code>GET/POST/DELETE /web-api/personal-access-tokens</code></td><td>Personal Access Tokens propios.</td></tr>
<tr><td><code>GET/DELETE /web-api/authorized-apps</code></td><td>Apps de terceros autorizadas.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /web-api/clients/{id}/webhooks</code></td><td>Webhooks de un cliente propio.</td></tr>
<tr><td><code>GET /web-api/clients/{id}/webhooks/{webhookId}/deliveries</code></td><td>Historial paginado de entregas de un webhook propio.</td></tr>
<tr><td><code>POST /web-api/clients/{id}/webhooks/{webhookId}/deliveries/{deliveryId}/retry</code></td><td>Reintentar manualmente una entrega.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /web-api/admin/users</code></td><td>Administración de usuarios (admin).</td></tr>
<tr><td><code>GET /web-api/admin/users/trashed</code></td><td>Usuarios en la papelera (admin).</td></tr>
<tr><td><code>POST /web-api/admin/users/{id}/restore</code></td><td>Restaurar un usuario borrado (admin).</td></tr>
@@ -584,7 +854,12 @@ usando el cliente de ejemplo <em>"Swagger UI"</em> sembrado (sección 3).</p>
<tr><td><code>POST /web-api/admin/users/{id}/approve</code></td><td>Aprobar un registro pendiente (admin).</td></tr>
<tr><td><code>GET /web-api/admin/apps</code></td><td>Listado de todas las apps registradas (admin).</td></tr>
<tr><td><code>GET/POST/DELETE /web-api/admin/users/{id}/app-roles</code></td><td>Roles por app de un usuario (admin).</td></tr>
<tr><td><code>GET /web-api/admin/analytics/summary</code></td><td>Sesiones, logins y actividad (admin).</td></tr>
<tr><td><code>GET /web-api/admin/users/{id}/sessions</code></td><td>Tokens OAuth activos de un usuario (admin).</td></tr>
<tr><td><code>DELETE /web-api/admin/users/{id}/sessions/{tokenId}</code></td><td>Revocar una sesión puntual (admin).</td></tr>
<tr><td><code>DELETE /web-api/admin/users/{id}/sessions</code></td><td>Revocar todas las sesiones activas de un usuario (admin).</td></tr>
<tr><td><code>GET /web-api/admin/analytics/summary</code></td><td>Sesiones, logins, funnel de autorización, tokens activos, uso de scopes y actividad (admin).</td></tr>
<tr><td><code>GET /web-api/admin/audit-log</code></td><td>Historial de auditoría paginado y filtrable (admin).</td></tr>
<tr><td><code>POST /web-api/oauth/authorize-events</code></td><td>Registro interno del funnel de autorización (llamado por la pantalla de consentimiento, no pensado para uso directo).</td></tr>
<tr><td><code>POST /web-api/locale</code></td><td>Cambiar idioma (es/en/ca).</td></tr>
</table>
@@ -594,6 +869,13 @@ usando el cliente de ejemplo <em>"Swagger UI"</em> sembrado (sección 3).</p>
<tr><td><code>GET /oauth/authorize</code></td><td>Inicio del flujo Authorization Code (pantalla de consentimiento).</td></tr>
<tr><td><code>POST /oauth/token</code></td><td>Emisión/renovación de tokens (todos los grant types).</td></tr>
</table>
<div class="box warn">
<strong>Rate limiting:</strong> todas las rutas <code>/oauth/*</code> admiten 20 requests por minuto por
combinación de IP + <code>client_id</code> — protege especialmente al Password Grant (usuario/contraseña
directos contra <code>/oauth/token</code>) de fuerza bruta, sin penalizar a otros clientes si uno solo
está siendo atacado. Va además del <code>throttle</code> genérico (60/min por IP) que Passport ya trae
por defecto en <code>/oauth/token</code>.
</div>
<h3>API REST (Bearer token, para apps externas — Angular, Flutter, lo que sea)</h3>
<table>
@@ -606,11 +888,17 @@ usando el cliente de ejemplo <em>"Swagger UI"</em> sembrado (sección 3).</p>
<tr><td><code>GET /api/users/pending</code></td><td>Registros pendientes de aprobación.</td></tr>
<tr><td><code>POST /api/users/{id}/approve</code></td><td>Aprobar un registro pendiente.</td></tr>
<tr><td><code>GET/POST/DELETE /api/users/{id}/app-roles</code></td><td>Roles por app de un usuario.</td></tr>
<tr><td><code>GET/DELETE /api/users/{id}/sessions</code></td><td>Tokens OAuth activos de un usuario; revocar uno o todos.</td></tr>
<tr><td><code>GET /api/apps</code></td><td>Listado de apps registradas.</td></tr>
<tr><td><code>GET /api/analytics/summary</code></td><td>Sesiones, logins y actividad.</td></tr>
<tr><td><code>GET /api/analytics/summary</code></td><td>Sesiones, logins, funnel de autorización, tokens activos, uso de scopes y actividad.</td></tr>
<tr><td><code>GET /api/audit-log</code></td><td>Historial de auditoría paginado y filtrable.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /api/clients</code></td><td>Tus clientes OAuth.</td></tr>
<tr><td><code>POST /api/clients/{id}/rotate-secret</code></td><td>Rotar el <code>client_secret</code> de un cliente confidencial (secret anterior válido 48h más).</td></tr>
<tr><td><code>GET/POST/DELETE /api/personal-access-tokens</code></td><td>Tus Personal Access Tokens.</td></tr>
<tr><td><code>GET/DELETE /api/authorized-apps</code></td><td>Apps de terceros que autorizaste.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /api/clients/{id}/webhooks</code></td><td>Webhooks de uno de tus clientes.</td></tr>
<tr><td><code>GET /api/clients/{id}/webhooks/{webhookId}/deliveries</code></td><td>Historial paginado de entregas de un webhook.</td></tr>
<tr><td><code>POST /api/clients/{id}/webhooks/{webhookId}/deliveries/{deliveryId}/retry</code></td><td>Reintentar manualmente una entrega.</td></tr>
</table>
<footer class="pagefoot">OAuth2 Identity Server — Manual de uso · generado automáticamente a partir del estado actual del proyecto.</footer>

Binary file not shown.

View File

@@ -10,4 +10,17 @@ return [
'cannot_delete_last_admin' => 'No pots esborrar l\'últim administrador.',
'self_service_requires_user' => 'Aquest recurs és self-service: necessita un token amb un usuari associat (no funciona amb client_credentials).',
'user_not_trashed' => 'Aquest usuari no és a la paperera.',
'two_factor_already_enabled' => 'Ja tens el 2FA activat. Desactiva\'l abans de tornar-lo a configurar.',
'two_factor_not_pending' => 'No hi ha cap activació de 2FA pendent de confirmar.',
'invalid_two_factor_code' => 'Codi no vàlid. Torna-ho a provar o fes servir un codi de recuperació.',
'invalid_password' => 'Contrasenya incorrecta.',
'two_factor_not_enabled' => 'Encara no has activat el 2FA.',
'webhook_url_not_allowed' => 'Aquesta URL no es pot fer servir per a un webhook: no resol a una adreça pública (localhost, IPs privades i de metadata de núvol estan bloquejades).',
'client_not_confidential' => 'Aquest client no té secret (és públic) — no hi ha res a rotar.',
'password_reset_link_sent' => 'Si existeix un compte amb aquest email, t\'hem enviat un enllaç per a restablir la contrasenya.',
'reset_password_subject' => 'Restableix la teva contrasenya',
'reset_password_intro' => 'Reps aquest email perquè hem rebut una sol·licitud per restablir la contrasenya del teu compte.',
'reset_password_action' => 'Restablir contrasenya',
'reset_password_expire' => 'Aquest enllaç caduca en :count minuts.',
'reset_password_no_action' => 'Si no vas demanar això, no cal que facis res.',
];

View File

@@ -10,4 +10,17 @@ return [
'cannot_delete_last_admin' => "You can't delete the last administrator.",
'self_service_requires_user' => 'This resource is self-service: it needs a token with an associated user (does not work with client_credentials).',
'user_not_trashed' => 'This user is not in the trash.',
'two_factor_already_enabled' => 'You already have 2FA enabled. Disable it before setting it up again.',
'two_factor_not_pending' => 'There is no pending 2FA setup to confirm.',
'invalid_two_factor_code' => 'Invalid code. Try again or use a recovery code.',
'invalid_password' => 'Incorrect password.',
'two_factor_not_enabled' => "You haven't enabled 2FA yet.",
'webhook_url_not_allowed' => "That URL can't be used for a webhook: it doesn't resolve to a public address (localhost, private IPs, and cloud metadata addresses are blocked).",
'client_not_confidential' => "This client doesn't have a secret (it's public) — there's nothing to rotate.",
'password_reset_link_sent' => "If an account exists with that email, we've sent a link to reset the password.",
'reset_password_subject' => 'Reset your password',
'reset_password_intro' => "You're receiving this email because we received a password reset request for your account.",
'reset_password_action' => 'Reset password',
'reset_password_expire' => 'This link expires in :count minutes.',
'reset_password_no_action' => "If you didn't request this, no further action is required.",
];

View File

@@ -10,4 +10,17 @@ return [
'cannot_delete_last_admin' => 'No podés borrar al último administrador.',
'self_service_requires_user' => 'Este recurso es self-service: necesita un token con usuario asociado (no funciona con client_credentials).',
'user_not_trashed' => 'Este usuario no está en la papelera.',
'two_factor_already_enabled' => 'Ya tenés 2FA activo. Desactivalo antes de volver a configurarlo.',
'two_factor_not_pending' => 'No hay ninguna activación de 2FA pendiente de confirmar.',
'invalid_two_factor_code' => 'Código inválido. Probá de nuevo o usá un código de recuperación.',
'invalid_password' => 'Contraseña incorrecta.',
'two_factor_not_enabled' => 'Todavía no activaste el 2FA.',
'webhook_url_not_allowed' => 'Esa URL no se puede usar para un webhook: no resuelve a una dirección pública (localhost, IPs privadas y de metadata de nube están bloqueadas).',
'client_not_confidential' => 'Este cliente no tiene secret (es público) — no hay nada que rotar.',
'password_reset_link_sent' => 'Si existe una cuenta con ese email, te enviamos un enlace para restablecer la contraseña.',
'reset_password_subject' => 'Restablecé tu contraseña',
'reset_password_intro' => 'Recibiste este email porque pedimos restablecer la contraseña de tu cuenta.',
'reset_password_action' => 'Restablecer contraseña',
'reset_password_expire' => 'Este enlace vence en :count minutos.',
'reset_password_no_action' => 'Si no pediste esto, no hace falta que hagas nada.',
];

226
package-lock.json generated
View File

@@ -7,12 +7,14 @@
"dependencies": {
"axios": "^1.18.1",
"pinia": "^4.0.2",
"qrcode": "^1.5.4",
"vue": "^3.5.40",
"vue-i18n": "^11.4.7",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"concurrently": "^9.0.1",
@@ -880,6 +882,16 @@
"undici-types": "~8.3.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.8",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
@@ -1148,7 +1160,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1158,7 +1169,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -1243,6 +1253,15 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1307,7 +1326,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -1320,7 +1338,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -1389,6 +1406,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -1408,6 +1434,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1426,7 +1458,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
@@ -1525,6 +1556,19 @@
}
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -1589,7 +1633,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -1734,7 +1777,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2095,6 +2137,18 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2222,6 +2276,42 @@
"integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==",
"license": "MIT"
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2229,6 +2319,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2295,6 +2394,15 @@
"pathe": "^2.0.3"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -2332,6 +2440,89 @@
"node": ">=10"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
@@ -2365,12 +2556,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -2442,6 +2638,12 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT"
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/shell-quote": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
@@ -2468,7 +2670,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -2483,7 +2684,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -2868,6 +3068,12 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT"
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -9,6 +9,7 @@
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"concurrently": "^9.0.1",
@@ -21,6 +22,7 @@
"dependencies": {
"axios": "^1.18.1",
"pinia": "^4.0.2",
"qrcode": "^1.5.4",
"vue": "^3.5.40",
"vue-i18n": "^11.4.7",
"vue-router": "^5.2.0"

BIN
public/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n';
import axios from 'axios';
import { useAuthStore } from './stores/auth';
import { useTheme } from './composables/useTheme';
import AppLogo from './components/AppLogo.vue';
const auth = useAuthStore();
const route = useRoute();
@@ -40,56 +41,58 @@ const navLinks = computed<NavLink[]>(() => [
{ to: '/clients', name: 'clients', label: t('nav.clients') },
{ to: '/tokens', name: 'tokens', label: t('nav.tokens') },
{ to: '/authorized-apps', name: 'authorized-apps', label: t('nav.authorizedApps') },
{ to: '/security', name: 'security', label: t('nav.security') },
...(auth.isAdmin ? [{ to: '/admin/users', name: 'admin-users', label: t('nav.users') }] : []),
...(auth.isAdmin ? [{ to: '/admin/analytics', name: 'admin-analytics', label: t('nav.analytics') }] : []),
...(auth.isAdmin ? [{ to: '/admin/audit-log', name: 'admin-audit-log', label: t('nav.auditLog') }] : []),
]);
</script>
<template>
<div class="app-shell">
<nav v-if="showNav" class="app-nav">
<nav v-if="showNav" class="app-nav no-print">
<div class="app-nav__inner">
<div class="app-nav__section">
<div class="app-nav__top">
<router-link to="/" class="app-nav__brand">
<span class="app-nav__logo">O2</span>
<AppLogo class="app-nav__logo" />
<span class="app-nav__brand-name">OAuth2 Server</span>
</router-link>
<div class="app-nav__links">
<router-link
v-for="link in navLinks"
:key="link.name"
:to="link.to"
class="app-nav__link"
active-class="app-nav__link--active"
>
{{ link.label }}
</router-link>
<a href="/api/documentation" class="app-nav__link">{{ t('nav.apiDocs') }}</a>
<div class="app-nav__controls">
<select :value="currentLocale" @change="onLocaleChange" class="pill-select">
<option value="es">ES</option>
<option value="en">EN</option>
<option value="ca">CA</option>
</select>
<button @click="toggleTheme" type="button" class="app-nav__theme-toggle"
:aria-label="isDark ? 'Light mode' : 'Dark mode'">
<svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 01.75.75v2a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.415 1.414a.75.75 0 101.06 1.06l1.415-1.414zM21.75 12a.75.75 0 01-.75.75h-2a.75.75 0 010-1.5h2a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.414-1.415a.75.75 0 10-1.06 1.06l1.414 1.415zM12 18a.75.75 0 01.75.75v2a.75.75 0 01-1.5 0v-2A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.414 1.414a.75.75 0 001.06 1.06l1.415-1.414zM6 12a.75.75 0 01-.75.75h-2a.75.75 0 010-1.5h2A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06L6.343 5.283a.75.75 0 10-1.06 1.06l1.414 1.414z" clip-rule="evenodd" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fill-rule="evenodd" d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.7-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z" clip-rule="evenodd" />
</svg>
</button>
<span class="app-nav__user">{{ auth.user?.email }}</span>
<button @click="logout" class="btn btn--invert">
{{ t('nav.logout') }}
</button>
</div>
</div>
<div class="app-nav__controls">
<select :value="currentLocale" @change="onLocaleChange" class="pill-select">
<option value="es">ES</option>
<option value="en">EN</option>
<option value="ca">CA</option>
</select>
<button @click="toggleTheme" type="button" class="app-nav__theme-toggle"
:aria-label="isDark ? 'Light mode' : 'Dark mode'">
<svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 01.75.75v2a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.415 1.414a.75.75 0 101.06 1.06l1.415-1.414zM21.75 12a.75.75 0 01-.75.75h-2a.75.75 0 010-1.5h2a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.414-1.415a.75.75 0 10-1.06 1.06l1.414 1.415zM12 18a.75.75 0 01.75.75v2a.75.75 0 01-1.5 0v-2A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.414 1.414a.75.75 0 001.06 1.06l1.415-1.414zM6 12a.75.75 0 01-.75.75h-2a.75.75 0 010-1.5h2A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06L6.343 5.283a.75.75 0 10-1.06 1.06l1.414 1.414z" clip-rule="evenodd" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path fill-rule="evenodd" d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.7-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z" clip-rule="evenodd" />
</svg>
</button>
<span class="app-nav__user">{{ auth.user?.email }}</span>
<button @click="logout" class="btn btn--invert">
{{ t('nav.logout') }}
</button>
<div class="app-nav__links">
<router-link
v-for="link in navLinks"
:key="link.name"
:to="link.to"
class="app-nav__link"
active-class="app-nav__link--active"
>
{{ link.label }}
</router-link>
<a href="/api/documentation" class="app-nav__link">{{ t('nav.apiDocs') }}</a>
</div>
</div>
</nav>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import logoSymbol from '../../images/logo-symbol.png';
</script>
<template>
<img :src="logoSymbol" alt="OAuth2 Server" class="app-logo" />
</template>

View File

@@ -6,8 +6,10 @@ const ca: MessageSchema = {
clients: 'Clients OAuth',
tokens: 'Personal Access Tokens',
authorizedApps: 'Apps autoritzades',
security: 'Seguretat',
users: 'Usuaris',
analytics: 'Analítiques',
auditLog: 'Audit log',
apiDocs: 'API Docs',
logout: 'Sortir',
},
@@ -32,6 +34,34 @@ const ca: MessageSchema = {
noAccount: 'No tens compte?',
signUp: "Registra't",
invalidCredentials: 'Credencials no vàlides.',
twoFactorTitle: 'Verificació en dos passos',
twoFactorSubtitle: "Introdueix el codi de la teva app autenticadora (o un codi de recuperació).",
twoFactorCode: 'Codi',
twoFactorSubmit: 'Verificar',
invalidTwoFactorCode: 'Codi no vàlid.',
backToLogin: 'Tornar a entrar',
forgotPassword: 'Has oblidat la contrasenya?',
},
forgotPassword: {
title: 'Restablir contrasenya',
subtitle: "Introdueix el teu email i t'enviarem un enllaç per triar una contrasenya nova.",
email: 'Email',
submit: 'Enviar enllaç',
submitting: 'Enviant…',
sent: "Si existeix un compte amb aquest email, t'hem enviat un enllaç per restablir la contrasenya. Revisa la teva bústia.",
backToLogin: 'Tornar a entrar',
},
resetPassword: {
title: 'Tria una contrasenya nova',
subtitle: 'Gairebé — confirma la contrasenya nova per al teu compte.',
password: 'Contrasenya nova',
passwordConfirm: 'Confirmar contrasenya',
submit: 'Restablir contrasenya',
submitting: 'Restablint…',
success: "La teva contrasenya s'ha restablert. Ja pots entrar amb la nova — hem tancat les sessions actives per seguretat.",
goToLogin: 'Anar a entrar',
invalidLink: 'Aquest enllaç no és vàlid o ha caducat. Demana\'n un de nou.',
missingParams: 'Falta informació a l\'enllaç. Demana\'n un de nou des de "Has oblidat la contrasenya?".',
},
register: {
title: 'Crear compte',
@@ -58,6 +88,8 @@ const ca: MessageSchema = {
tokensDesc: "Genera tokens d'accés directe a l'API, com API keys.",
appsTitle: 'Apps autoritzades',
appsDesc: 'Apps de tercers a les quals has donat accés al teu compte.',
securityTitle: 'Seguretat',
securityDesc: 'Activa la verificació en dos passos (2FA) per al teu compte.',
usersTitle: 'Usuaris',
usersDesc: 'Alta, edició de rol/estat i baixa de comptes (només admins).',
analyticsTitle: 'Analítiques',
@@ -85,6 +117,32 @@ const ca: MessageSchema = {
revokeConfirm: 'Vols revocar el client "{name}"? Els seus tokens deixaran de funcionar.',
clientIdShort: 'client_id',
redirectLabel: 'redirect',
webhooksTitle: 'Webhooks',
hideWebhooks: 'Amagar webhooks',
webhooksDesc: "Notificacions sortints a la teva app quan passa alguna cosa rellevant — sense haver de fer polling.",
webhookSecretWarning: 'Guarda aquest secret ara, no es podrà tornar a veure. Fes-lo servir per verificar la signatura HMAC-SHA256 a la capçalera X-Webhook-Signature.',
webhookSecretLabel: 'secret:',
webhookPaused: 'pausat',
lastDelivery: 'últim enviament',
pending: 'pendent',
pause: 'Pausar',
resume: 'Reprendre',
viewHistory: 'Veure historial',
hideHistory: 'Amagar historial',
noDeliveries: 'Encara no hi ha hagut entregues per a aquest webhook.',
attempts: 'intents: {count}',
retryDelivery: 'Reintentar',
retrying: 'Reintentant…',
noWebhooks: 'Encara no has registrat cap webhook per a aquesta app.',
webhookUrl: 'URL del webhook',
addWebhook: 'Afegir webhook',
webhookDeleteConfirm: 'Vols esborrar el webhook "{url}"?',
eventTokenRevoked: 'Token revocat',
eventUserDeactivated: 'Usuari desactivat',
rotateSecret: 'Rotar secret',
rotateSecretConfirm: 'Vols rotar el secret de "{name}"? El secret actual seguirà funcionant 48 hores més per no tallar integracions que encara no s\'han actualitzat.',
rotateSecretFailed: 'No s\'ha pogut rotar el secret.',
previousSecretStillValid: 'El secret anterior segueix sent vàlid fins al {date}.',
},
tokens: {
title: 'Personal Access Tokens',
@@ -169,6 +227,14 @@ const ca: MessageSchema = {
reject: 'Rebutjar',
rejectConfirm: 'Vols rebutjar el registre de "{name}"? Anirà a la paperera i es pot restaurar després.',
rejectFailed: "No s'ha pogut rebutjar el registre.",
sessionsTitle: 'Sessions actives',
sessionsDesc: "Tokens OAuth vigents d'aquest usuari — apps amb accés al seu compte ara mateix.",
noSessions: 'No hi ha sessions actives.',
sessionUnknownClient: '(app esborrada)',
revokeSession: 'Revocar',
revokeSessionConfirm: 'Vols revocar la sessió de "{name}"? Aquesta app deixarà de tenir accés.',
revokeAllSessions: 'Tancar totes',
revokeAllSessionsConfirm: 'Vols tancar totes les sessions actives de "{name}"? Tots els seus tokens OAuth quedaran revocats.',
},
analytics: {
title: 'Analítiques',
@@ -176,6 +242,7 @@ const ca: MessageSchema = {
last7: 'Últims 7 dies',
last30: 'Últims 30 dies',
last90: 'Últims 90 dies',
exportPdf: 'Exportar PDF',
totalUsers: 'Usuaris totals',
activeSessions: 'Sessions actives',
activeSessionsHint: 'tokens OAuth vigents',
@@ -203,6 +270,86 @@ const ca: MessageSchema = {
tooltipAppLabel: 'Apps (OAuth):',
tooltipSessionLabel: 'Tauler admin:',
barAriaLabel: "{date}: {oauth} logins d'apps, {session} al tauler admin",
loginFailureRate: 'Taxa de fallades de login',
loginFailureRateHint: '{count} fallits en els últims 7 dies',
oauthApprovalRate: "Taxa d'aprovació OAuth",
oauthApprovalRateHint: '{approved}/{attempted} en aquest període',
funnelTitle: "Funnel d'autorització OAuth",
funnelEmpty: "Encara no hi ha intents d'autorització en aquest període.",
colClient: 'Client',
colAttempted: 'Intents',
colApproved: 'Aprovats',
colDenied: 'Denegats',
colApprovalRate: 'Aprovació',
activeTokensByClient: 'Tokens actius per client',
noActiveTokens: 'No hi ha tokens actius ara mateix.',
scopeUsageTitle: "Ús d'scopes",
noScopeUsage: "Encara no s'ha concedit cap scope en aquest període.",
},
auditLog: {
title: 'Audit log',
subtitle: "Canvis de permisos, altes/baixes d'usuaris i clients OAuth.",
allSubjects: 'Tots els subjectes',
subjectUser: 'Usuaris',
subjectClient: 'Clients OAuth',
allActions: 'Totes les accions',
empty: 'Encara no hi ha esdeveniments registrats.',
colWhen: 'Quan',
colActor: 'Qui',
colAction: 'Acció',
colSubject: 'Subjecte',
colDetails: 'Detalls',
colIp: 'IP',
systemActor: '(sistema)',
fieldChanged: '{field} canviat',
prevPage: 'Anterior',
nextPage: 'Següent',
pageInfo: 'Pàgina {page} de {lastPage} — {total} esdeveniments',
actions: {
userCreated: 'Usuari creat',
userUpdated: 'Usuari editat',
userDeleted: 'Usuari esborrat',
userRestored: 'Usuari restaurat',
userForceDeleted: 'Usuari esborrat definitivament',
userApproved: 'Usuari aprovat',
appRoleAssigned: 'Rol per app assignat',
appRoleRevoked: 'Rol per app revocat',
clientCreated: 'Client OAuth creat',
clientRevoked: 'Client OAuth revocat',
twoFactorEnabled: '2FA activat',
twoFactorDisabled: '2FA desactivat',
twoFactorRecoveryRegenerated: 'Codis de recuperació regenerats',
sessionRevoked: 'Sessió revocada',
sessionsRevokedAll: 'Totes les sessions tancades',
webhookCreated: 'Webhook creat',
webhookDeleted: 'Webhook esborrat',
secretRotated: 'Secret rotat',
passwordReset: 'Contrasenya restablerta',
},
},
security: {
title: 'Seguretat',
subtitle: 'Verificació en dos passos (2FA) per al teu compte.',
enabled: '2FA activat',
enabledDesc: "El teu compte demana un codi de la teva app autenticadora cada cop que inicies sessió.",
disabled: '2FA desactivat',
disabledDesc: 'Afegeix una capa extra de seguretat: un codi del teu telèfon a més de la contrasenya.',
enable: 'Activar 2FA',
disable: 'Desactivar',
disableConfirm: 'Confirmar desactivació',
regenerateCodes: 'Regenerar codis de recuperació',
confirmPassword: 'Confirma la teva contrasenya',
confirm: 'Confirmar',
setupTitle: 'Escaneja el codi QR',
setupDesc: 'Fes servir Google Authenticator, Authy o qualsevol app compatible amb TOTP.',
manualEntry: 'No pots escanejar? Introdueix aquest codi a mà:',
enterCode: 'Codi de 6 dígits',
invalidCode: 'Codi no vàlid. Torna-ho a provar.',
invalidPassword: 'Contrasenya incorrecta.',
setupFailed: "No s'ha pogut iniciar l'activació del 2FA.",
qrAlt: 'Codi QR per configurar el 2FA',
recoveryCodesTitle: 'Guarda aquests codis de recuperació',
recoveryCodesWarning: "Cadascun serveix una sola vegada. Fes-los servir si perds l'accés a la teva app autenticadora. No es tornaran a mostrar.",
},
};

View File

@@ -6,8 +6,10 @@ const en: MessageSchema = {
clients: 'OAuth Clients',
tokens: 'Personal Access Tokens',
authorizedApps: 'Authorized Apps',
security: 'Security',
users: 'Users',
analytics: 'Analytics',
auditLog: 'Audit log',
apiDocs: 'API Docs',
logout: 'Sign out',
},
@@ -32,6 +34,34 @@ const en: MessageSchema = {
noAccount: "Don't have an account?",
signUp: 'Sign up',
invalidCredentials: 'Invalid credentials.',
twoFactorTitle: 'Two-factor verification',
twoFactorSubtitle: 'Enter the code from your authenticator app (or a recovery code).',
twoFactorCode: 'Code',
twoFactorSubmit: 'Verify',
invalidTwoFactorCode: 'Invalid code.',
backToLogin: 'Back to sign in',
forgotPassword: 'Forgot your password?',
},
forgotPassword: {
title: 'Reset password',
subtitle: "Enter your email and we'll send you a link to choose a new password.",
email: 'Email',
submit: 'Send link',
submitting: 'Sending…',
sent: "If an account exists with that email, we've sent a link to reset the password. Check your inbox.",
backToLogin: 'Back to sign in',
},
resetPassword: {
title: 'Choose a new password',
subtitle: "Almost done — confirm the new password for your account.",
password: 'New password',
passwordConfirm: 'Confirm password',
submit: 'Reset password',
submitting: 'Resetting…',
success: 'Your password has been reset. You can sign in with the new one now — we closed your active sessions for security.',
goToLogin: 'Go to sign in',
invalidLink: 'This link is invalid or has expired. Request a new one.',
missingParams: 'This link is missing information. Request a new one from "Forgot your password?".',
},
register: {
title: 'Create account',
@@ -58,6 +88,8 @@ const en: MessageSchema = {
tokensDesc: 'Generate tokens for direct API access, like API keys.',
appsTitle: 'Authorized Apps',
appsDesc: "Third-party apps you've granted access to your account.",
securityTitle: 'Security',
securityDesc: 'Enable two-factor authentication (2FA) for your account.',
usersTitle: 'Users',
usersDesc: 'Create, edit role/status and delete accounts (admins only).',
analyticsTitle: 'Analytics',
@@ -85,6 +117,32 @@ const en: MessageSchema = {
revokeConfirm: 'Revoke client "{name}"? Its tokens will stop working.',
clientIdShort: 'client_id',
redirectLabel: 'redirect',
webhooksTitle: 'Webhooks',
hideWebhooks: 'Hide webhooks',
webhooksDesc: 'Outgoing notifications to your app when something relevant happens — no polling needed.',
webhookSecretWarning: 'Save this secret now, it cannot be shown again. Use it to verify the HMAC-SHA256 signature in the X-Webhook-Signature header.',
webhookSecretLabel: 'secret:',
webhookPaused: 'paused',
lastDelivery: 'last delivery',
pending: 'pending',
pause: 'Pause',
resume: 'Resume',
viewHistory: 'View history',
hideHistory: 'Hide history',
noDeliveries: 'No deliveries for this webhook yet.',
attempts: 'attempts: {count}',
retryDelivery: 'Retry',
retrying: 'Retrying…',
noWebhooks: "You haven't registered any webhooks for this app yet.",
webhookUrl: 'Webhook URL',
addWebhook: 'Add webhook',
webhookDeleteConfirm: 'Delete the webhook "{url}"?',
eventTokenRevoked: 'Token revoked',
eventUserDeactivated: 'User deactivated',
rotateSecret: 'Rotate secret',
rotateSecretConfirm: 'Rotate the secret for "{name}"? The current secret will keep working for 48 more hours so integrations that haven\'t updated yet don\'t break.',
rotateSecretFailed: 'Could not rotate the secret.',
previousSecretStillValid: 'The previous secret stays valid until {date}.',
},
tokens: {
title: 'Personal Access Tokens',
@@ -169,6 +227,14 @@ const en: MessageSchema = {
reject: 'Reject',
rejectConfirm: 'Reject the registration for "{name}"? It will go to the trash and can be restored later.',
rejectFailed: 'Could not reject the registration.',
sessionsTitle: 'Active sessions',
sessionsDesc: 'Active OAuth tokens for this user — apps with access to their account right now.',
noSessions: 'No active sessions.',
sessionUnknownClient: '(deleted app)',
revokeSession: 'Revoke',
revokeSessionConfirm: 'Revoke the session for "{name}"? That app will lose access.',
revokeAllSessions: 'Revoke all',
revokeAllSessionsConfirm: 'Revoke all active sessions for "{name}"? All their OAuth tokens will be revoked.',
},
analytics: {
title: 'Analytics',
@@ -176,6 +242,7 @@ const en: MessageSchema = {
last7: 'Last 7 days',
last30: 'Last 30 days',
last90: 'Last 90 days',
exportPdf: 'Export PDF',
totalUsers: 'Total users',
activeSessions: 'Active sessions',
activeSessionsHint: 'valid OAuth tokens',
@@ -203,6 +270,86 @@ const en: MessageSchema = {
tooltipAppLabel: 'Apps (OAuth):',
tooltipSessionLabel: 'Admin panel:',
barAriaLabel: '{date}: {oauth} app logins, {session} to admin panel',
loginFailureRate: 'Login failure rate',
loginFailureRateHint: '{count} failed in the last 7 days',
oauthApprovalRate: 'OAuth approval rate',
oauthApprovalRateHint: '{approved}/{attempted} in this period',
funnelTitle: 'OAuth authorization funnel',
funnelEmpty: 'No authorization attempts in this period yet.',
colClient: 'Client',
colAttempted: 'Attempts',
colApproved: 'Approved',
colDenied: 'Denied',
colApprovalRate: 'Approval',
activeTokensByClient: 'Active tokens by client',
noActiveTokens: 'No active tokens right now.',
scopeUsageTitle: 'Scope usage',
noScopeUsage: 'No scopes granted in this period yet.',
},
auditLog: {
title: 'Audit log',
subtitle: 'Permission changes, user and OAuth client create/delete events.',
allSubjects: 'All subjects',
subjectUser: 'Users',
subjectClient: 'OAuth clients',
allActions: 'All actions',
empty: 'No events recorded yet.',
colWhen: 'When',
colActor: 'Who',
colAction: 'Action',
colSubject: 'Subject',
colDetails: 'Details',
colIp: 'IP',
systemActor: '(system)',
fieldChanged: '{field} changed',
prevPage: 'Previous',
nextPage: 'Next',
pageInfo: 'Page {page} of {lastPage} — {total} events',
actions: {
userCreated: 'User created',
userUpdated: 'User updated',
userDeleted: 'User deleted',
userRestored: 'User restored',
userForceDeleted: 'User permanently deleted',
userApproved: 'User approved',
appRoleAssigned: 'App role assigned',
appRoleRevoked: 'App role revoked',
clientCreated: 'OAuth client created',
clientRevoked: 'OAuth client revoked',
twoFactorEnabled: '2FA enabled',
twoFactorDisabled: '2FA disabled',
twoFactorRecoveryRegenerated: 'Recovery codes regenerated',
sessionRevoked: 'Session revoked',
sessionsRevokedAll: 'All sessions revoked',
webhookCreated: 'Webhook created',
webhookDeleted: 'Webhook deleted',
secretRotated: 'Secret rotated',
passwordReset: 'Password reset',
},
},
security: {
title: 'Security',
subtitle: 'Two-factor authentication (2FA) for your account.',
enabled: '2FA enabled',
enabledDesc: 'Your account asks for a code from your authenticator app every time you sign in.',
disabled: '2FA disabled',
disabledDesc: 'Add an extra layer of security: a code from your phone in addition to your password.',
enable: 'Enable 2FA',
disable: 'Disable',
disableConfirm: 'Confirm disable',
regenerateCodes: 'Regenerate recovery codes',
confirmPassword: 'Confirm your password',
confirm: 'Confirm',
setupTitle: 'Scan the QR code',
setupDesc: 'Use Google Authenticator, Authy, or any TOTP-compatible app.',
manualEntry: "Can't scan? Enter this code manually:",
enterCode: '6-digit code',
invalidCode: 'Invalid code. Try again.',
invalidPassword: 'Incorrect password.',
setupFailed: 'Could not start 2FA setup.',
qrAlt: 'QR code to set up 2FA',
recoveryCodesTitle: 'Save these recovery codes',
recoveryCodesWarning: "Each one works once. Use them if you lose access to your authenticator app. They won't be shown again.",
},
};

View File

@@ -4,8 +4,10 @@ const es = {
clients: 'Clientes OAuth',
tokens: 'Personal Access Tokens',
authorizedApps: 'Apps autorizadas',
security: 'Seguridad',
users: 'Usuarios',
analytics: 'Analíticas',
auditLog: 'Audit log',
apiDocs: 'API Docs',
logout: 'Salir',
},
@@ -30,6 +32,34 @@ const es = {
noAccount: '¿No tenés cuenta?',
signUp: 'Registrate',
invalidCredentials: 'Credenciales inválidas.',
twoFactorTitle: 'Verificación en dos pasos',
twoFactorSubtitle: 'Ingresá el código de tu app autenticadora (o un código de recuperación).',
twoFactorCode: 'Código',
twoFactorSubmit: 'Verificar',
invalidTwoFactorCode: 'Código inválido.',
backToLogin: 'Volver a ingresar',
forgotPassword: '¿Olvidaste tu contraseña?',
},
forgotPassword: {
title: 'Restablecer contraseña',
subtitle: 'Ingresá tu email y te mandamos un enlace para elegir una contraseña nueva.',
email: 'Email',
submit: 'Enviar enlace',
submitting: 'Enviando…',
sent: 'Si existe una cuenta con ese email, te enviamos un enlace para restablecer la contraseña. Revisá tu bandeja de entrada.',
backToLogin: 'Volver a ingresar',
},
resetPassword: {
title: 'Elegí una contraseña nueva',
subtitle: 'Ya casi — confirmá la contraseña nueva para tu cuenta.',
password: 'Contraseña nueva',
passwordConfirm: 'Confirmar contraseña',
submit: 'Restablecer contraseña',
submitting: 'Restableciendo…',
success: 'Tu contraseña se restableció. Ya podés ingresar con la nueva — cerramos las sesiones activas por seguridad.',
goToLogin: 'Ir a ingresar',
invalidLink: 'Este enlace es inválido o venció. Pedí uno nuevo.',
missingParams: 'Falta información en el enlace. Pedí uno nuevo desde "¿Olvidaste tu contraseña?".',
},
register: {
title: 'Crear cuenta',
@@ -56,6 +86,8 @@ const es = {
tokensDesc: 'Generá tokens de acceso directo a la API, como API keys.',
appsTitle: 'Apps autorizadas',
appsDesc: 'Apps de terceros a las que le diste acceso a tu cuenta.',
securityTitle: 'Seguridad',
securityDesc: 'Activá verificación en dos pasos (2FA) para tu cuenta.',
usersTitle: 'Usuarios',
usersDesc: 'Alta, edición de rol/estado y baja de cuentas (solo admins).',
analyticsTitle: 'Analíticas',
@@ -83,6 +115,32 @@ const es = {
revokeConfirm: '¿Revocar el cliente "{name}"? Sus tokens dejarán de funcionar.',
clientIdShort: 'client_id',
redirectLabel: 'redirect',
webhooksTitle: 'Webhooks',
hideWebhooks: 'Ocultar webhooks',
webhooksDesc: 'Notificaciones salientes a tu app cuando pasa algo relevante — sin tener que hacer polling.',
webhookSecretWarning: 'Guardá este secret ahora, no se puede volver a ver. Usalo para verificar la firma HMAC-SHA256 en el header X-Webhook-Signature.',
webhookSecretLabel: 'secret:',
webhookPaused: 'pausado',
lastDelivery: 'último envío',
pending: 'pendiente',
pause: 'Pausar',
resume: 'Reanudar',
viewHistory: 'Ver historial',
hideHistory: 'Ocultar historial',
noDeliveries: 'Todavía no hubo entregas para este webhook.',
attempts: 'intentos: {count}',
retryDelivery: 'Reintentar',
retrying: 'Reintentando…',
noWebhooks: 'Todavía no registraste ningún webhook para esta app.',
webhookUrl: 'URL del webhook',
addWebhook: 'Agregar webhook',
webhookDeleteConfirm: '¿Borrar el webhook "{url}"?',
eventTokenRevoked: 'Token revocado',
eventUserDeactivated: 'Usuario desactivado',
rotateSecret: 'Rotar secret',
rotateSecretConfirm: '¿Rotar el secret de "{name}"? El secret actual va a seguir funcionando 48 horas más para no cortar integraciones que todavía no se actualizaron.',
rotateSecretFailed: 'No se pudo rotar el secret.',
previousSecretStillValid: 'El secret anterior sigue siendo válido hasta el {date}.',
},
tokens: {
title: 'Personal Access Tokens',
@@ -167,6 +225,14 @@ const es = {
reject: 'Rechazar',
rejectConfirm: '¿Rechazar el registro de "{name}"? Va a la papelera y se puede restaurar después.',
rejectFailed: 'No se pudo rechazar el registro.',
sessionsTitle: 'Sesiones activas',
sessionsDesc: 'Tokens OAuth vigentes de este usuario — apps con acceso a su cuenta ahora mismo.',
noSessions: 'No hay sesiones activas.',
sessionUnknownClient: '(app borrada)',
revokeSession: 'Revocar',
revokeSessionConfirm: '¿Revocar la sesión de "{name}"? Esa app va a dejar de tener acceso.',
revokeAllSessions: 'Cerrar todas',
revokeAllSessionsConfirm: '¿Cerrar todas las sesiones activas de "{name}"? Todos sus tokens OAuth van a quedar revocados.',
},
analytics: {
title: 'Analíticas',
@@ -174,6 +240,7 @@ const es = {
last7: 'Últimos 7 días',
last30: 'Últimos 30 días',
last90: 'Últimos 90 días',
exportPdf: 'Exportar PDF',
totalUsers: 'Usuarios totales',
activeSessions: 'Sesiones activas',
activeSessionsHint: 'tokens OAuth vigentes',
@@ -201,6 +268,86 @@ const es = {
tooltipAppLabel: 'Apps (OAuth):',
tooltipSessionLabel: 'Panel admin:',
barAriaLabel: '{date}: {oauth} logins de apps, {session} al panel admin',
loginFailureRate: 'Tasa de fallos de login',
loginFailureRateHint: '{count} fallidos en 7 días',
oauthApprovalRate: 'Tasa de aprobación OAuth',
oauthApprovalRateHint: '{approved}/{attempted} en el período',
funnelTitle: 'Funnel de autorización OAuth',
funnelEmpty: 'Todavía no hay intentos de autorización en este período.',
colClient: 'Cliente',
colAttempted: 'Intentos',
colApproved: 'Aprobados',
colDenied: 'Denegados',
colApprovalRate: 'Aprobación',
activeTokensByClient: 'Tokens activos por cliente',
noActiveTokens: 'No hay tokens activos por ahora.',
scopeUsageTitle: 'Uso de scopes',
noScopeUsage: 'Todavía no se otorgó ningún scope en este período.',
},
auditLog: {
title: 'Audit log',
subtitle: 'Cambios de permisos, altas/bajas de usuarios y clientes OAuth.',
allSubjects: 'Todos los sujetos',
subjectUser: 'Usuarios',
subjectClient: 'Clientes OAuth',
allActions: 'Todas las acciones',
empty: 'Todavía no hay eventos registrados.',
colWhen: 'Cuándo',
colActor: 'Quién',
colAction: 'Acción',
colSubject: 'Sujeto',
colDetails: 'Detalles',
colIp: 'IP',
systemActor: '(sistema)',
fieldChanged: '{field} cambiada',
prevPage: 'Anterior',
nextPage: 'Siguiente',
pageInfo: 'Página {page} de {lastPage} — {total} eventos',
actions: {
userCreated: 'Usuario creado',
userUpdated: 'Usuario editado',
userDeleted: 'Usuario borrado',
userRestored: 'Usuario restaurado',
userForceDeleted: 'Usuario borrado definitivamente',
userApproved: 'Usuario aprobado',
appRoleAssigned: 'Rol por app asignado',
appRoleRevoked: 'Rol por app revocado',
clientCreated: 'Cliente OAuth creado',
clientRevoked: 'Cliente OAuth revocado',
twoFactorEnabled: '2FA activado',
twoFactorDisabled: '2FA desactivado',
twoFactorRecoveryRegenerated: 'Códigos de recuperación regenerados',
sessionRevoked: 'Sesión revocada',
sessionsRevokedAll: 'Todas las sesiones cerradas',
webhookCreated: 'Webhook creado',
webhookDeleted: 'Webhook borrado',
secretRotated: 'Secret rotado',
passwordReset: 'Contraseña restablecida',
},
},
security: {
title: 'Seguridad',
subtitle: 'Verificación en dos pasos (2FA) para tu cuenta.',
enabled: '2FA activo',
enabledDesc: 'Tu cuenta pide un código de tu app autenticadora cada vez que iniciás sesión.',
disabled: '2FA desactivado',
disabledDesc: 'Agregá una capa extra de seguridad: un código de tu teléfono además de la contraseña.',
enable: 'Activar 2FA',
disable: 'Desactivar',
disableConfirm: 'Confirmar desactivación',
regenerateCodes: 'Regenerar códigos de recuperación',
confirmPassword: 'Confirmá tu contraseña',
confirm: 'Confirmar',
setupTitle: 'Escaneá el código QR',
setupDesc: 'Usá Google Authenticator, Authy o cualquier app compatible con TOTP.',
manualEntry: '¿No podés escanear? Ingresá este código a mano:',
enterCode: 'Código de 6 dígitos',
invalidCode: 'Código inválido. Probá de nuevo.',
invalidPassword: 'Contraseña incorrecta.',
setupFailed: 'No se pudo iniciar la activación de 2FA.',
qrAlt: 'Código QR para configurar 2FA',
recoveryCodesTitle: 'Guardá estos códigos de recuperación',
recoveryCodesWarning: 'Cada uno sirve una sola vez. Usalos si perdés acceso a tu app autenticadora. No se van a volver a mostrar.',
},
};

View File

@@ -1,12 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import AppLogo from '../components/AppLogo.vue';
const { t } = useI18n();
const data = window.__OAUTH_AUTHORIZE__ ?? null;
const approveForm = ref<HTMLFormElement | null>(null);
const denyForm = ref<HTMLFormElement | null>(null);
function logAuthorizationEvent(outcome: 'attempted' | 'approved' | 'denied'): Promise<void> {
if (!data) {
return Promise.resolve();
}
return axios
.post('/web-api/oauth/authorize-events', {
client_id: data.client.id,
outcome,
scopes: data.scopes.map((scope) => scope.id),
})
.then(() => undefined)
.catch(() => {
// Analíticas best-effort: nunca debe bloquear el flujo real de OAuth.
});
}
onMounted(() => {
logAuthorizationEvent('attempted');
});
async function submitWithOutcome(outcome: 'approved' | 'denied', form: HTMLFormElement | null): Promise<void> {
await logAuthorizationEvent(outcome);
form?.submit();
}
</script>
<template>
@@ -15,7 +43,7 @@ const denyForm = ref<HTMLFormElement | null>(null);
<div class="auth-card auth-card--wide">
<template v-if="data">
<span class="auth-card__logo">O2</span>
<AppLogo class="auth-card__logo" />
<h1 class="auth-card__title">{{ t('authorize.title') }}</h1>
<p class="auth-card__subtitle">
<i18n-t keypath="authorize.wantsAccess" tag="span">
@@ -35,7 +63,7 @@ const denyForm = ref<HTMLFormElement | null>(null);
</ul>
<div class="auth-card__actions">
<form ref="approveForm" method="POST" action="/oauth/authorize">
<form ref="approveForm" method="POST" action="/oauth/authorize" @submit.prevent="submitWithOutcome('approved', approveForm)">
<input type="hidden" name="_token" :value="data.csrfToken" />
<input type="hidden" name="auth_token" :value="data.authToken" />
<button type="submit" class="btn btn--primary btn--pill btn--full">
@@ -43,7 +71,7 @@ const denyForm = ref<HTMLFormElement | null>(null);
</button>
</form>
<form ref="denyForm" method="POST" action="/oauth/authorize">
<form ref="denyForm" method="POST" action="/oauth/authorize" @submit.prevent="submitWithOutcome('denied', denyForm)">
<input type="hidden" name="_token" :value="data.csrfToken" />
<input type="hidden" name="_method" value="DELETE" />
<input type="hidden" name="auth_token" :value="data.authToken" />

View File

@@ -2,14 +2,14 @@
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import type { OAuthClient } from '../types/models';
import type { OAuthClient, Paginated, WebhookDelivery, WebhookEndpoint } from '../types/models';
const { t } = useI18n();
const { t, locale } = useI18n();
const clients = ref<OAuthClient[]>([]);
const loading = ref(true);
const creating = ref(false);
const newSecret = ref<{ name: string; secret: string; id: string } | null>(null);
const newSecret = ref<{ name: string; secret: string; id: string; previousSecretExpiresAt?: string } | null>(null);
const form = reactive({
name: '',
@@ -19,6 +19,22 @@ const form = reactive({
});
const errors = ref<Record<string, string[]>>({});
const WEBHOOK_EVENTS = ['token.revoked', 'user.deactivated'];
const expandedClientId = ref<string | null>(null);
const webhooks = ref<WebhookEndpoint[]>([]);
const webhookForm = reactive({ url: '', events: [] as string[] });
const webhookErrors = ref<Record<string, string[]>>({});
const webhookCreating = ref(false);
const newWebhookSecret = ref<{ url: string; secret: string } | null>(null);
const historyWebhookId = ref<number | null>(null);
const deliveries = ref<WebhookDelivery[]>([]);
const deliveriesLoading = ref(false);
const deliveriesPage = ref(1);
const deliveriesLastPage = ref(1);
const retryingDeliveryId = ref<number | null>(null);
async function load(): Promise<void> {
loading.value = true;
const { data } = await axios.get<OAuthClient[]>('/web-api/clients');
@@ -64,6 +80,28 @@ async function revoke(client: OAuthClient): Promise<void> {
await load();
}
async function rotateSecret(client: OAuthClient): Promise<void> {
if (!confirm(t('clients.rotateSecretConfirm', { name: client.name }))) return;
try {
const { data } = await axios.post<{ plain_secret: string; previous_secret_expires_at: string }>(
`/web-api/clients/${client.id}/rotate-secret`,
);
newSecret.value = {
name: client.name,
secret: data.plain_secret,
id: client.id,
previousSecretExpiresAt: data.previous_secret_expires_at,
};
} catch (e: any) {
alert(e.response?.data?.message ?? t('clients.rotateSecretFailed'));
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function grantLabel(grantTypes: string[]): string {
if (grantTypes.includes('authorization_code')) return t('clients.grantAuthCode');
if (grantTypes.includes('client_credentials')) return t('clients.grantClientCredentials');
@@ -71,6 +109,108 @@ function grantLabel(grantTypes: string[]): string {
return grantTypes.join(', ');
}
async function toggleWebhooks(client: OAuthClient): Promise<void> {
if (expandedClientId.value === client.id) {
expandedClientId.value = null;
return;
}
expandedClientId.value = client.id;
webhookForm.url = '';
webhookForm.events = [];
webhookErrors.value = {};
newWebhookSecret.value = null;
historyWebhookId.value = null;
await loadWebhooks(client);
}
async function loadWebhooks(client: OAuthClient): Promise<void> {
const { data } = await axios.get<WebhookEndpoint[]>(`/web-api/clients/${client.id}/webhooks`);
webhooks.value = data;
}
async function createWebhook(client: OAuthClient): Promise<void> {
webhookCreating.value = true;
webhookErrors.value = {};
try {
const { data } = await axios.post<WebhookEndpoint & { secret: string }>(`/web-api/clients/${client.id}/webhooks`, webhookForm);
newWebhookSecret.value = { url: data.url, secret: data.secret };
webhookForm.url = '';
webhookForm.events = [];
await loadWebhooks(client);
} catch (e: any) {
webhookErrors.value = e.response?.data?.errors ?? {};
} finally {
webhookCreating.value = false;
}
}
async function toggleWebhookActive(client: OAuthClient, webhook: WebhookEndpoint): Promise<void> {
await axios.put(`/web-api/clients/${client.id}/webhooks/${webhook.id}`, { active: !webhook.active });
await loadWebhooks(client);
}
async function deleteWebhook(client: OAuthClient, webhook: WebhookEndpoint): Promise<void> {
if (!confirm(t('clients.webhookDeleteConfirm', { url: webhook.url }))) return;
await axios.delete(`/web-api/clients/${client.id}/webhooks/${webhook.id}`);
await loadWebhooks(client);
}
async function toggleHistory(client: OAuthClient, webhook: WebhookEndpoint): Promise<void> {
if (historyWebhookId.value === webhook.id) {
historyWebhookId.value = null;
return;
}
historyWebhookId.value = webhook.id;
deliveriesPage.value = 1;
await loadDeliveries(client, webhook);
}
async function loadDeliveries(client: OAuthClient, webhook: WebhookEndpoint): Promise<void> {
deliveriesLoading.value = true;
const { data } = await axios.get<Paginated<WebhookDelivery>>(
`/web-api/clients/${client.id}/webhooks/${webhook.id}/deliveries`,
{ params: { page: deliveriesPage.value } },
);
deliveries.value = data.data;
deliveriesLastPage.value = data.last_page;
deliveriesLoading.value = false;
}
function goToDeliveriesPage(client: OAuthClient, webhook: WebhookEndpoint, next: number): void {
if (next < 1 || next > deliveriesLastPage.value) return;
deliveriesPage.value = next;
loadDeliveries(client, webhook);
}
async function retryDelivery(client: OAuthClient, webhook: WebhookEndpoint, delivery: WebhookDelivery): Promise<void> {
retryingDeliveryId.value = delivery.id;
try {
await axios.post(`/web-api/clients/${client.id}/webhooks/${webhook.id}/deliveries/${delivery.id}/retry`);
await loadDeliveries(client, webhook);
} finally {
retryingDeliveryId.value = null;
}
}
function deliveryStatusClass(delivery: WebhookDelivery): string {
if (delivery.delivered_at) return 'badge--active';
if (delivery.response_status) return 'badge--danger';
return 'badge--inactive';
}
function deliveryStatusLabel(delivery: WebhookDelivery): string {
if (delivery.response_status) return String(delivery.response_status);
return t('clients.pending');
}
function eventLabel(event: string): string {
return event === 'token.revoked' ? t('clients.eventTokenRevoked') : t('clients.eventUserDeactivated');
}
onMounted(load);
</script>
@@ -83,6 +223,9 @@ onMounted(load);
<p class="banner__title">{{ t('clients.secretWarning') }}</p>
<p class="banner__line"><strong>{{ t('clients.clientIdLabel') }}</strong> <code class="banner__code">{{ newSecret.id }}</code></p>
<p class="banner__line"><strong>{{ t('clients.clientSecretLabel') }}</strong> <code class="banner__code">{{ newSecret.secret }}</code></p>
<p v-if="newSecret.previousSecretExpiresAt" class="banner__line">
{{ t('clients.previousSecretStillValid', { date: formatDate(newSecret.previousSecretExpiresAt) }) }}
</p>
<button class="banner__close" @click="newSecret = null">{{ t('common.close') }}</button>
</div>
@@ -126,17 +269,119 @@ onMounted(load);
<p v-if="loading" class="page-subtitle">{{ t('common.loading') }}</p>
<p v-else-if="!clients.length" class="page-subtitle">{{ t('clients.empty') }}</p>
<div v-for="client in clients" :key="client.id" class="card clients__item">
<div>
<p class="card__title">{{ client.name }}</p>
<p class="card__subtitle">{{ grantLabel(client.grant_types) }}</p>
<p class="clients__item-meta">{{ t('clients.clientIdShort') }}: {{ client.id }}</p>
<p v-if="client.redirect_uris?.length" class="clients__item-meta">
{{ t('clients.redirectLabel') }}: {{ client.redirect_uris.join(', ') }}
</p>
<template v-for="client in clients" :key="client.id">
<div class="card clients__item">
<div>
<p class="card__title">{{ client.name }}</p>
<p class="card__subtitle">{{ grantLabel(client.grant_types) }}</p>
<p class="clients__item-meta">{{ t('clients.clientIdShort') }}: {{ client.id }}</p>
<p v-if="client.redirect_uris?.length" class="clients__item-meta">
{{ t('clients.redirectLabel') }}: {{ client.redirect_uris.join(', ') }}
</p>
</div>
<div class="clients__item-actions">
<button @click="toggleWebhooks(client)" class="link">
{{ expandedClientId === client.id ? t('clients.hideWebhooks') : t('clients.webhooksTitle') }}
</button>
<button @click="rotateSecret(client)" class="link">{{ t('clients.rotateSecret') }}</button>
<button @click="revoke(client)" class="link link--danger">{{ t('clients.revoke') }}</button>
</div>
</div>
<button @click="revoke(client)" class="link link--danger">{{ t('clients.revoke') }}</button>
</div>
<div v-if="expandedClientId === client.id" class="card clients__webhooks">
<h3 class="card__title">{{ t('clients.webhooksTitle') }}</h3>
<p class="clients__item-meta">{{ t('clients.webhooksDesc') }}</p>
<div v-if="newWebhookSecret" class="banner banner--warning">
<p class="banner__title">{{ t('clients.webhookSecretWarning') }}</p>
<p class="banner__line"><strong>{{ t('clients.webhookSecretLabel') }}</strong> <code class="banner__code">{{ newWebhookSecret.secret }}</code></p>
<button class="banner__close" @click="newWebhookSecret = null">{{ t('common.close') }}</button>
</div>
<ul v-if="webhooks.length" class="clients__webhook-list">
<template v-for="webhook in webhooks" :key="webhook.id">
<li class="clients__webhook-row">
<div>
<p class="clients__webhook-url">{{ webhook.url }}</p>
<p class="clients__item-meta">
{{ webhook.events.map(eventLabel).join(', ') }}
<span v-if="!webhook.active"> · {{ t('clients.webhookPaused') }}</span>
<span v-if="webhook.last_delivery">
· {{ t('clients.lastDelivery') }}: {{ webhook.last_delivery.response_status ?? t('clients.pending') }}
</span>
</p>
</div>
<div class="clients__webhook-actions">
<button @click="toggleHistory(client, webhook)" class="link">
{{ historyWebhookId === webhook.id ? t('clients.hideHistory') : t('clients.viewHistory') }}
</button>
<button @click="toggleWebhookActive(client, webhook)" class="link">
{{ webhook.active ? t('clients.pause') : t('clients.resume') }}
</button>
<button @click="deleteWebhook(client, webhook)" class="link link--danger">{{ t('common.delete') }}</button>
</div>
</li>
<li v-if="historyWebhookId === webhook.id" class="clients__delivery-history">
<p v-if="deliveriesLoading" class="clients__item-meta">{{ t('common.loading') }}</p>
<p v-else-if="!deliveries.length" class="clients__item-meta">{{ t('clients.noDeliveries') }}</p>
<ul v-else class="clients__delivery-list">
<li v-for="delivery in deliveries" :key="delivery.id" class="clients__delivery-row">
<div class="clients__delivery-info">
<span class="badge" :class="deliveryStatusClass(delivery)">
<span class="badge__dot"></span>
{{ deliveryStatusLabel(delivery) }}
</span>
<span class="clients__item-meta">
{{ eventLabel(delivery.event) }} · {{ formatDate(delivery.created_at) }} ·
{{ t('clients.attempts', { count: delivery.attempts }) }}
</span>
</div>
<button
v-if="!delivery.delivered_at"
@click="retryDelivery(client, webhook, delivery)"
:disabled="retryingDeliveryId === delivery.id"
class="link"
>
{{ retryingDeliveryId === delivery.id ? t('clients.retrying') : t('clients.retryDelivery') }}
</button>
</li>
</ul>
<div v-if="deliveries.length" class="clients__delivery-pager">
<button class="link" :disabled="deliveriesPage <= 1" @click="goToDeliveriesPage(client, webhook, deliveriesPage - 1)">
{{ t('auditLog.prevPage') }}
</button>
<span class="clients__item-meta">{{ deliveriesPage }} / {{ deliveriesLastPage }}</span>
<button class="link" :disabled="deliveriesPage >= deliveriesLastPage" @click="goToDeliveriesPage(client, webhook, deliveriesPage + 1)">
{{ t('auditLog.nextPage') }}
</button>
</div>
</li>
</template>
</ul>
<p v-else class="clients__item-meta">{{ t('clients.noWebhooks') }}</p>
<form class="clients__webhook-form" @submit.prevent="createWebhook(client)">
<div class="field">
<label class="field__label">{{ t('clients.webhookUrl') }}</label>
<input v-model="webhookForm.url" type="url" required placeholder="https://mi-app.com/webhooks/iam" class="field__input" />
<p v-if="webhookErrors.url" class="field__error">{{ webhookErrors.url[0] }}</p>
</div>
<div class="field__checkbox-row" v-for="event in WEBHOOK_EVENTS" :key="event">
<input :id="`event-${client.id}-${event}`" type="checkbox" :value="event" v-model="webhookForm.events" class="field__checkbox" />
<label :for="`event-${client.id}-${event}`">{{ eventLabel(event) }}</label>
</div>
<p v-if="webhookErrors.events" class="field__error">{{ webhookErrors.events[0] }}</p>
<button type="submit" :disabled="webhookCreating" class="btn btn--dark">
{{ webhookCreating ? t('common.creating') : t('clients.addWebhook') }}
</button>
</form>
</div>
</template>
</div>
</div>
</template>
@@ -170,12 +415,99 @@ onMounted(load);
justify-content: space-between;
}
.clients__item-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.clients__item-meta {
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.clients__webhooks {
margin-top: -0.5rem;
}
.clients__webhook-list {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.clients__webhook-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
}
.clients__webhook-url {
font-size: 0.8125rem;
font-weight: 500;
word-break: break-all;
}
.clients__webhook-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.clients__delivery-history {
border: 1px solid var(--color-border);
border-top: none;
border-radius: 0 0 0.5rem 0.5rem;
margin-top: -0.5rem;
padding: 0.75rem;
}
.clients__delivery-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.clients__delivery-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.clients__delivery-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.clients__delivery-pager {
margin-top: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
font-size: 0.8125rem;
}
.clients__webhook-form {
margin-top: 1rem;
border-top: 1px solid var(--color-border);
padding-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
@media (min-width: 640px) {
.clients__form {
grid-template-columns: 1fr 1fr;

View File

@@ -27,6 +27,11 @@ const { t } = useI18n();
<p class="card__subtitle">{{ t('dashboard.appsDesc') }}</p>
</router-link>
<router-link to="/security" class="card card--interactive">
<h2 class="card__title">{{ t('dashboard.securityTitle') }}</h2>
<p class="card__subtitle">{{ t('dashboard.securityDesc') }}</p>
</router-link>
<router-link v-if="auth.isAdmin" to="/admin/users" class="card card--interactive">
<h2 class="card__title">{{ t('dashboard.usersTitle') }}</h2>
<p class="card__subtitle">{{ t('dashboard.usersDesc') }}</p>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import AppLogo from '../components/AppLogo.vue';
const { t } = useI18n();
const form = reactive({ email: '' });
const errors = ref<Record<string, string[]>>({});
const loading = ref(false);
const sent = ref(false);
async function submit(): Promise<void> {
loading.value = true;
errors.value = {};
try {
await axios.post('/web-api/forgot-password', form);
sent.value = true;
} catch (e: any) {
errors.value = e.response?.data?.errors ?? {};
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth-page">
<div class="auth-page__glow"></div>
<div class="auth-card">
<AppLogo class="auth-card__logo" />
<template v-if="!sent">
<h1 class="auth-card__title">{{ t('forgotPassword.title') }}</h1>
<p class="auth-card__subtitle">{{ t('forgotPassword.subtitle') }}</p>
<form class="auth-card__form" @submit.prevent="submit">
<div class="field">
<label class="field__label">{{ t('forgotPassword.email') }}</label>
<input v-model="form.email" type="email" required autofocus class="field__input" />
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
</div>
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
{{ loading ? t('forgotPassword.submitting') : t('forgotPassword.submit') }}
</button>
</form>
</template>
<template v-else>
<h1 class="auth-card__title">{{ t('forgotPassword.title') }}</h1>
<p class="auth-card__subtitle">{{ t('forgotPassword.sent') }}</p>
</template>
<p class="auth-card__footer">
<router-link to="/login" class="link">{{ t('forgotPassword.backToLogin') }}</router-link>
</p>
</div>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import AppLogo from '../components/AppLogo.vue';
const auth = useAuthStore();
const route = useRoute();
@@ -10,28 +11,59 @@ const router = useRouter();
const { t } = useI18n();
const form = reactive({ email: '', password: '' });
const code = ref('');
const errors = ref<Record<string, string[]>>({});
const loading = ref(false);
const step = ref<'credentials' | 'two_factor'>('credentials');
function redirectAfterLogin(intended: string | null): void {
if (intended) {
window.location.href = intended;
return;
}
router.push((route.query.redirect as string) || { name: 'dashboard' });
}
async function submit(): Promise<void> {
loading.value = true;
errors.value = {};
try {
const intended = await auth.login(form);
const result = await auth.login(form);
if (intended) {
window.location.href = intended;
if (result.twoFactor) {
step.value = 'two_factor';
return;
}
router.push((route.query.redirect as string) || { name: 'dashboard' });
redirectAfterLogin(result.intended);
} catch (e: any) {
errors.value = e.response?.data?.errors ?? { email: [t('login.invalidCredentials')] };
} finally {
loading.value = false;
}
}
async function submitTwoFactor(): Promise<void> {
loading.value = true;
errors.value = {};
try {
const intended = await auth.loginTwoFactor(code.value);
redirectAfterLogin(intended);
} catch (e: any) {
errors.value = e.response?.data?.errors ?? { code: [t('login.invalidTwoFactorCode')] };
} finally {
loading.value = false;
}
}
function backToCredentials(): void {
step.value = 'credentials';
code.value = '';
errors.value = {};
}
</script>
<template>
@@ -39,31 +71,56 @@ async function submit(): Promise<void> {
<div class="auth-page__glow"></div>
<div class="auth-card">
<span class="auth-card__logo">O2</span>
<h1 class="auth-card__title">{{ t('login.title') }}</h1>
<p class="auth-card__subtitle">{{ t('login.subtitle') }}</p>
<AppLogo class="auth-card__logo" />
<form class="auth-card__form" @submit.prevent="submit">
<div class="field">
<label class="field__label">{{ t('login.email') }}</label>
<input v-model="form.email" type="email" required class="field__input" />
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
</div>
<template v-if="step === 'credentials'">
<h1 class="auth-card__title">{{ t('login.title') }}</h1>
<p class="auth-card__subtitle">{{ t('login.subtitle') }}</p>
<div class="field">
<label class="field__label">{{ t('login.password') }}</label>
<input v-model="form.password" type="password" required class="field__input" />
</div>
<form class="auth-card__form" @submit.prevent="submit">
<div class="field">
<label class="field__label">{{ t('login.email') }}</label>
<input v-model="form.email" type="email" required class="field__input" />
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
</div>
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
{{ loading ? t('login.submitting') : t('login.submit') }}
</button>
</form>
<div class="field">
<label class="field__label">{{ t('login.password') }}</label>
<input v-model="form.password" type="password" required class="field__input" />
<router-link to="/forgot-password" class="field__hint link">{{ t('login.forgotPassword') }}</router-link>
</div>
<p class="auth-card__footer">
{{ t('login.noAccount') }}
<router-link to="/register" class="link">{{ t('login.signUp') }}</router-link>
</p>
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
{{ loading ? t('login.submitting') : t('login.submit') }}
</button>
</form>
<p class="auth-card__footer">
{{ t('login.noAccount') }}
<router-link to="/register" class="link">{{ t('login.signUp') }}</router-link>
</p>
</template>
<template v-else>
<h1 class="auth-card__title">{{ t('login.twoFactorTitle') }}</h1>
<p class="auth-card__subtitle">{{ t('login.twoFactorSubtitle') }}</p>
<form class="auth-card__form" @submit.prevent="submitTwoFactor">
<div class="field">
<label class="field__label">{{ t('login.twoFactorCode') }}</label>
<input v-model="code" required autofocus autocomplete="one-time-code" class="field__input" />
<p v-if="errors.code" class="field__error">{{ errors.code[0] }}</p>
</div>
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
{{ loading ? t('login.submitting') : t('login.twoFactorSubmit') }}
</button>
</form>
<p class="auth-card__footer">
<button type="button" @click="backToCredentials" class="link">{{ t('login.backToLogin') }}</button>
</p>
</template>
</div>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import AppLogo from '../components/AppLogo.vue';
const auth = useAuthStore();
const router = useRouter();
@@ -49,7 +50,7 @@ async function submit(): Promise<void> {
<div class="auth-card">
<template v-if="registered">
<span class="auth-card__logo">O2</span>
<AppLogo class="auth-card__logo" />
<h1 class="auth-card__title">{{ t('register.pendingTitle') }}</h1>
<p class="auth-card__subtitle">{{ t('register.pendingBody') }}</p>
@@ -59,7 +60,7 @@ async function submit(): Promise<void> {
</template>
<template v-else>
<span class="auth-card__logo">O2</span>
<AppLogo class="auth-card__logo" />
<h1 class="auth-card__title">{{ t('register.title') }}</h1>
<p v-if="isFirstUser" class="auth-card__banner">{{ t('register.firstUserBanner') }}</p>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import axios from 'axios';
import AppLogo from '../components/AppLogo.vue';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const token = String(route.query.token ?? '');
const email = String(route.query.email ?? '');
const missingParams = !token || !email;
const form = reactive({ password: '', password_confirmation: '' });
const errors = ref<Record<string, string[]>>({});
const loading = ref(false);
const success = ref(false);
async function submit(): Promise<void> {
loading.value = true;
errors.value = {};
try {
await axios.post('/web-api/reset-password', { ...form, token, email });
success.value = true;
} catch (e: any) {
errors.value = e.response?.data?.errors ?? {};
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="auth-page">
<div class="auth-page__glow"></div>
<div class="auth-card">
<AppLogo class="auth-card__logo" />
<template v-if="missingParams">
<h1 class="auth-card__title">{{ t('resetPassword.title') }}</h1>
<p class="field__error">{{ t('resetPassword.missingParams') }}</p>
</template>
<template v-else-if="!success">
<h1 class="auth-card__title">{{ t('resetPassword.title') }}</h1>
<p class="auth-card__subtitle">{{ t('resetPassword.subtitle') }}</p>
<p v-if="errors.email" class="field__error">{{ errors.email[0] }}</p>
<form class="auth-card__form" @submit.prevent="submit">
<div class="field">
<label class="field__label">{{ t('resetPassword.password') }}</label>
<input v-model="form.password" type="password" required minlength="8" autofocus class="field__input" />
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
</div>
<div class="field">
<label class="field__label">{{ t('resetPassword.passwordConfirm') }}</label>
<input v-model="form.password_confirmation" type="password" required minlength="8" class="field__input" />
</div>
<button type="submit" :disabled="loading" class="btn btn--primary btn--pill btn--full">
{{ loading ? t('resetPassword.submitting') : t('resetPassword.submit') }}
</button>
</form>
</template>
<template v-else>
<h1 class="auth-card__title">{{ t('resetPassword.title') }}</h1>
<p class="auth-card__subtitle">{{ t('resetPassword.success') }}</p>
<button type="button" class="btn btn--primary btn--pill btn--full" @click="router.push({ name: 'login' })">
{{ t('resetPassword.goToLogin') }}
</button>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,259 @@
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
import QRCode from 'qrcode';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
const auth = useAuthStore();
const { t } = useI18n();
const loading = ref(false);
const errors = ref<Record<string, string[]>>({});
const settingUp = ref(false);
const otpauthUrl = ref('');
const secret = ref('');
const qrDataUrl = ref('');
const confirmCode = ref('');
const recoveryCodes = ref<string[] | null>(null);
const showDisableForm = ref(false);
const showRegenerateForm = ref(false);
const password = ref('');
async function startSetup(): Promise<void> {
loading.value = true;
errors.value = {};
try {
const { data } = await axios.post<{ secret: string; otpauth_url: string }>('/web-api/2fa/setup');
secret.value = data.secret;
otpauthUrl.value = data.otpauth_url;
qrDataUrl.value = await QRCode.toDataURL(data.otpauth_url);
settingUp.value = true;
} catch (e: any) {
alert(e.response?.data?.message ?? t('security.setupFailed'));
} finally {
loading.value = false;
}
}
async function confirmSetup(): Promise<void> {
loading.value = true;
errors.value = {};
try {
const { data } = await axios.post<{ recovery_codes: string[] }>('/web-api/2fa/confirm', { code: confirmCode.value });
recoveryCodes.value = data.recovery_codes;
cancelSetup();
await auth.fetchUser();
} catch (e: any) {
errors.value = e.response?.data?.errors ?? { code: [t('security.invalidCode')] };
} finally {
loading.value = false;
}
}
function cancelSetup(): void {
settingUp.value = false;
secret.value = '';
otpauthUrl.value = '';
qrDataUrl.value = '';
confirmCode.value = '';
errors.value = {};
}
async function disable(): Promise<void> {
loading.value = true;
errors.value = {};
try {
await axios.post('/web-api/2fa/disable', { password: password.value });
password.value = '';
showDisableForm.value = false;
recoveryCodes.value = null;
await auth.fetchUser();
} catch (e: any) {
errors.value = e.response?.data?.errors ?? { password: [t('security.invalidPassword')] };
} finally {
loading.value = false;
}
}
async function regenerateRecoveryCodes(): Promise<void> {
loading.value = true;
errors.value = {};
try {
const { data } = await axios.post<{ recovery_codes: string[] }>('/web-api/2fa/recovery-codes', { password: password.value });
recoveryCodes.value = data.recovery_codes;
password.value = '';
showRegenerateForm.value = false;
} catch (e: any) {
errors.value = e.response?.data?.errors ?? { password: [t('security.invalidPassword')] };
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="security">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('security.title') }}</h1>
<p class="page-subtitle">{{ t('security.subtitle') }}</p>
</div>
</div>
<div v-if="recoveryCodes" class="banner banner--warning">
<p class="banner__title">{{ t('security.recoveryCodesTitle') }}</p>
<p class="banner__line">{{ t('security.recoveryCodesWarning') }}</p>
<ul class="security__codes">
<li v-for="rc in recoveryCodes" :key="rc"><code class="banner__code">{{ rc }}</code></li>
</ul>
<button class="banner__close" @click="recoveryCodes = null">{{ t('common.close') }}</button>
</div>
<div v-if="auth.user?.two_factor_enabled && !settingUp" class="card security__card">
<p class="security__status security__status--on">{{ t('security.enabled') }}</p>
<p class="security__desc">{{ t('security.enabledDesc') }}</p>
<div class="security__actions">
<button
@click="showRegenerateForm = !showRegenerateForm; showDisableForm = false; errors = {}"
type="button"
class="btn btn--secondary"
>
{{ t('security.regenerateCodes') }}
</button>
<button
@click="showDisableForm = !showDisableForm; showRegenerateForm = false; errors = {}"
type="button"
class="btn btn--secondary"
>
{{ t('security.disable') }}
</button>
</div>
<form v-if="showRegenerateForm" class="security__password-form" @submit.prevent="regenerateRecoveryCodes">
<div class="field">
<label class="field__label">{{ t('security.confirmPassword') }}</label>
<input v-model="password" type="password" required class="field__input" />
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
</div>
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.confirm') }}</button>
</form>
<form v-if="showDisableForm" class="security__password-form" @submit.prevent="disable">
<div class="field">
<label class="field__label">{{ t('security.confirmPassword') }}</label>
<input v-model="password" type="password" required class="field__input" />
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
</div>
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.disableConfirm') }}</button>
</form>
</div>
<div v-else-if="!settingUp" class="card security__card">
<p class="security__status security__status--off">{{ t('security.disabled') }}</p>
<p class="security__desc">{{ t('security.disabledDesc') }}</p>
<button @click="startSetup" :disabled="loading" type="button" class="btn btn--primary">{{ t('security.enable') }}</button>
</div>
<div v-else class="card security__card">
<h3 class="card__title">{{ t('security.setupTitle') }}</h3>
<p class="security__desc">{{ t('security.setupDesc') }}</p>
<img :src="qrDataUrl" :alt="t('security.qrAlt')" class="security__qr" />
<p class="security__manual">
{{ t('security.manualEntry') }} <code class="banner__code">{{ secret }}</code>
</p>
<form class="security__confirm-form" @submit.prevent="confirmSetup">
<div class="field">
<label class="field__label">{{ t('security.enterCode') }}</label>
<input v-model="confirmCode" required autofocus autocomplete="one-time-code" class="field__input" />
<p v-if="errors.code" class="field__error">{{ errors.code[0] }}</p>
</div>
<div class="security__actions">
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.confirm') }}</button>
<button type="button" @click="cancelSetup" class="btn btn--secondary">{{ t('common.cancel') }}</button>
</div>
</form>
</div>
</div>
</template>
<style scoped>
.security__card {
margin-top: 1.5rem;
max-width: 32rem;
}
.security__status {
font-weight: 600;
}
.security__status--on {
color: var(--color-accent-strong);
}
.security__status--off {
color: var(--color-text-secondary);
}
.security__desc {
margin-top: 0.25rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.security__actions {
margin-top: 1rem;
display: flex;
gap: 0.75rem;
}
.security__password-form {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.security__qr {
margin-top: 1rem;
width: 200px;
height: 200px;
border-radius: 0.5rem;
border: 1px solid var(--color-border);
}
.security__manual {
margin-top: 0.75rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.security__confirm-form {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
align-items: flex-start;
}
.security__codes {
margin-top: 0.5rem;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.375rem;
list-style: none;
padding: 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import StatTile from '../../components/charts/StatTile.vue';
@@ -26,10 +26,28 @@ onMounted(load);
const appItems = computed(() => (summary.value?.logins_by_app ?? []).map((a) => ({ label: a.client_name, value: a.count })));
const userItems = computed(() => (summary.value?.top_users ?? []).map((u) => ({ label: u.name, value: u.count })));
const activeTokenItems = computed(() => (summary.value?.active_tokens_by_client ?? []).map((c) => ({ label: c.client_name, value: c.count })));
const scopeItems = computed(() => (summary.value?.scope_usage ?? []).map((s) => ({ label: s.description, value: s.count })));
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
async function exportPdf(): Promise<void> {
const tableWasHidden = !showTable.value;
if (tableWasHidden) showTable.value = true;
await nextTick();
// Fuerza tema claro para la impresión (ahorra tinta, evita fondos oscuros
// recortados) y lo restaura después de que el usuario cierre el diálogo.
const wasDark = document.documentElement.classList.contains('dark');
document.documentElement.classList.remove('dark');
window.print();
if (wasDark) document.documentElement.classList.add('dark');
if (tableWasHidden) showTable.value = false;
}
</script>
<template>
@@ -40,12 +58,18 @@ function formatDateTime(iso: string): string {
<p class="page-subtitle">{{ t('analytics.subtitle') }}</p>
</div>
<!-- filtro de rango de fechas: una fila, arriba de todo, escopa todo lo de abajo -->
<select v-model.number="days" class="pill-select">
<option :value="7">{{ t('analytics.last7') }}</option>
<option :value="30">{{ t('analytics.last30') }}</option>
<option :value="90">{{ t('analytics.last90') }}</option>
</select>
<div class="analytics__header-controls no-print">
<!-- filtro de rango de fechas: una fila, arriba de todo, escopa todo lo de abajo -->
<select v-model.number="days" class="pill-select">
<option :value="7">{{ t('analytics.last7') }}</option>
<option :value="30">{{ t('analytics.last30') }}</option>
<option :value="90">{{ t('analytics.last90') }}</option>
</select>
<button v-if="summary" @click="exportPdf" type="button" class="btn btn--secondary">
{{ t('analytics.exportPdf') }}
</button>
</div>
</div>
<p v-if="loading && !summary" class="analytics__loading">{{ t('common.loading') }}</p>
@@ -56,6 +80,16 @@ function formatDateTime(iso: string): string {
<StatTile :label="t('analytics.activeSessions')" :value="summary.kpis.active_sessions" :hint="t('analytics.activeSessionsHint')" />
<StatTile :label="t('analytics.loginsToday')" :value="summary.kpis.logins_today" />
<StatTile :label="t('analytics.logins7d')" :value="summary.kpis.logins_7d" />
<StatTile
:label="t('analytics.loginFailureRate')"
:value="`${summary.kpis.login_failure_rate_7d}%`"
:hint="t('analytics.loginFailureRateHint', { count: summary.kpis.login_failures_7d })"
/>
<StatTile
:label="t('analytics.oauthApprovalRate')"
:value="`${summary.oauth_funnel.approval_rate}%`"
:hint="t('analytics.oauthApprovalRateHint', { approved: summary.oauth_funnel.approved, attempted: summary.oauth_funnel.attempted })"
/>
</div>
<div class="analytics__chart" :class="{ 'analytics__section--loading': loading }">
@@ -67,10 +101,43 @@ function formatDateTime(iso: string): string {
<BarList :title="t('analytics.topUsers')" :items="userItems" :empty-text="t('analytics.noUserActivity')" />
</div>
<div class="card analytics__funnel" :class="{ 'analytics__section--loading': loading }">
<h3 class="card__title">{{ t('analytics.funnelTitle') }}</h3>
<p v-if="!summary.oauth_funnel_by_client.length" class="analytics__recent-count">{{ t('analytics.funnelEmpty') }}</p>
<div v-else class="analytics__table-wrap">
<table class="table">
<thead>
<tr>
<th class="table__head-cell">{{ t('analytics.colClient') }}</th>
<th class="table__head-cell">{{ t('analytics.colAttempted') }}</th>
<th class="table__head-cell">{{ t('analytics.colApproved') }}</th>
<th class="table__head-cell">{{ t('analytics.colDenied') }}</th>
<th class="table__head-cell">{{ t('analytics.colApprovalRate') }}</th>
</tr>
</thead>
<tbody class="table__body">
<tr v-for="row in summary.oauth_funnel_by_client" :key="row.client_name">
<td class="table__cell table__cell--primary">{{ row.client_name }}</td>
<td class="table__cell">{{ row.attempted }}</td>
<td class="table__cell">{{ row.approved }}</td>
<td class="table__cell">{{ row.denied }}</td>
<td class="table__cell">{{ row.attempted ? Math.round((row.approved / row.attempted) * 100) : 0 }}%</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="analytics__bar-lists" :class="{ 'analytics__section--loading': loading }">
<BarList :title="t('analytics.activeTokensByClient')" :items="activeTokenItems" :empty-text="t('analytics.noActiveTokens')" />
<BarList :title="t('analytics.scopeUsageTitle')" :items="scopeItems" :empty-text="t('analytics.noScopeUsage')" />
</div>
<div class="card analytics__recent" :class="{ 'analytics__section--loading': loading }">
<div class="analytics__recent-header">
<h3 class="card__title">{{ t('analytics.recentActivity') }}</h3>
<button @click="showTable = !showTable" class="link analytics__toggle">
<button @click="showTable = !showTable" class="link analytics__toggle no-print">
{{ showTable ? t('analytics.hide') : t('analytics.viewAll') }}
</button>
</div>
@@ -109,6 +176,12 @@ function formatDateTime(iso: string): string {
--series-2: #eb6834;
}
.analytics__header-controls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.analytics__loading {
margin-top: 1.5rem;
color: var(--color-text-secondary);
@@ -132,6 +205,10 @@ function formatDateTime(iso: string): string {
gap: 1rem;
}
.analytics__funnel {
margin-top: 1rem;
}
.analytics__recent {
margin-top: 1rem;
}

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import type { AuditLogEntry, Paginated } from '../../types/models';
const { t, locale } = useI18n();
const loading = ref(true);
const entries = ref<AuditLogEntry[]>([]);
const page = ref(1);
const lastPage = ref(1);
const total = ref(0);
const subjectType = ref<'' | 'user' | 'client'>('');
const action = ref('');
const actionOptions = [
'user.created',
'user.updated',
'user.deleted',
'user.restored',
'user.force_deleted',
'user.approved',
'user.app_role_assigned',
'user.app_role_revoked',
'client.created',
'client.revoked',
'user.2fa_enabled',
'user.2fa_disabled',
'user.2fa_recovery_regenerated',
'user.session_revoked',
'user.sessions_revoked_all',
'client.webhook_created',
'client.webhook_deleted',
'client.secret_rotated',
'user.password_reset',
];
const actionLabelKeys: Record<string, string> = {
'user.created': 'auditLog.actions.userCreated',
'user.updated': 'auditLog.actions.userUpdated',
'user.deleted': 'auditLog.actions.userDeleted',
'user.restored': 'auditLog.actions.userRestored',
'user.force_deleted': 'auditLog.actions.userForceDeleted',
'user.approved': 'auditLog.actions.userApproved',
'user.app_role_assigned': 'auditLog.actions.appRoleAssigned',
'user.app_role_revoked': 'auditLog.actions.appRoleRevoked',
'client.created': 'auditLog.actions.clientCreated',
'client.revoked': 'auditLog.actions.clientRevoked',
'user.2fa_enabled': 'auditLog.actions.twoFactorEnabled',
'user.2fa_disabled': 'auditLog.actions.twoFactorDisabled',
'user.2fa_recovery_regenerated': 'auditLog.actions.twoFactorRecoveryRegenerated',
'user.session_revoked': 'auditLog.actions.sessionRevoked',
'user.sessions_revoked_all': 'auditLog.actions.sessionsRevokedAll',
'client.webhook_created': 'auditLog.actions.webhookCreated',
'client.webhook_deleted': 'auditLog.actions.webhookDeleted',
'client.secret_rotated': 'auditLog.actions.secretRotated',
'user.password_reset': 'auditLog.actions.passwordReset',
};
function actionLabel(value: string): string {
const key = actionLabelKeys[value];
return key ? t(key) : value;
}
async function load(): Promise<void> {
loading.value = true;
const { data } = await axios.get<Paginated<AuditLogEntry>>('/web-api/admin/audit-log', {
params: {
page: page.value,
...(subjectType.value ? { subject_type: subjectType.value } : {}),
...(action.value ? { action: action.value } : {}),
},
});
entries.value = data.data;
lastPage.value = data.last_page;
total.value = data.total;
loading.value = false;
}
function goToPage(next: number): void {
if (next < 1 || next > lastPage.value) return;
page.value = next;
}
watch([subjectType, action], () => {
page.value = 1;
load();
});
watch(page, load);
onMounted(load);
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(locale.value, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function formatMetadata(metadata: Record<string, unknown> | null): string {
if (!metadata || !Object.keys(metadata).length) return '—';
return Object.entries(metadata)
.map(([key, value]) => {
if (value && typeof value === 'object' && 'from' in value && 'to' in value) {
const { from, to } = value as { from: unknown; to: unknown };
return `${key}: ${from ?? '—'} → ${to ?? '—'}`;
}
if (value && typeof value === 'object' && 'changed' in value) {
return t('auditLog.fieldChanged', { field: key });
}
return `${key}: ${value}`;
})
.join(' · ');
}
</script>
<template>
<div class="audit-log">
<div class="page-header">
<div>
<h1 class="page-title">{{ t('auditLog.title') }}</h1>
<p class="page-subtitle">{{ t('auditLog.subtitle') }}</p>
</div>
</div>
<div class="audit-log__filters">
<select v-model="subjectType" class="pill-select">
<option value="">{{ t('auditLog.allSubjects') }}</option>
<option value="user">{{ t('auditLog.subjectUser') }}</option>
<option value="client">{{ t('auditLog.subjectClient') }}</option>
</select>
<select v-model="action" class="pill-select">
<option value="">{{ t('auditLog.allActions') }}</option>
<option v-for="a in actionOptions" :key="a" :value="a">{{ actionLabel(a) }}</option>
</select>
</div>
<div class="table-card audit-log__table-card">
<p v-if="loading" class="table__empty">{{ t('common.loading') }}</p>
<p v-else-if="!entries.length" class="table__empty">{{ t('auditLog.empty') }}</p>
<table v-else class="table">
<thead class="table__head">
<tr>
<th class="table__head-cell">{{ t('auditLog.colWhen') }}</th>
<th class="table__head-cell">{{ t('auditLog.colActor') }}</th>
<th class="table__head-cell">{{ t('auditLog.colAction') }}</th>
<th class="table__head-cell">{{ t('auditLog.colSubject') }}</th>
<th class="table__head-cell">{{ t('auditLog.colDetails') }}</th>
<th class="table__head-cell">{{ t('auditLog.colIp') }}</th>
</tr>
</thead>
<tbody class="table__body">
<tr v-for="entry in entries" :key="entry.id">
<td class="table__cell audit-log__when">{{ formatDateTime(entry.created_at) }}</td>
<td class="table__cell">{{ entry.actor_name ?? t('auditLog.systemActor') }}</td>
<td class="table__cell table__cell--primary">{{ actionLabel(entry.action) }}</td>
<td class="table__cell">{{ entry.subject_label ?? entry.subject_id }}</td>
<td class="table__cell audit-log__details">{{ formatMetadata(entry.metadata) }}</td>
<td class="table__cell">{{ entry.ip_address }}</td>
</tr>
</tbody>
</table>
<div v-if="!loading && entries.length" class="audit-log__pager">
<button class="link" :disabled="page <= 1" @click="goToPage(page - 1)">{{ t('auditLog.prevPage') }}</button>
<span class="audit-log__pager-info">{{ t('auditLog.pageInfo', { page, lastPage, total }) }}</span>
<button class="link" :disabled="page >= lastPage" @click="goToPage(page + 1)">{{ t('auditLog.nextPage') }}</button>
</div>
</div>
</div>
</template>
<style scoped>
.audit-log__filters {
margin-top: 1rem;
display: flex;
gap: 0.75rem;
}
.audit-log__table-card {
margin-top: 1rem;
}
.audit-log__when {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.audit-log__details {
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.audit-log__pager {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
font-size: 0.8125rem;
}
.audit-log__pager button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.audit-log__pager-info {
color: var(--color-text-secondary);
}
</style>

View File

@@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../../stores/auth';
import type { AppRoleGroup, OAuthApp, User } from '../../types/models';
import type { AppRoleGroup, OAuthApp, User, UserSession } from '../../types/models';
type View = 'active' | 'pending' | 'trashed';
@@ -16,6 +16,7 @@ const trashedUsers = ref<User[]>([]);
const view = ref<View>('active');
const apps = ref<OAuthApp[]>([]);
const appRoles = ref<AppRoleGroup[]>([]);
const sessions = ref<UserSession[]>([]);
const loading = ref(true);
const creating = ref(false);
const editingId = ref<number | null>(null);
@@ -133,6 +134,25 @@ async function removeAppRole(user: User, clientId: string, role: string): Promis
await loadAppRoles(user);
}
async function loadSessions(user: User): Promise<void> {
const { data } = await axios.get<UserSession[]>(`/web-api/admin/users/${user.id}/sessions`);
sessions.value = data;
}
async function revokeSession(user: User, session: UserSession): Promise<void> {
if (!confirm(t('adminUsers.revokeSessionConfirm', { name: session.client_name ?? session.name ?? t('adminUsers.sessionUnknownClient') }))) return;
await axios.delete(`/web-api/admin/users/${user.id}/sessions/${session.id}`);
await loadSessions(user);
}
async function revokeAllSessions(user: User): Promise<void> {
if (!confirm(t('adminUsers.revokeAllSessionsConfirm', { name: user.name }))) return;
await axios.delete(`/web-api/admin/users/${user.id}/sessions`);
await loadSessions(user);
}
async function create(): Promise<void> {
creating.value = true;
errors.value = {};
@@ -160,7 +180,9 @@ function startEdit(user: User): void {
editForm.is_active = user.is_active;
errors.value = {};
appRoles.value = [];
sessions.value = [];
loadAppRoles(user);
loadSessions(user);
}
function cancelEdit(): void {
@@ -357,6 +379,31 @@ onMounted(load);
</button>
</div>
</div>
<div class="users__app-roles">
<div class="users__sessions-header">
<h3 class="users__app-roles-title">{{ t('adminUsers.sessionsTitle') }}</h3>
<button v-if="sessions.length" @click="revokeAllSessions(user)" class="link link--danger">
{{ t('adminUsers.revokeAllSessions') }}
</button>
</div>
<p class="users__app-roles-desc">{{ t('adminUsers.sessionsDesc') }}</p>
<div v-if="!sessions.length" class="users__app-roles-empty">{{ t('adminUsers.noSessions') }}</div>
<ul v-else class="users__sessions-list">
<li v-for="session in sessions" :key="session.id" class="users__session-row">
<div>
<span class="users__session-client">{{ session.client_name ?? t('adminUsers.sessionUnknownClient') }}</span>
<span class="users__session-meta">
{{ session.scopes?.join(', ') || t('common.none') }} · {{ formatDate(session.created_at) }}
</span>
</div>
<button @click="revokeSession(user, session)" class="link link--danger">
{{ t('adminUsers.revokeSession') }}
</button>
</li>
</ul>
</div>
</td>
</tr>
</template>
@@ -553,6 +600,41 @@ onMounted(load);
color: var(--color-text-muted);
}
.users__sessions-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.users__sessions-list {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.users__session-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
}
.users__session-client {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-primary);
}
.users__session-meta {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
}
@media (min-width: 640px) {
.users__form {
grid-template-columns: 1fr 1fr;

View File

@@ -28,6 +28,18 @@ const routes = [
component: () => import('../pages/Register.vue'),
meta: { guestOnly: true },
},
{
path: '/forgot-password',
name: 'forgot-password',
component: () => import('../pages/ForgotPassword.vue'),
meta: { guestOnly: true },
},
{
path: '/reset-password',
name: 'reset-password',
component: () => import('../pages/ResetPassword.vue'),
meta: { guestOnly: true },
},
{
path: '/clients',
name: 'clients',
@@ -46,6 +58,12 @@ const routes = [
component: () => import('../pages/AuthorizedApps.vue'),
meta: { requiresAuth: true },
},
{
path: '/security',
name: 'security',
component: () => import('../pages/Security.vue'),
meta: { requiresAuth: true },
},
{
path: '/oauth/authorize',
name: 'oauth-authorize',
@@ -64,6 +82,12 @@ const routes = [
component: () => import('../pages/admin/Analytics.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/audit-log',
name: 'admin-audit-log',
component: () => import('../pages/admin/AuditLog.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
];
const router = createRouter({

View File

@@ -48,8 +48,21 @@ export const useAuthStore = defineStore('auth', {
}
},
async login(payload: Credentials): Promise<string | null> {
const { data } = await axios.post<{ user: User; intended: string | null }>('/web-api/login', payload);
async login(payload: Credentials): Promise<{ twoFactor: true } | { twoFactor: false; intended: string | null }> {
const { data } = await axios.post<{ two_factor?: true; user?: User; intended?: string | null }>('/web-api/login', payload);
if (data.two_factor) {
return { twoFactor: true };
}
this.user = data.user!;
this.checked = true;
return { twoFactor: false, intended: data.intended ?? null };
},
async loginTwoFactor(code: string): Promise<string | null> {
const { data } = await axios.post<{ user: User; intended: string | null }>('/web-api/login/2fa', { code });
this.user = data.user;
this.checked = true;

View File

@@ -8,6 +8,7 @@ export interface User {
created_at: string;
deleted_at?: string | null;
approved_at?: string | null;
two_factor_enabled?: boolean;
}
export interface OAuthClient {
@@ -24,6 +25,31 @@ export interface OAuthClient {
updated_at: string;
}
export interface WebhookEndpoint {
id: number;
client_id: string;
url: string;
events: string[];
active: boolean;
created_at: string;
last_delivery: {
event: string;
response_status: number | null;
delivered_at: string | null;
created_at: string;
} | null;
}
export interface WebhookDelivery {
id: number;
event: string;
response_status: number | null;
response_body: string | null;
attempts: number;
delivered_at: string | null;
created_at: string;
}
export interface PersonalAccessToken {
id: string;
name: string;
@@ -55,6 +81,16 @@ export interface OAuthApp {
name: string;
}
export interface UserSession {
id: string;
client_name: string | null;
client_revoked: boolean;
name: string | null;
scopes: string[];
created_at: string;
expires_at: string | null;
}
export interface AppRoleGroup {
client_id: string;
client_name: string;
@@ -67,6 +103,8 @@ export interface AnalyticsSummary {
active_sessions: number;
logins_today: number;
logins_7d: number;
login_failures_7d: number;
login_failure_rate_7d: number;
};
logins_by_day: Array<{ date: string; session: number; oauth: number }>;
logins_by_app: Array<{ client_name: string; count: number }>;
@@ -79,6 +117,36 @@ export interface AnalyticsSummary {
ip_address: string | null;
created_at: string;
}>;
oauth_funnel: {
attempted: number;
approved: number;
denied: number;
approval_rate: number;
};
oauth_funnel_by_client: Array<{ client_name: string; attempted: number; approved: number; denied: number }>;
active_tokens_by_client: Array<{ client_name: string; count: number }>;
scope_usage: Array<{ scope: string; description: string; count: number }>;
}
export interface AuditLogEntry {
id: number;
actor_name: string | null;
actor_email: string | null;
action: string;
subject_type: 'user' | 'client';
subject_id: string;
subject_label: string | null;
metadata: Record<string, unknown> | null;
ip_address: string | null;
created_at: string;
}
export interface Paginated<T> {
data: T[];
current_page: number;
last_page: number;
total: number;
per_page: number;
}
export interface OAuthAuthorizePayload {

View File

@@ -1,6 +1,7 @@
@use 'base/tokens';
@use 'base/reset';
@use 'base/typography';
@use 'base/print';
@use 'components/page-header';
@use 'components/button';

View File

@@ -0,0 +1,29 @@
@page {
margin: 15mm;
}
@media print {
.no-print {
display: none !important;
}
// Fuerza tema claro al imprimir (el oscuro desperdicia tinta y el
// navegador ya arranca la impresión con .dark sacado del <html>, pero
// esto cubre navegadores que no respeten ese cambio a tiempo).
:root {
color-scheme: light;
}
body {
background: #fff !important;
color: #000 !important;
}
.card,
.table-card,
.stat-tile,
table,
tr {
break-inside: avoid;
}
}

View File

@@ -18,16 +18,16 @@
max-width: $page-max-width;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-4;
flex-direction: column;
gap: $space-2;
padding: $space-3 $space-6;
}
.app-nav__section {
.app-nav__top {
display: flex;
align-items: center;
gap: $space-6;
justify-content: space-between;
gap: $space-4;
}
.app-nav__brand {
@@ -37,15 +37,9 @@
}
.app-nav__logo {
display: grid;
place-items: center;
height: 1.75rem;
width: 1.75rem;
border-radius: $radius-md;
background: var(--gradient-accent);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
width: auto;
object-fit: contain;
}
.app-nav__brand-name {
@@ -57,6 +51,7 @@
.app-nav__links {
display: none;
align-items: center;
flex-wrap: wrap;
gap: $space-1;
@include respond-up($breakpoint-md) {
@@ -70,6 +65,7 @@
font-size: $font-sm;
font-weight: 500;
color: var(--color-text-secondary);
white-space: nowrap;
transition: $transition-fast;
&:hover {

View File

@@ -40,16 +40,9 @@
}
.auth-card__logo {
display: grid;
place-items: center;
height: 2.25rem;
width: 2.25rem;
border-radius: $radius-lg;
background: var(--gradient-accent);
color: #fff;
font-size: $font-xs;
font-weight: 700;
box-shadow: 0 1px 2px 0 rgb(79 70 229 / 20%);
width: auto;
object-fit: contain;
}
.auth-card__title {

View File

@@ -22,3 +22,7 @@
.badge--inactive {
color: var(--color-text-muted);
}
.badge--danger {
color: var(--color-danger);
}

View File

@@ -14,6 +14,7 @@
}
.field__hint {
display: block;
margin-top: $space-1;
font-size: $font-xs;
color: var(--color-text-muted);

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OAuth2 Server</title>
<link rel="icon" type="image/png" href="/images/favicon.png">
<script>
// Aplica el tema ANTES del primer paint (evita el flash del tema equivocado).
// Prioridad: preferencia guardada > prefers-color-scheme del SO.

View File

@@ -2,10 +2,13 @@
use App\Http\Controllers\Api\AnalyticsController;
use App\Http\Controllers\Api\AppController;
use App\Http\Controllers\Api\AuditLogController;
use App\Http\Controllers\Api\AuthorizedAppController;
use App\Http\Controllers\Api\ClientController;
use App\Http\Controllers\Api\PersonalAccessTokenController;
use App\Http\Controllers\Api\UserController;
use App\Http\Controllers\Api\WebhookController;
use App\Http\Controllers\Api\UserSessionController;
use Illuminate\Support\Facades\Route;
// Usuario del token actual (whoami) + roles.
@@ -24,17 +27,34 @@ Route::get('/users/{user}/app-roles', [UserController::class, 'appRoles']);
Route::post('/users/{user}/app-roles', [UserController::class, 'assignAppRole']);
Route::delete('/users/{user}/app-roles/{clientId}/{role}', [UserController::class, 'revokeAppRole']);
// Sesiones (tokens OAuth activos) de un usuario.
Route::get('/users/{user}/sessions', [UserSessionController::class, 'index']);
Route::delete('/users/{user}/sessions/{tokenId}', [UserSessionController::class, 'destroy']);
Route::delete('/users/{user}/sessions', [UserSessionController::class, 'destroyAll']);
// Apps registradas en el sistema (admin, para el selector de roles-por-app).
Route::get('/apps', [AppController::class, 'index']);
// Analíticas de uso: sesiones, logins por app, actividad reciente.
Route::get('/analytics/summary', [AnalyticsController::class, 'summary']);
// Audit log: altas/bajas de usuarios, cambios de rol y de clientes OAuth.
Route::get('/audit-log', [AuditLogController::class, 'index']);
// Cuenta propia: clientes OAuth, personal access tokens y apps autorizadas.
// Todo esto es lo mismo que ofrece la SPA Vue por sesión (routes/web.php), pero
// vía Bearer token — para que cualquier cliente (Angular, Flutter, etc.) pueda
// construir su propio panel sin depender de la sesión de Laravel.
Route::apiResource('clients', ClientController::class)->except(['show'])->names('api.clients');
Route::post('/clients/{client}/rotate-secret', [ClientController::class, 'rotateSecret']);
// Webhooks salientes de un client propio (token.revoked, user.deactivated).
Route::get('/clients/{client}/webhooks', [WebhookController::class, 'index']);
Route::post('/clients/{client}/webhooks', [WebhookController::class, 'store']);
Route::put('/clients/{client}/webhooks/{webhook}', [WebhookController::class, 'update']);
Route::delete('/clients/{client}/webhooks/{webhook}', [WebhookController::class, 'destroy']);
Route::get('/clients/{client}/webhooks/{webhook}/deliveries', [WebhookController::class, 'deliveries']);
Route::post('/clients/{client}/webhooks/{webhook}/deliveries/{delivery}/retry', [WebhookController::class, 'retryDelivery']);
Route::get('/personal-access-tokens', [PersonalAccessTokenController::class, 'index']);
Route::post('/personal-access-tokens', [PersonalAccessTokenController::class, 'store']);

View File

@@ -2,11 +2,17 @@
use App\Http\Controllers\Admin\AnalyticsController;
use App\Http\Controllers\Admin\AppController;
use App\Http\Controllers\Admin\AuditLogController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\Admin\UserSessionController;
use App\Http\Controllers\Auth\AuthController;
use App\Http\Controllers\Auth\PasswordResetController;
use App\Http\Controllers\Auth\TwoFactorController;
use App\Http\Controllers\OAuth\AuthorizationEventController;
use App\Http\Controllers\OAuth\AuthorizedAppController;
use App\Http\Controllers\OAuth\ClientController;
use App\Http\Controllers\OAuth\PersonalAccessTokenController;
use App\Http\Controllers\OAuth\WebhookController;
use Illuminate\Support\Facades\Route;
use Laravel\Passport\Passport;
@@ -22,15 +28,40 @@ Route::post('/web-api/locale', function () {
// administrar el servidor OAuth2 (clientes, personal access tokens, apps autorizadas).
Route::prefix('web-api')->group(function () {
Route::get('/setup-status', [AuthController::class, 'setupStatus']);
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
// Segundo paso del login cuando el usuario tiene 2FA activo. Throttle
// acotado: el espacio de códigos TOTP de 6 dígitos es chico.
Route::post('/login/2fa', [AuthController::class, 'loginTwoFactor'])->middleware('throttle:6,1');
Route::post('/forgot-password', [PasswordResetController::class, 'forgotPassword'])->middleware('throttle:password-reset');
Route::post('/reset-password', [PasswordResetController::class, 'resetPassword'])->middleware('throttle:password-reset');
Route::middleware('auth')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']);
Route::get('/scopes', fn () => response()->json(Passport::scopes()));
// 2FA propio (self-service, como clientes/tokens): setup pendiente,
// confirmación, desactivación y regeneración de códigos de recuperación.
Route::post('/2fa/setup', [TwoFactorController::class, 'setup']);
// Confirmar activación (código TOTP) y confirmar contraseña actual
// (disable/recovery-codes) son ambos espacios de adivinanza chicos —
// mismo throttle acotado que el segundo paso del login con 2FA.
Route::post('/2fa/confirm', [TwoFactorController::class, 'confirm'])->middleware('throttle:6,1');
Route::post('/2fa/disable', [TwoFactorController::class, 'disable'])->middleware('throttle:6,1');
Route::post('/2fa/recovery-codes', [TwoFactorController::class, 'regenerateRecoveryCodes'])->middleware('throttle:6,1');
Route::apiResource('clients', ClientController::class)->except(['show']);
Route::post('/clients/{client}/rotate-secret', [ClientController::class, 'rotateSecret']);
// Webhooks salientes de un client propio (token.revoked, user.deactivated).
Route::get('/clients/{client}/webhooks', [WebhookController::class, 'index']);
Route::post('/clients/{client}/webhooks', [WebhookController::class, 'store']);
Route::put('/clients/{client}/webhooks/{webhook}', [WebhookController::class, 'update']);
Route::delete('/clients/{client}/webhooks/{webhook}', [WebhookController::class, 'destroy']);
Route::get('/clients/{client}/webhooks/{webhook}/deliveries', [WebhookController::class, 'deliveries']);
Route::post('/clients/{client}/webhooks/{webhook}/deliveries/{delivery}/retry', [WebhookController::class, 'retryDelivery']);
Route::get('/personal-access-tokens', [PersonalAccessTokenController::class, 'index']);
Route::post('/personal-access-tokens', [PersonalAccessTokenController::class, 'store']);
@@ -39,6 +70,10 @@ Route::prefix('web-api')->group(function () {
Route::get('/authorized-apps', [AuthorizedAppController::class, 'index']);
Route::delete('/authorized-apps/{tokenId}', [AuthorizedAppController::class, 'destroy']);
// Analíticas del funnel de autorización OAuth (intento/aprobación/denegación),
// registrado desde la pantalla de consentimiento (Authorize.vue).
Route::post('/oauth/authorize-events', [AuthorizationEventController::class, 'store']);
// Panel de administración de usuarios (solo para roles/permisos con "users.*").
Route::prefix('admin')->group(function () {
Route::get('/users', [UserController::class, 'index'])->middleware('can:users.view');
@@ -57,8 +92,17 @@ Route::prefix('web-api')->group(function () {
Route::post('/users/{user}/app-roles', [UserController::class, 'assignAppRole'])->middleware('can:users.update');
Route::delete('/users/{user}/app-roles/{clientId}/{role}', [UserController::class, 'revokeAppRole'])->middleware('can:users.update');
// Sesiones (tokens OAuth activos) de un usuario — para incident response:
// ver qué apps/tokens tiene vigentes y cerrarlos remotamente.
Route::get('/users/{user}/sessions', [UserSessionController::class, 'index'])->middleware('can:users.view');
Route::delete('/users/{user}/sessions/{tokenId}', [UserSessionController::class, 'destroy'])->middleware('can:users.update');
Route::delete('/users/{user}/sessions', [UserSessionController::class, 'destroyAll'])->middleware('can:users.update');
// Analíticas de uso: sesiones, logins por app, actividad reciente.
Route::get('/analytics/summary', [AnalyticsController::class, 'summary'])->middleware('can:users.view');
// Audit log: altas/bajas de usuarios, cambios de rol y de clientes OAuth.
Route::get('/audit-log', [AuditLogController::class, 'index'])->middleware('can:audit.view');
});
});
});

View File

@@ -0,0 +1,207 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserCrudTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolesAndPermissionsSeeder::class);
$this->admin = User::factory()->approved()->create();
$this->admin->assignRole('admin');
}
public function test_admin_can_list_approved_users(): void
{
$other = User::factory()->approved()->create();
User::factory()->create(['approved_at' => null]);
$response = $this->actingAs($this->admin)->getJson('/web-api/admin/users');
$response->assertOk();
$emails = collect($response->json())->pluck('email');
$this->assertTrue($emails->contains($this->admin->email));
$this->assertTrue($emails->contains($other->email));
}
public function test_non_admin_cannot_list_users(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)->getJson('/web-api/admin/users')->assertForbidden();
}
public function test_admin_can_create_a_user(): void
{
$response = $this->actingAs($this->admin)->postJson('/web-api/admin/users', [
'name' => 'New Person',
'email' => 'newperson@example.com',
'password' => 'password123',
'role' => 'user',
]);
$response->assertCreated();
$response->assertJsonPath('email', 'newperson@example.com');
$response->assertJsonPath('roles.0', 'user');
$created = User::where('email', 'newperson@example.com')->firstOrFail();
$this->assertNotNull($created->approved_at, 'un usuario creado por un admin queda aprobado automáticamente');
$this->assertDatabaseHas('audit_logs', ['action' => 'user.created', 'subject_id' => (string) $created->id]);
}
public function test_create_validates_unique_email_and_known_role(): void
{
$this->actingAs($this->admin)->postJson('/web-api/admin/users', [
'name' => 'Dup',
'email' => $this->admin->email,
'password' => 'password123',
'role' => 'user',
])->assertUnprocessable()->assertJsonValidationErrors('email');
$this->actingAs($this->admin)->postJson('/web-api/admin/users', [
'name' => 'Bad Role',
'email' => 'badrole@example.com',
'password' => 'password123',
'role' => 'superadmin',
])->assertUnprocessable()->assertJsonValidationErrors('role');
}
public function test_admin_can_update_a_users_name_email_role_and_active_state(): void
{
$target = User::factory()->approved()->create(['is_active' => true]);
$target->assignRole('user');
$response = $this->actingAs($this->admin)->putJson("/web-api/admin/users/{$target->id}", [
'name' => 'Renamed',
'email' => 'renamed@example.com',
'is_active' => false,
'role' => 'admin',
]);
$response->assertOk();
$response->assertJsonPath('name', 'Renamed');
$response->assertJsonPath('is_active', false);
$response->assertJsonPath('roles.0', 'admin');
$this->assertDatabaseHas('audit_logs', ['action' => 'user.updated', 'subject_id' => (string) $target->id]);
}
public function test_deactivating_a_user_dispatches_the_user_deactivated_event(): void
{
\Illuminate\Support\Facades\Event::fake([\App\Events\UserDeactivated::class]);
$target = User::factory()->approved()->create(['is_active' => true]);
$target->assignRole('user');
$this->actingAs($this->admin)->putJson("/web-api/admin/users/{$target->id}", [
'name' => $target->name,
'email' => $target->email,
'is_active' => false,
'role' => 'user',
])->assertOk();
\Illuminate\Support\Facades\Event::assertDispatched(\App\Events\UserDeactivated::class);
}
public function test_admin_cannot_remove_their_own_admin_role(): void
{
$response = $this->actingAs($this->admin)->putJson("/web-api/admin/users/{$this->admin->id}", [
'name' => $this->admin->name,
'email' => $this->admin->email,
'role' => 'user',
]);
$response->assertStatus(422);
$this->assertTrue($this->admin->fresh()->hasRole('admin'));
}
public function test_admin_cannot_delete_themselves(): void
{
$this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$this->admin->id}")
->assertStatus(422);
$this->assertNull($this->admin->fresh()->deleted_at);
}
public function test_cannot_delete_the_last_admin(): void
{
$secondAdmin = User::factory()->approved()->create();
$secondAdmin->assignRole('admin');
// Con dos admins, borrar uno debe andar.
$this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$secondAdmin->id}")
->assertNoContent();
// Ahora $this->admin es el único admin — ni siquiera otro usuario con
// permiso users.delete (pero sin rol admin) puede borrarlo.
$viewer = User::factory()->approved()->create();
$viewer->givePermissionTo('users.delete');
$this->actingAs($viewer)
->deleteJson("/web-api/admin/users/{$this->admin->id}")
->assertStatus(422);
$this->assertNull($this->admin->fresh()->deleted_at);
}
public function test_admin_can_soft_delete_restore_and_force_delete_a_user(): void
{
$target = User::factory()->approved()->create();
$this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$target->id}")
->assertNoContent();
$this->assertSoftDeleted('users', ['id' => $target->id]);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.deleted', 'subject_id' => (string) $target->id]);
$this->actingAs($this->admin)
->getJson('/web-api/admin/users/trashed')
->assertOk()
->assertJsonFragment(['email' => $target->email]);
$this->actingAs($this->admin)
->postJson("/web-api/admin/users/{$target->id}/restore")
->assertOk();
$this->assertDatabaseHas('users', ['id' => $target->id, 'deleted_at' => null]);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.restored', 'subject_id' => (string) $target->id]);
$this->actingAs($this->admin)->deleteJson("/web-api/admin/users/{$target->id}")->assertNoContent();
$this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$target->id}/force")
->assertNoContent();
$this->assertDatabaseMissing('users', ['id' => $target->id]);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.force_deleted', 'subject_id' => (string) $target->id]);
}
public function test_admin_can_list_and_approve_pending_registrations(): void
{
$pending = User::factory()->create(['approved_at' => null]);
$this->actingAs($this->admin)
->getJson('/web-api/admin/users/pending')
->assertOk()
->assertJsonFragment(['email' => $pending->email]);
$this->actingAs($this->admin)
->postJson("/web-api/admin/users/{$pending->id}/approve")
->assertOk()
->assertJsonPath('email', $pending->email);
$this->assertNotNull($pending->fresh()->approved_at);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.approved', 'subject_id' => (string) $pending->id]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Laravel\Passport\Client;
use Laravel\Passport\Token;
use Tests\TestCase;
class UserSessionTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private User $target;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolesAndPermissionsSeeder::class);
$this->admin = User::factory()->approved()->create();
$this->admin->assignRole('admin');
$this->target = User::factory()->approved()->create();
}
public function test_admin_can_list_a_users_active_sessions(): void
{
$token = $this->createActiveToken($this->target, 'Demo App');
$response = $this->actingAs($this->admin)->getJson("/web-api/admin/users/{$this->target->id}/sessions");
$response->assertOk();
$response->assertJsonFragment(['id' => $token->id, 'client_name' => 'Demo App']);
}
public function test_revoked_and_expired_tokens_are_not_listed(): void
{
$this->createActiveToken($this->target, 'Revoked App', ['revoked' => true]);
$this->createActiveToken($this->target, 'Expired App', ['expires_at' => now()->subDay()]);
$active = $this->createActiveToken($this->target, 'Active App');
$response = $this->actingAs($this->admin)->getJson("/web-api/admin/users/{$this->target->id}/sessions");
$response->assertOk()->assertJsonCount(1);
$response->assertJsonFragment(['id' => $active->id]);
}
public function test_non_admin_cannot_list_sessions(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)
->getJson("/web-api/admin/users/{$this->target->id}/sessions")
->assertForbidden();
}
public function test_admin_can_revoke_a_single_session(): void
{
$token = $this->createActiveToken($this->target);
$response = $this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$this->target->id}/sessions/{$token->id}");
$response->assertNoContent();
$this->assertTrue($token->fresh()->revoked);
$this->assertDatabaseHas('audit_logs', [
'action' => 'user.session_revoked',
'subject_id' => (string) $this->target->id,
]);
}
public function test_revoking_nonexistent_session_returns_404(): void
{
$this->actingAs($this->admin)
->deleteJson('/web-api/admin/users/'.$this->target->id.'/sessions/'.Str::random(80))
->assertNotFound();
}
public function test_admin_can_revoke_all_sessions(): void
{
$this->createActiveToken($this->target, 'App One');
$this->createActiveToken($this->target, 'App Two');
$response = $this->actingAs($this->admin)
->deleteJson("/web-api/admin/users/{$this->target->id}/sessions");
$response->assertNoContent();
$this->assertSame(0, Token::where('user_id', $this->target->id)->where('revoked', false)->count());
$this->assertDatabaseHas('audit_logs', [
'action' => 'user.sessions_revoked_all',
'subject_id' => (string) $this->target->id,
]);
}
public function test_non_admin_cannot_revoke_sessions(): void
{
$user = User::factory()->approved()->create();
$token = $this->createActiveToken($this->target);
$this->actingAs($user)
->deleteJson("/web-api/admin/users/{$this->target->id}/sessions/{$token->id}")
->assertForbidden();
$this->assertFalse($token->fresh()->revoked);
}
private function createActiveToken(User $user, string $clientName = 'Test Client', array $overrides = []): Token
{
$client = Client::create([
'name' => $clientName,
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['authorization_code', 'refresh_token'],
'revoked' => false,
]);
return Token::create(array_merge([
'id' => Str::random(80),
'user_id' => $user->id,
'client_id' => $client->id,
'name' => null,
'scopes' => ['users:read'],
'revoked' => false,
'created_at' => now(),
'updated_at' => now(),
'expires_at' => now()->addDays(15),
], $overrides));
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature\Api;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Passport\Client;
use Laravel\Passport\Passport;
use Tests\TestCase;
class ClientCrudTest extends TestCase
{
use RefreshDatabase;
private User $owner;
protected function setUp(): void
{
parent::setUp();
$this->owner = User::factory()->approved()->create();
}
public function test_can_create_a_client_with_account_write_scope(): void
{
Passport::actingAs($this->owner, ['account:write']);
$response = $this->postJson('/api/clients', [
'name' => 'API Created Client',
'grant_type' => 'client_credentials',
]);
$response->assertCreated();
$this->assertNotNull($response->json('plain_secret'));
$this->assertDatabaseHas('oauth_clients', ['name' => 'API Created Client', 'owner_id' => $this->owner->id]);
$this->assertDatabaseHas('audit_logs', ['action' => 'client.created']);
}
public function test_creating_a_client_without_account_write_scope_is_forbidden(): void
{
Passport::actingAs($this->owner, ['account:read']);
$this->postJson('/api/clients', [
'name' => 'Should Fail',
'grant_type' => 'client_credentials',
])->assertForbidden();
}
public function test_can_list_only_own_clients_with_account_read_scope(): void
{
$ownClient = Client::create([
'owner_id' => $this->owner->id,
'owner_type' => User::class,
'name' => 'Mine',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
$stranger = User::factory()->approved()->create();
Client::create([
'owner_id' => $stranger->id,
'owner_type' => User::class,
'name' => 'Not Mine',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
Passport::actingAs($this->owner, ['account:read']);
$response = $this->getJson('/api/clients');
$response->assertOk();
$names = collect($response->json())->pluck('name');
$this->assertTrue($names->contains('Mine'));
$this->assertFalse($names->contains('Not Mine'));
$this->assertTrue(collect($response->json())->every(fn ($c) => $c['id'] !== null));
$this->assertSame($ownClient->id, $response->json('0.id'));
}
public function test_can_update_own_client(): void
{
$client = Client::create([
'owner_id' => $this->owner->id,
'owner_type' => User::class,
'name' => 'Old Name',
'secret' => null,
'provider' => null,
'redirect_uris' => ['https://example.test/callback'],
'grant_types' => ['authorization_code', 'refresh_token'],
'revoked' => false,
]);
Passport::actingAs($this->owner, ['account:write']);
$response = $this->putJson("/api/clients/{$client->id}", [
'name' => 'New Name',
'redirect_uris' => ['https://example.test/new'],
]);
$response->assertOk();
$response->assertJsonPath('name', 'New Name');
$this->assertSame('New Name', $client->fresh()->name);
}
public function test_cannot_update_another_users_client(): void
{
$stranger = User::factory()->approved()->create();
$client = Client::create([
'owner_id' => $stranger->id,
'owner_type' => User::class,
'name' => 'Not Yours',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
Passport::actingAs($this->owner, ['account:write']);
$this->putJson("/api/clients/{$client->id}", ['name' => 'Hijacked'])->assertNotFound();
}
public function test_client_credentials_token_cannot_use_self_service_client_endpoints(): void
{
$serviceClient = Client::create([
'owner_id' => $this->owner->id,
'owner_type' => User::class,
'name' => 'Service Client',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
Passport::actingAsClient($serviceClient, ['account:write']);
$this->postJson('/api/clients', [
'name' => 'Should Fail, No User',
'grant_type' => 'client_credentials',
])->assertStatus(422);
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\LoginEvent;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class LoginTest extends TestCase
{
use RefreshDatabase;
public function test_active_approved_user_can_log_in(): void
{
$user = User::factory()->approved()->create(['password' => Hash::make('password123')]);
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertOk()->assertJsonPath('user.email', $user->email);
$this->assertAuthenticatedAs($user);
$this->assertDatabaseHas('login_events', [
'user_id' => $user->id,
'type' => 'session',
'outcome' => 'success',
]);
}
public function test_invalid_credentials_are_rejected_and_recorded_as_failure(): void
{
$user = User::factory()->approved()->create(['password' => Hash::make('password123')]);
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('email');
$this->assertGuest();
$failure = LoginEvent::where('outcome', 'failure')->first();
$this->assertNotNull($failure);
$this->assertNull($failure->user_id);
}
public function test_deactivated_account_cannot_log_in(): void
{
$user = User::factory()->approved()->create([
'password' => Hash::make('password123'),
'is_active' => false,
]);
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('email');
$this->assertGuest();
}
public function test_pending_account_cannot_log_in(): void
{
$user = User::factory()->create(['password' => Hash::make('password123')]);
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('email');
$this->assertGuest();
}
public function test_login_with_two_factor_enabled_requires_second_step(): void
{
$user = $this->createUserWithTwoFactor();
$response = $this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
]);
$response->assertOk()->assertJson(['two_factor' => true]);
$this->assertGuest();
}
public function test_two_factor_challenge_completes_login_with_valid_totp_code(): void
{
$user = $this->createUserWithTwoFactor();
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
])->assertOk();
$code = (new Google2FA())->getCurrentOtp($user->two_factor_secret);
$response = $this->postJson('/web-api/login/2fa', ['code' => $code]);
$response->assertOk()->assertJsonPath('user.email', $user->email);
$this->assertAuthenticatedAs($user);
}
public function test_two_factor_challenge_rejects_invalid_code(): void
{
$user = $this->createUserWithTwoFactor();
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
])->assertOk();
$response = $this->postJson('/web-api/login/2fa', ['code' => '000000']);
$response->assertUnprocessable()->assertJsonValidationErrors('code');
$this->assertGuest();
}
public function test_two_factor_challenge_accepts_recovery_code_only_once(): void
{
$user = $this->createUserWithTwoFactor();
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
])->assertOk();
$this->postJson('/web-api/login/2fa', ['code' => 'AAAA-1111'])->assertOk();
$this->assertAuthenticatedAs($user);
$this->postJson('/web-api/logout')->assertNoContent();
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'password123',
])->assertOk();
$reuse = $this->postJson('/web-api/login/2fa', ['code' => 'AAAA-1111']);
$reuse->assertUnprocessable()->assertJsonValidationErrors('code');
$this->assertGuest();
}
public function test_two_factor_challenge_without_pending_session_fails(): void
{
$response = $this->postJson('/web-api/login/2fa', ['code' => '123456']);
$response->assertUnprocessable()->assertJsonValidationErrors('code');
$this->assertGuest();
}
private function createUserWithTwoFactor(): User
{
$secret = (new Google2FA())->generateSecretKey();
return User::factory()->approved()->create([
'password' => Hash::make('password123'),
'two_factor_secret' => $secret,
'two_factor_recovery_codes' => ['AAAA-1111', 'BBBB-2222'],
'two_factor_confirmed_at' => now(),
]);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class PasswordGrantTwoFactorTest extends TestCase
{
use RefreshDatabase;
private string $clientId;
private string $clientSecret;
protected function setUp(): void
{
parent::setUp();
$owner = User::factory()->approved()->create();
$response = $this->actingAs($owner)->postJson('/web-api/clients', [
'name' => 'Password Grant Test Client',
'grant_type' => 'password',
]);
$response->assertCreated();
$this->clientId = $response->json('client.id');
$this->clientSecret = $response->json('plain_secret');
}
public function test_password_grant_still_works_for_users_without_two_factor(): void
{
User::factory()->approved()->create([
'email' => 'no2fa@example.com',
'password' => Hash::make('password123'),
]);
$this->requestToken('no2fa@example.com', 'password123')
->assertOk()->assertJsonStructure(['access_token']);
}
public function test_password_grant_is_rejected_without_otp_when_two_factor_is_enabled(): void
{
User::factory()->approved()->create([
'email' => 'has2fa@example.com',
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
$this->requestToken('has2fa@example.com', 'password123')->assertStatus(400);
}
public function test_password_grant_is_rejected_with_wrong_otp(): void
{
User::factory()->approved()->create([
'email' => 'has2fa@example.com',
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
$this->requestToken('has2fa@example.com', 'password123', '000000')->assertStatus(400);
}
public function test_password_grant_succeeds_with_valid_totp_code(): void
{
$secret = (new Google2FA())->generateSecretKey();
User::factory()->approved()->create([
'email' => 'has2fa@example.com',
'password' => Hash::make('password123'),
'two_factor_secret' => $secret,
'two_factor_confirmed_at' => now(),
]);
$otp = (new Google2FA())->getCurrentOtp($secret);
$this->requestToken('has2fa@example.com', 'password123', $otp)
->assertOk()->assertJsonStructure(['access_token']);
}
public function test_password_grant_succeeds_with_a_recovery_code_and_consumes_it(): void
{
$user = User::factory()->approved()->create([
'email' => 'has2fa@example.com',
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_recovery_codes' => ['AAAA-1111', 'BBBB-2222'],
'two_factor_confirmed_at' => now(),
]);
$this->requestToken('has2fa@example.com', 'password123', 'AAAA-1111')->assertOk();
$this->assertNotContains('AAAA-1111', $user->fresh()->two_factor_recovery_codes);
// El mismo código de recuperación no sirve una segunda vez.
$this->requestToken('has2fa@example.com', 'password123', 'AAAA-1111')->assertStatus(400);
}
private function requestToken(string $email, string $password, ?string $otp = null)
{
return $this->postJson('/oauth/token', array_filter([
'grant_type' => 'password',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'username' => $email,
'password' => $password,
'otp' => $otp,
'scope' => 'users:read',
], fn ($v) => $v !== null));
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Notifications\ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Laravel\Passport\Client;
use Laravel\Passport\Token;
use Tests\TestCase;
// NOTA: Notification::fake() nunca ejecuta toMail() (solo registra que se
// "envió"), así que un error real ahí adentro (como pasó acá: llamar a un
// método que no existe en esta versión de MailMessage) no lo detecta ningún
// test de arriba — solo se vio al procesar la cola de verdad en un browser
// check manual. test_the_mail_actually_renders_without_throwing cubre
// justamente ese hueco.
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
public function test_requesting_a_reset_link_for_an_existing_email_sends_the_notification(): void
{
Notification::fake();
$user = User::factory()->approved()->create();
$response = $this->postJson('/web-api/forgot-password', ['email' => $user->email]);
$response->assertOk();
Notification::assertSentTo($user, ResetPasswordNotification::class);
}
public function test_requesting_a_reset_link_for_an_unknown_email_returns_the_same_generic_response(): void
{
Notification::fake();
$known = $this->postJson('/web-api/forgot-password', ['email' => 'known@example.com']);
$unknown = $this->postJson('/web-api/forgot-password', ['email' => 'nobody@example.com']);
$known->assertOk();
$unknown->assertOk();
$this->assertSame($known->json('message'), $unknown->json('message'));
Notification::assertNothingSent();
}
public function test_forgot_password_validates_email_format(): void
{
$this->postJson('/web-api/forgot-password', ['email' => 'not-an-email'])
->assertUnprocessable()
->assertJsonValidationErrors('email');
}
public function test_can_reset_password_with_a_valid_token(): void
{
Notification::fake();
$user = User::factory()->approved()->create(['password' => Hash::make('old-password')]);
$this->postJson('/web-api/forgot-password', ['email' => $user->email])->assertOk();
$token = null;
Notification::assertSentTo($user, ResetPasswordNotification::class, function ($notification) use (&$token) {
$token = $notification->token;
return true;
});
$this->assertNotNull($token);
$response = $this->postJson('/web-api/reset-password', [
'token' => $token,
'email' => $user->email,
'password' => 'brand-new-password',
'password_confirmation' => 'brand-new-password',
]);
$response->assertNoContent();
$this->assertTrue(Hash::check('brand-new-password', $user->fresh()->password));
$this->assertDatabaseHas('audit_logs', [
'action' => 'user.password_reset',
'subject_id' => (string) $user->id,
]);
}
public function test_resetting_the_password_revokes_all_active_sessions(): void
{
Notification::fake();
$user = User::factory()->approved()->create(['password' => Hash::make('old-password')]);
$client = Client::create([
'owner_id' => $user->id,
'owner_type' => User::class,
'name' => 'Some App',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['authorization_code', 'refresh_token'],
'revoked' => false,
]);
$token = Token::create([
'id' => \Illuminate\Support\Str::random(80),
'user_id' => $user->id,
'client_id' => $client->id,
'name' => null,
'scopes' => ['users:read'],
'revoked' => false,
'created_at' => now(),
'updated_at' => now(),
'expires_at' => now()->addDays(15),
]);
$this->postJson('/web-api/forgot-password', ['email' => $user->email]);
$resetToken = null;
Notification::assertSentTo($user, ResetPasswordNotification::class, function ($notification) use (&$resetToken) {
$resetToken = $notification->token;
return true;
});
$this->postJson('/web-api/reset-password', [
'token' => $resetToken,
'email' => $user->email,
'password' => 'brand-new-password',
'password_confirmation' => 'brand-new-password',
])->assertNoContent();
$this->assertTrue($token->fresh()->revoked);
}
public function test_reset_fails_with_an_invalid_token(): void
{
$user = User::factory()->approved()->create(['password' => Hash::make('old-password')]);
$response = $this->postJson('/web-api/reset-password', [
'token' => 'not-a-real-token',
'email' => $user->email,
'password' => 'brand-new-password',
'password_confirmation' => 'brand-new-password',
]);
$response->assertUnprocessable();
$this->assertTrue(Hash::check('old-password', $user->fresh()->password));
}
public function test_reset_validates_password_confirmation_and_minimum_length(): void
{
$user = User::factory()->approved()->create();
$this->postJson('/web-api/reset-password', [
'token' => 'irrelevant',
'email' => $user->email,
'password' => 'short',
'password_confirmation' => 'short',
])->assertUnprocessable()->assertJsonValidationErrors('password');
$this->postJson('/web-api/reset-password', [
'token' => 'irrelevant',
'email' => $user->email,
'password' => 'a-long-enough-password',
'password_confirmation' => 'does-not-match',
])->assertUnprocessable()->assertJsonValidationErrors('password');
}
public function test_the_mail_actually_renders_without_throwing(): void
{
$user = User::factory()->approved()->create();
$mail = (new ResetPasswordNotification('a-token', 'es'))->toMail($user);
$rendered = $mail->render();
$this->assertStringContainsString('reset-password?token=a-token', $rendered);
$this->assertStringContainsString(urlencode($user->email), $rendered);
$this->assertStringContainsString('Restablecer contraseña', $rendered);
}
public function test_forgot_password_is_throttled(): void
{
Notification::fake();
for ($i = 0; $i < 5; $i++) {
$this->postJson('/web-api/forgot-password', ['email' => 'someone@example.com'])->assertOk();
}
$this->postJson('/web-api/forgot-password', ['email' => 'someone@example.com'])->assertStatus(429);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_first_user_bootstraps_as_admin_and_is_logged_in(): void
{
$response = $this->postJson('/web-api/register', [
'name' => 'Admin One',
'email' => 'admin@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertOk()->assertJson(['bootstrap' => true]);
$user = User::where('email', 'admin@example.com')->firstOrFail();
$this->assertTrue($user->hasRole('admin'));
$this->assertNotNull($user->approved_at);
$this->assertAuthenticatedAs($user);
}
public function test_second_user_registration_is_pending_and_not_logged_in(): void
{
$this->postJson('/web-api/register', [
'name' => 'Admin One',
'email' => 'admin@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
])->assertOk();
$this->postJson('/web-api/logout');
$response = $this->postJson('/web-api/register', [
'name' => 'Regular User',
'email' => 'user@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertCreated()->assertJson(['pending' => true]);
$user = User::where('email', 'user@example.com')->firstOrFail();
$this->assertTrue($user->hasRole('user'));
$this->assertNull($user->approved_at);
$this->assertGuest();
}
public function test_registration_requires_matching_password_confirmation(): void
{
$response = $this->postJson('/web-api/register', [
'name' => 'Someone',
'email' => 'someone@example.com',
'password' => 'password123',
'password_confirmation' => 'different',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('password');
}
public function test_registration_requires_unique_email(): void
{
User::factory()->create(['email' => 'taken@example.com']);
$response = $this->postJson('/web-api/register', [
'name' => 'Someone',
'email' => 'taken@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertUnprocessable()->assertJsonValidationErrors('email');
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
class TwoFactorTest extends TestCase
{
use RefreshDatabase;
public function test_setup_generates_a_pending_secret_and_qr_url(): void
{
$user = User::factory()->approved()->create();
$response = $this->actingAs($user)->postJson('/web-api/2fa/setup');
$response->assertOk()->assertJsonStructure(['secret', 'otpauth_url']);
$user->refresh();
$this->assertNotNull($user->two_factor_secret);
$this->assertFalse($user->hasTwoFactorEnabled());
}
public function test_setup_is_blocked_when_already_enabled(): void
{
$user = User::factory()->approved()->create([
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_confirmed_at' => now(),
]);
$this->actingAs($user)->postJson('/web-api/2fa/setup')->assertStatus(422);
}
public function test_confirm_enables_two_factor_and_returns_recovery_codes(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)->postJson('/web-api/2fa/setup')->assertOk();
$user->refresh();
$code = (new Google2FA())->getCurrentOtp($user->two_factor_secret);
$response = $this->actingAs($user)->postJson('/web-api/2fa/confirm', ['code' => $code]);
$response->assertOk();
$this->assertCount(8, $response->json('recovery_codes'));
$user->refresh();
$this->assertTrue($user->hasTwoFactorEnabled());
$this->assertDatabaseHas('audit_logs', [
'action' => 'user.2fa_enabled',
'subject_id' => (string) $user->id,
]);
}
public function test_confirm_rejects_invalid_code(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)->postJson('/web-api/2fa/setup')->assertOk();
$response = $this->actingAs($user)->postJson('/web-api/2fa/confirm', ['code' => '000000']);
$response->assertUnprocessable()->assertJsonValidationErrors('code');
$this->assertFalse($user->fresh()->hasTwoFactorEnabled());
}
public function test_confirm_without_pending_setup_fails(): void
{
$user = User::factory()->approved()->create();
$this->actingAs($user)->postJson('/web-api/2fa/confirm', ['code' => '123456'])
->assertStatus(422);
}
public function test_disable_requires_correct_password(): void
{
$user = User::factory()->approved()->create([
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_recovery_codes' => ['AAAA-1111'],
'two_factor_confirmed_at' => now(),
]);
$this->actingAs($user)->postJson('/web-api/2fa/disable', ['password' => 'nope'])
->assertUnprocessable()->assertJsonValidationErrors('password');
$this->assertTrue($user->fresh()->hasTwoFactorEnabled());
$this->actingAs($user)->postJson('/web-api/2fa/disable', ['password' => 'password123'])
->assertNoContent();
$user->refresh();
$this->assertFalse($user->hasTwoFactorEnabled());
$this->assertNull($user->two_factor_secret);
$this->assertNull($user->two_factor_recovery_codes);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.2fa_disabled']);
}
public function test_regenerate_recovery_codes_requires_enabled_two_factor(): void
{
$user = User::factory()->approved()->create(['password' => Hash::make('password123')]);
$this->actingAs($user)->postJson('/web-api/2fa/recovery-codes', ['password' => 'password123'])
->assertStatus(422);
}
public function test_regenerate_recovery_codes_replaces_old_codes(): void
{
$user = User::factory()->approved()->create([
'password' => Hash::make('password123'),
'two_factor_secret' => (new Google2FA())->generateSecretKey(),
'two_factor_recovery_codes' => ['OLD1-CODE', 'OLD2-CODE'],
'two_factor_confirmed_at' => now(),
]);
$response = $this->actingAs($user)->postJson('/web-api/2fa/recovery-codes', ['password' => 'password123']);
$response->assertOk();
$newCodes = $response->json('recovery_codes');
$this->assertCount(8, $newCodes);
$this->assertNotContains('OLD1-CODE', $newCodes);
$user->refresh();
$this->assertEquals($newCodes, $user->two_factor_recovery_codes);
$this->assertDatabaseHas('audit_logs', ['action' => 'user.2fa_recovery_regenerated']);
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Passport\Token;
use Tests\TestCase;
class ClientCrudTest extends TestCase
{
use RefreshDatabase;
private User $owner;
protected function setUp(): void
{
parent::setUp();
$this->owner = User::factory()->approved()->create();
}
public function test_owner_can_list_only_their_own_non_revoked_clients(): void
{
$this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'My Client',
'grant_type' => 'client_credentials',
])->assertCreated();
$stranger = User::factory()->approved()->create();
$this->actingAs($stranger)->postJson('/web-api/clients', [
'name' => 'Someone Elses Client',
'grant_type' => 'client_credentials',
])->assertCreated();
$response = $this->actingAs($this->owner)->getJson('/web-api/clients');
$response->assertOk();
$names = collect($response->json())->pluck('name');
$this->assertTrue($names->contains('My Client'));
$this->assertFalse($names->contains('Someone Elses Client'));
}
public function test_creating_a_client_credentials_client_is_always_confidential(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'CC Client',
'grant_type' => 'client_credentials',
'confidential' => false,
]);
$response->assertCreated();
$this->assertNotNull($response->json('plain_secret'));
$this->assertDatabaseHas('audit_logs', ['action' => 'client.created']);
}
public function test_creating_a_public_authorization_code_client_has_no_secret(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Public SPA',
'grant_type' => 'authorization_code',
'redirect_uris' => ['https://example.test/callback'],
'confidential' => false,
]);
$response->assertCreated();
$this->assertNull($response->json('plain_secret'));
}
public function test_authorization_code_requires_redirect_uris(): void
{
$this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'No Redirect',
'grant_type' => 'authorization_code',
])->assertUnprocessable()->assertJsonValidationErrors('redirect_uris');
}
public function test_invalid_grant_type_is_rejected(): void
{
$this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Bad Grant',
'grant_type' => 'not-a-real-grant',
])->assertUnprocessable()->assertJsonValidationErrors('grant_type');
}
public function test_owner_can_update_name_and_redirect_uris(): void
{
$created = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Original Name',
'grant_type' => 'authorization_code',
'redirect_uris' => ['https://example.test/callback'],
])->json('client');
$response = $this->actingAs($this->owner)->putJson("/web-api/clients/{$created['id']}", [
'name' => 'Updated Name',
'redirect_uris' => ['https://example.test/new-callback'],
]);
$response->assertOk();
$response->assertJsonPath('name', 'Updated Name');
$response->assertJsonPath('redirect_uris.0', 'https://example.test/new-callback');
}
public function test_non_owner_cannot_update_or_revoke_another_users_client(): void
{
$created = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Owned Client',
'grant_type' => 'client_credentials',
])->json('client');
$stranger = User::factory()->approved()->create();
$this->actingAs($stranger)
->putJson("/web-api/clients/{$created['id']}", ['name' => 'Hijacked'])
->assertNotFound();
$this->actingAs($stranger)
->deleteJson("/web-api/clients/{$created['id']}")
->assertNotFound();
}
public function test_revoking_a_client_also_revokes_its_active_tokens(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'To Revoke',
'grant_type' => 'client_credentials',
]);
$clientId = $response->json('client.id');
$token = Token::create([
'id' => \Illuminate\Support\Str::random(80),
'user_id' => $this->owner->id,
'client_id' => $clientId,
'name' => null,
'scopes' => ['users:read'],
'revoked' => false,
'created_at' => now(),
'updated_at' => now(),
'expires_at' => now()->addDays(15),
]);
$this->actingAs($this->owner)
->deleteJson("/web-api/clients/{$clientId}")
->assertNoContent();
$this->assertDatabaseHas('oauth_clients', ['id' => $clientId, 'revoked' => true]);
$this->assertDatabaseHas('oauth_access_tokens', ['id' => $token->id, 'revoked' => true]);
$this->assertDatabaseHas('audit_logs', ['action' => 'client.revoked', 'subject_id' => $clientId]);
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\TestResponse;
use Laravel\Passport\Client;
use Tests\TestCase;
class ClientSecretRotationTest extends TestCase
{
use RefreshDatabase;
private User $owner;
private Client $client;
private string $originalSecret;
protected function setUp(): void
{
parent::setUp();
$this->owner = User::factory()->approved()->create();
// Se crea vía el endpoint real para tener un secret en texto plano
// válido y un cliente client_credentials real, igual que en producción.
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Rotation Test Client',
'grant_type' => 'client_credentials',
]);
$response->assertCreated();
$this->originalSecret = $response->json('plain_secret');
$this->client = Client::find($response->json('client.id'));
}
public function test_original_secret_works_before_rotation(): void
{
$this->requestToken($this->originalSecret)->assertOk()->assertJsonStructure(['access_token']);
}
public function test_rotating_returns_a_new_secret_and_grace_period(): void
{
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$response->assertOk()->assertJsonStructure(['plain_secret', 'previous_secret_expires_at']);
$this->assertNotEmpty($response->json('plain_secret'));
$this->assertNotSame($this->originalSecret, $response->json('plain_secret'));
$this->assertDatabaseHas('audit_logs', [
'action' => 'client.secret_rotated',
'subject_id' => $this->client->id,
]);
}
public function test_both_old_and_new_secret_work_during_the_grace_period(): void
{
$response = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$newSecret = $response->json('plain_secret');
$this->requestToken($newSecret)->assertOk();
$this->requestToken($this->originalSecret)->assertOk();
}
public function test_old_secret_stops_working_after_the_grace_period_expires(): void
{
$this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$this->client->forceFill(['previous_secret_expires_at' => now()->subMinute()])->save();
$this->requestToken($this->originalSecret)->assertStatus(401);
}
public function test_rotating_again_invalidates_the_first_old_secret_immediately(): void
{
// Solo se guarda UN secret anterior — rotar de nuevo pisa el que
// había, no acumula un historial de secrets viejos válidos.
$firstResponse = $this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$secondSecret = $firstResponse->json('plain_secret');
$this->actingAs($this->owner)->postJson("/web-api/clients/{$this->client->id}/rotate-secret");
$this->requestToken($this->originalSecret)->assertStatus(401);
$this->requestToken($secondSecret)->assertOk();
}
public function test_non_owner_cannot_rotate_another_users_client_secret(): void
{
$stranger = User::factory()->approved()->create();
$this->actingAs($stranger)
->postJson("/web-api/clients/{$this->client->id}/rotate-secret")
->assertNotFound();
$this->requestToken($this->originalSecret)->assertOk();
}
public function test_cannot_rotate_a_public_clients_secret(): void
{
$response = $this->actingAs($this->owner)->postJson('/web-api/clients', [
'name' => 'Public Client',
'grant_type' => 'authorization_code',
'redirect_uris' => ['https://example.test/callback'],
'confidential' => false,
]);
$response->assertCreated();
$publicClient = Client::find($response->json('client.id'));
$this->actingAs($this->owner)
->postJson("/web-api/clients/{$publicClient->id}/rotate-secret")
->assertStatus(422);
}
private function requestToken(string $secret): TestResponse
{
return $this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $this->client->id,
'client_secret' => $secret,
'scope' => 'users:read',
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Passport\Client;
use Laravel\Passport\Events\AccessTokenCreated;
use Tests\TestCase;
/**
* Regresión: los listeners bajo app/Listeners se auto-descubren por su
* handle() tipado (Laravel 11+). Registrarlos TAMBIÉN a mano con
* Event::listen() en un ServiceProvider los duplica — cada evento real
* termina disparando el listener dos veces (visto con "php artisan
* event:list" listando el mismo listener dos veces por evento). Pasó acá:
* cada login_event/webhook se creaba duplicado. Este test evita que vuelva.
*/
class EventRegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_access_token_created_records_exactly_one_login_event(): void
{
$user = User::factory()->approved()->create();
$client = Client::create([
'name' => 'Regression Test Client',
'secret' => null,
'provider' => null,
'redirect_uris' => [],
'grant_types' => ['client_credentials'],
'revoked' => false,
]);
event(new AccessTokenCreated('fake-token-id', (string) $user->id, $client->id));
$this->assertDatabaseCount('login_events', 1);
$this->assertDatabaseHas('login_events', [
'user_id' => $user->id,
'client_id' => $client->id,
'type' => 'oauth',
'outcome' => 'success',
]);
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class RateLimitingTest extends TestCase
{
use RefreshDatabase;
public function test_login_is_throttled_after_five_attempts(): void
{
$user = User::factory()->approved()->create(['password' => bcrypt('correct-password')]);
for ($i = 0; $i < 5; $i++) {
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'wrong-password',
])->assertUnprocessable();
}
$this->postJson('/web-api/login', [
'email' => $user->email,
'password' => 'wrong-password',
])->assertStatus(429);
// El throttle es por (email, ip): otro email desde la misma IP no está afectado.
$other = User::factory()->approved()->create(['password' => bcrypt('correct-password')]);
$this->postJson('/web-api/login', [
'email' => $other->email,
'password' => 'correct-password',
])->assertOk();
}
public function test_register_is_throttled_after_five_attempts(): void
{
// El primer registro de una instalación vacía toma un camino distinto
// (bootstrap admin, 200 en vez de 201, y siembra roles/permisos) — se
// crea un usuario previo y se siembran los roles a mano para que
// todos los intentos del loop pasen por el camino normal.
(new RolesAndPermissionsSeeder)->run();
User::factory()->approved()->create();
for ($i = 0; $i < 5; $i++) {
$this->postJson('/web-api/register', [
'name' => "User {$i}",
'email' => "user{$i}@example.com",
'password' => 'password123',
'password_confirmation' => 'password123',
])->assertCreated();
}
$this->postJson('/web-api/register', [
'name' => 'One Too Many',
'email' => 'onetoomany@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
])->assertStatus(429);
}
public function test_oauth_token_endpoint_is_throttled_per_client(): void
{
$owner = User::factory()->approved()->create();
$response = $this->actingAs($owner)->postJson('/web-api/clients', [
'name' => 'Throttle Test Client',
'grant_type' => 'client_credentials',
]);
$response->assertCreated();
$clientId = $response->json('client.id');
for ($i = 0; $i < 20; $i++) {
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => 'wrong-secret',
])->assertStatus(401);
}
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => 'wrong-secret',
])->assertStatus(429);
}
public function test_oauth_token_throttle_is_scoped_per_client_not_globally(): void
{
$owner = User::factory()->approved()->create();
$clientA = $this->actingAs($owner)->postJson('/web-api/clients', [
'name' => 'Client A',
'grant_type' => 'client_credentials',
])->json('client.id');
for ($i = 0; $i < 20; $i++) {
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientA,
'client_secret' => 'wrong-secret',
]);
}
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientA,
'client_secret' => 'wrong-secret',
])->assertStatus(429);
$clientBResponse = $this->actingAs($owner)->postJson('/web-api/clients', [
'name' => 'Client B',
'grant_type' => 'client_credentials',
]);
$clientBSecret = $clientBResponse->json('plain_secret');
$clientBId = $clientBResponse->json('client.id');
$this->postJson('/oauth/token', [
'grant_type' => 'client_credentials',
'client_id' => $clientBId,
'client_secret' => $clientBSecret,
'scope' => 'users:read',
])->assertOk();
}
}

Some files were not shown because too many files have changed in this diff Show More