Files
oauth/app/Http/Controllers/Api/UserController.php
Josep Piguillem c2b5291a74 Initial commit: OAuth IAM Laravel application
Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and
Docker Compose setup for local and production deployment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 01:02:41 +02:00

491 lines
20 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Laravel\Passport\Http\Middleware\CheckToken;
use OpenApi\Attributes as OA;
use Spatie\Permission\Models\Role;
#[OA\Schema(
schema: 'User',
type: 'object',
properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'name', type: 'string', example: 'Ada Lovelace'),
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'ada@example.com'),
new OA\Property(property: 'is_active', type: 'boolean', example: true),
new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string'), example: ['user']),
new OA\Property(
property: 'app_roles',
type: 'array',
items: new OA\Items(type: 'string'),
example: ['editor'],
description: 'Roles del usuario específicos de la app que llama (el client_id del token usado).'
),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'deleted_at', type: 'string', format: 'date-time', nullable: true, description: 'Solo presente para usuarios en la papelera (soft-deleted).'),
new OA\Property(property: 'approved_at', type: 'string', format: 'date-time', nullable: true, description: 'Null = todavía no lo aprobó ningún admin (registro pendiente).'),
]
)]
class UserController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
// No usamos "auth:api": rompe con tokens client_credentials (sin usuario).
// CheckToken valida el bearer token y el scope; los permisos por-usuario
// (spatie) se chequean a mano en cada acción, ver authorize().
return [
new Middleware(CheckToken::using('users:read'), only: ['index', 'show', 'appRoles', 'trashed', 'pending']),
new Middleware(CheckToken::using('users:write'), only: ['store', 'update', 'destroy', 'assignAppRole', 'revokeAppRole', 'restore', 'forceDelete', 'approve']),
];
}
#[OA\Get(
path: '/user',
summary: 'Usuario del token actual (whoami) + sus roles global y en la app que llama',
security: [['oauth2' => []]],
tags: ['Users'],
responses: [
new OA\Response(response: 200, description: 'Usuario autenticado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
new OA\Response(response: 401, description: 'Token inválido o sin usuario asociado (ej. client_credentials)'),
]
)]
public function me(Request $request)
{
return response()->json($this->present($request->user()));
}
#[OA\Get(
path: '/users',
summary: 'Listar usuarios',
description: 'Incluye usuarios pendientes de aprobación del panel admin (ver campo approved_at) — esa aprobación solo gobierna el acceso al panel de sesión de esta app, no la consulta de usuarios vía API.',
security: [['oauth2' => ['users:read']]],
tags: ['Users'],
responses: [
new OA\Response(response: 200, description: 'Listado de usuarios', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/User'))),
new OA\Response(response: 403, description: 'Sin permiso users.view'),
]
)]
public function index(Request $request)
{
$this->authorize($request, 'users.view');
// A diferencia del panel admin (Admin\UserController), acá NO se
// esconden los usuarios pendientes de aprobación: "aprobado" es un
// concepto del panel de esta app (¿puede entrar a /admin?), no algo
// que le importe a otra app que solo está consultando/consumiendo
// usuarios por la API.
return response()->json(
User::query()->with('roles:id,name')->orderBy('name')->get()->map($this->present(...))
);
}
#[OA\Post(
path: '/users',
summary: 'Crear un usuario',
security: [['oauth2' => ['users:write']]],
tags: ['Users'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'email', 'password'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'email', type: 'string', format: 'email'),
new OA\Property(property: 'password', type: 'string', format: 'password'),
new OA\Property(property: 'role', type: 'string', example: 'user'),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Usuario creado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
new OA\Response(response: 403, description: 'Sin permiso users.create'),
]
)]
public function store(Request $request)
{
$this->authorize($request, 'users.create');
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'min:8'],
'role' => ['sometimes', Rule::in(Role::pluck('name'))],
]);
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'is_active' => true,
'approved_at' => now(), // lo crea un admin/servicio de confianza: ya está aprobado.
]);
$user->assignRole($data['role'] ?? 'user');
return response()->json($this->present($user), 201);
}
#[OA\Get(
path: '/users/{user}',
summary: 'Ver un usuario (el propio siempre permitido; otros requieren users.view)',
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: 'Datos del usuario', content: new OA\JsonContent(ref: '#/components/schemas/User')),
new OA\Response(response: 403, description: 'Sin permiso users.view'),
]
)]
public function show(Request $request, User $user)
{
$this->authorize($request, 'users.view', $user);
return response()->json($this->present($user));
}
#[OA\Put(
path: '/users/{user}',
summary: 'Editar un usuario (el propio siempre permitido; rol/estado y otros usuarios requieren users.update)',
security: [['oauth2' => ['users:write']]],
tags: ['Users'],
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'email', type: 'string', format: 'email'),
new OA\Property(property: 'password', type: 'string', format: 'password'),
new OA\Property(property: 'is_active', type: 'boolean'),
new OA\Property(property: 'role', type: 'string'),
]
)
),
responses: [
new OA\Response(response: 200, description: 'Usuario actualizado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
new OA\Response(response: 403, description: 'Sin permiso users.update'),
]
)]
public function update(Request $request, User $user)
{
$actor = $this->authorize($request, 'users.update', $user);
$isAdminAction = is_null($actor) || $actor->can('users.update');
$data = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'email' => ['sometimes', 'string', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
'password' => ['sometimes', 'string', 'min:8'],
'is_active' => ['sometimes', 'boolean'],
'role' => ['sometimes', Rule::in(Role::pluck('name'))],
]);
if (! $isAdminAction) {
$data = array_intersect_key($data, array_flip(['name', 'email', 'password']));
}
if (isset($data['role']) && $user->id === $actor?->id && $data['role'] !== 'admin' && $user->hasRole('admin')) {
abort(422, __('messages.cannot_remove_own_admin_role'));
}
$user->update([
...array_diff_key($data, array_flip(['password', 'role'])),
...(isset($data['password']) ? ['password' => Hash::make($data['password'])] : []),
]);
if (isset($data['role'])) {
$user->syncRoles([$data['role']]);
}
return response()->json($this->present($user->fresh()));
}
#[OA\Delete(
path: '/users/{user}',
summary: 'Borrar un usuario (requiere users.delete; no se puede autoeliminar)',
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: 'Usuario borrado'),
new OA\Response(response: 403, description: 'Sin permiso users.delete'),
new OA\Response(response: 422, description: 'No se puede autoeliminar ni borrar al último admin'),
]
)]
public function destroy(Request $request, User $user)
{
$actor = $request->user('api');
abort_if($actor && $actor->id === $user->id, 422, __('messages.cannot_delete_self'));
abort_if($actor && ! $actor->can('users.delete'), 403);
if ($user->hasRole('admin') && User::role('admin')->count() <= 1) {
abort(422, __('messages.cannot_delete_last_admin'));
}
$user->delete();
return response()->noContent();
}
#[OA\Get(
path: '/users/trashed',
summary: 'Listar usuarios borrados (papelera)',
security: [['oauth2' => ['users:read']]],
tags: ['Users'],
responses: [
new OA\Response(response: 200, description: 'Listado de usuarios en la papelera', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/User'))),
new OA\Response(response: 403, description: 'Sin permiso users.view'),
]
)]
public function trashed(Request $request)
{
$this->authorize($request, 'users.view');
return response()->json(
User::onlyTrashed()->orderByDesc('deleted_at')->get()->map($this->present(...))
);
}
#[OA\Post(
path: '/users/{id}/restore',
summary: 'Restaurar un usuario borrado',
security: [['oauth2' => ['users:write']]],
tags: ['Users'],
parameters: [new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
responses: [
new OA\Response(response: 200, description: 'Usuario restaurado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
new OA\Response(response: 403, description: 'Sin permiso users.update'),
]
)]
public function restore(Request $request, int $id)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.update'), 403);
$user = User::onlyTrashed()->findOrFail($id);
$user->restore();
return response()->json($this->present($user));
}
#[OA\Delete(
path: '/users/{id}/force',
summary: 'Borrar un usuario definitivamente (tiene que estar en la papelera)',
security: [['oauth2' => ['users:write']]],
tags: ['Users'],
parameters: [new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
responses: [
new OA\Response(response: 204, description: 'Borrado definitivamente'),
new OA\Response(response: 403, description: 'Sin permiso users.delete'),
]
)]
public function forceDelete(Request $request, int $id)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.delete'), 403);
$user = User::onlyTrashed()->findOrFail($id);
$user->forceDelete();
return response()->noContent();
}
#[OA\Get(
path: '/users/pending',
summary: 'Listar usuarios autorregistrados que todavía no aprobó ningún admin',
security: [['oauth2' => ['users:read']]],
tags: ['Users'],
responses: [
new OA\Response(response: 200, description: 'Listado de usuarios pendientes', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/User'))),
new OA\Response(response: 403, description: 'Sin permiso users.view'),
]
)]
public function pending(Request $request)
{
$this->authorize($request, 'users.view');
return response()->json(
User::query()->whereNull('approved_at')->orderBy('created_at')->get()->map($this->present(...))
);
}
#[OA\Post(
path: '/users/{user}/approve',
summary: 'Aprobar un registro pendiente',
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: 200, description: 'Usuario aprobado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
new OA\Response(response: 403, description: 'Sin permiso users.update'),
]
)]
public function approve(Request $request, User $user)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.update'), 403);
$user->update(['approved_at' => now()]);
return response()->json($this->present($user));
}
#[OA\Get(
path: '/users/{user}/app-roles',
summary: 'Roles que el usuario tiene en cada app (el propio siempre permitido; otros requieren users.view)',
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: 'Roles agrupados por app')]
)]
public function appRoles(Request $request, User $user)
{
$this->authorize($request, 'users.view', $user);
$rows = DB::table('app_user_roles')
->join('oauth_clients', 'oauth_clients.id', '=', 'app_user_roles.client_id')
->where('app_user_roles.user_id', $user->id)
->get(['oauth_clients.id as client_id', 'oauth_clients.name as client_name', 'app_user_roles.role']);
return response()->json(
$rows->groupBy('client_id')->map(fn ($group) => [
'client_id' => $group->first()->client_id,
'client_name' => $group->first()->client_name,
'roles' => $group->pluck('role'),
])->values()
);
}
#[OA\Post(
path: '/users/{user}/app-roles',
summary: 'Asignar un rol a un usuario dentro de una app (requiere users.update; sin bypass por self)',
security: [['oauth2' => ['users:write']]],
tags: ['Users'],
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['client_id', 'role'],
properties: [
new OA\Property(property: 'client_id', type: 'string', format: 'uuid'),
new OA\Property(property: 'role', type: 'string', example: 'admin'),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Rol asignado'),
new OA\Response(response: 403, description: 'Sin permiso users.update'),
]
)]
public function assignAppRole(Request $request, User $user)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.update'), 403);
$data = $request->validate([
'client_id' => ['required', 'exists:oauth_clients,id'],
'role' => ['required', Rule::in(Role::pluck('name'))],
]);
DB::table('app_user_roles')->insertOrIgnore([
'user_id' => $user->id,
'client_id' => $data['client_id'],
'role' => $data['role'],
'created_at' => now(),
'updated_at' => now(),
]);
return response()->json($data, 201);
}
#[OA\Delete(
path: '/users/{user}/app-roles/{clientId}/{role}',
summary: 'Quitarle a un usuario un rol dentro de una app (requiere users.update)',
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: 'clientId', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'role', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 204, description: 'Rol quitado'),
new OA\Response(response: 403, description: 'Sin permiso users.update'),
]
)]
public function revokeAppRole(Request $request, User $user, string $clientId, string $role)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.update'), 403);
DB::table('app_user_roles')
->where('user_id', $user->id)
->where('client_id', $clientId)
->where('role', $role)
->delete();
return response()->noContent();
}
/**
* Un token client_credentials (sin usuario) actúa como servicio de confianza:
* ya validado por scope, no necesita permiso de usuario. Si hay usuario, puede
* operar siempre sobre sí mismo; sobre otros usuarios necesita el permiso dado.
*/
private function authorize(Request $request, string $permission, ?User $target = null): ?User
{
$actor = $request->user('api');
if (! $actor) {
return null;
}
$isSelf = $target && $target->id === $actor->id;
if (! $isSelf && ! $actor->can($permission)) {
abort(403);
}
return $actor;
}
/**
* Devuelve el usuario más "app_roles": los roles que tiene asignados
* específicamente para la app que está llamando (el cliente OAuth del
* token actual), guardados en app_user_roles — un sistema aparte del rol
* global de spatie (ver Admin\UserController::appRoles).
*/
private function present(User $user): array
{
$callingClientId = Auth::guard('api')->client()?->id;
$appRoles = $callingClientId
? DB::table('app_user_roles')
->where('user_id', $user->id)
->where('client_id', $callingClientId)
->pluck('role')
: collect();
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'is_active' => $user->is_active,
'roles' => $user->getRoleNames(),
'app_roles' => $appRoles,
'created_at' => $user->created_at,
'approved_at' => $user->approved_at,
];
}
}