new funcionality

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

View File

@@ -0,0 +1,15 @@
<?php
namespace App\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]);
}
}