new funcionality
This commit is contained in:
43
app/Http/Controllers/Api/AuditLogController.php
Normal file
43
app/Http/Controllers/Api/AuditLogController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
98
app/Http/Controllers/Api/UserSessionController.php
Normal file
98
app/Http/Controllers/Api/UserSessionController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
220
app/Http/Controllers/Api/WebhookController.php
Normal file
220
app/Http/Controllers/Api/WebhookController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user