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>
This commit is contained in:
2026-07-26 01:02:41 +02:00
commit c2b5291a74
174 changed files with 29318 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Support\AnalyticsSummary;
use Illuminate\Http\Request;
class AnalyticsController extends Controller
{
public function summary(Request $request)
{
$days = min(max((int) $request->integer('days', 30), 7), 90);
return response()->json(AnalyticsSummary::build($days));
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Laravel\Passport\Client;
class AppController extends Controller
{
/**
* Todas las apps (clientes OAuth) registradas en el sistema, sin importar
* quién las creó — para que el admin elija a cuál asignarle un rol por-app.
*/
public function index()
{
return response()->json(
Client::query()->where('revoked', false)->orderBy('name')->get(['id', 'name'])
);
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Role;
class UserController extends Controller
{
public function index()
{
return response()->json(
User::query()->whereNotNull('approved_at')->orderBy('name')->get()->map($this->present(...))
);
}
/**
* Usuarios borrados (soft delete) — la "papelera".
*/
public function trashed()
{
return response()->json(
User::onlyTrashed()->orderByDesc('deleted_at')->get()->map($this->present(...))
);
}
/**
* Usuarios que se autorregistraron y todavía no aprobó ningún admin.
*/
public function pending()
{
return response()->json(
User::query()->whereNull('approved_at')->orderBy('created_at')->get()->map($this->present(...))
);
}
public function approve(User $user)
{
$user->update(['approved_at' => now()]);
return response()->json($this->present($user));
}
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'min:8'],
'role' => ['required', 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 da de alta un admin: ya está aprobado.
]);
$user->assignRole($data['role']);
return response()->json($this->present($user), 201);
}
public function update(Request $request, User $user)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
'password' => ['nullable', 'string', 'min:8'],
'is_active' => ['boolean'],
'role' => ['required', Rule::in(Role::pluck('name'))],
]);
if ($user->id === $request->user()->id && $data['role'] !== 'admin' && $user->hasRole('admin')) {
abort(422, __('messages.cannot_remove_own_admin_role'));
}
$user->update([
'name' => $data['name'],
'email' => $data['email'],
'is_active' => $data['is_active'] ?? $user->is_active,
...(isset($data['password']) ? ['password' => Hash::make($data['password'])] : []),
]);
$user->syncRoles([$data['role']]);
return response()->json($this->present($user->fresh()));
}
/**
* Soft delete: el usuario deja de aparecer en el sistema pero queda
* recuperable desde la papelera.
*/
public function destroy(Request $request, User $user)
{
abort_if($user->id === $request->user()->id, 422, __('messages.cannot_delete_self'));
if ($user->hasRole('admin') && User::role('admin')->count() <= 1) {
abort(422, __('messages.cannot_delete_last_admin'));
}
$user->delete();
return response()->noContent();
}
public function restore(int $id)
{
$user = User::onlyTrashed()->findOrFail($id);
$user->restore();
return response()->json($this->present($user));
}
/**
* Borrado definitivo — solo tiene sentido sobre alguien que ya está en la papelera.
*/
public function forceDelete(Request $request, int $id)
{
$user = User::onlyTrashed()->findOrFail($id);
$user->forceDelete();
return response()->noContent();
}
/**
* Roles que este usuario tiene asignados en cada app (cliente OAuth), agrupados.
* Es un sistema aparte del rol global de spatie: vive en app_user_roles,
* no en las tablas de spatie (evita mezclar "quién administra este panel"
* con "qué rol tiene este usuario dentro de la app de un tercero").
*/
public function appRoles(User $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()
);
}
public function assignAppRole(Request $request, User $user)
{
$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);
}
public function revokeAppRole(User $user, string $clientId, string $role)
{
DB::table('app_user_roles')
->where('user_id', $user->id)
->where('client_id', $clientId)
->where('role', $role)
->delete();
return response()->noContent();
}
private function present(User $user): array
{
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'is_active' => $user->is_active,
'roles' => $user->getRoleNames(),
'created_at' => $user->created_at,
'deleted_at' => $user->deleted_at,
'approved_at' => $user->approved_at,
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Support\AnalyticsSummary;
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 AnalyticsController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [new Middleware(CheckToken::using('users:read'))];
}
#[OA\Get(
path: '/analytics/summary',
summary: 'Métricas de uso: logins por día, por app, usuarios más activos y actividad reciente',
description: 'Requiere el permiso users.view (o un token client_credentials, que actúa como servicio de confianza).',
security: [['oauth2' => ['users:read']]],
tags: ['Analytics'],
parameters: [
new OA\Parameter(name: 'days', in: 'query', required: false, description: 'Ventana en días (7-90, default 30)', schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(response: 200, description: 'Resumen de analíticas'),
new OA\Response(response: 403, description: 'Sin permiso users.view'),
]
)]
public function summary(Request $request)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.view'), 403);
$days = min(max((int) $request->integer('days', 30), 7), 90);
return response()->json(AnalyticsSummary::build($days));
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
use Laravel\Passport\Client;
use Laravel\Passport\Http\Middleware\CheckToken;
use OpenApi\Attributes as OA;
class AppController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [new Middleware(CheckToken::using('users:read'))];
}
#[OA\Get(
path: '/apps',
summary: 'Listar todas las apps (clientes OAuth) registradas en el sistema',
description: 'Requiere el permiso users.view (o un token client_credentials, que actúa como servicio de confianza). Se usa para armar el selector de "roles por app".',
security: [['oauth2' => ['users:read']]],
tags: ['Users'],
responses: [
new OA\Response(response: 200, description: 'Listado de apps'),
new OA\Response(response: 403, description: 'Sin permiso users.view'),
]
)]
public function index(Request $request)
{
$actor = $request->user('api');
abort_if($actor && ! $actor->can('users.view'), 403);
return response()->json(
Client::query()->where('revoked', false)->orderBy('name')->get(['id', 'name'])
);
}
}

View File

@@ -0,0 +1,71 @@
<?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 Laravel\Passport\Http\Middleware\CheckToken;
use OpenApi\Attributes as OA;
class AuthorizedAppController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
new Middleware(CheckToken::using('account:read'), only: ['index']),
new Middleware(CheckToken::using('account:write'), only: ['destroy']),
];
}
#[OA\Get(
path: '/authorized-apps',
summary: 'Listar apps de terceros a las que le diste acceso a tu cuenta',
security: [['oauth2' => ['account:read']]],
tags: ['Account'],
responses: [new OA\Response(response: 200, description: 'Listado de tokens/apps autorizadas')]
)]
public function index(Request $request)
{
$user = $this->requireUser($request);
$tokens = $user->tokens()
->where('revoked', false)
->with('client')
->get()
->reject(fn ($token) => $token->client->revoked || $token->client->hasGrantType('personal_access'))
->values();
return response()->json($tokens);
}
#[OA\Delete(
path: '/authorized-apps/{tokenId}',
summary: 'Revocar el acceso de una app de terceros',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
parameters: [new OA\Parameter(name: 'tokenId', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 204, description: 'Acceso revocado'),
new OA\Response(response: 404, description: 'No existe o no es tuyo'),
]
)]
public function destroy(Request $request, string $tokenId)
{
$token = $this->requireUser($request)->tokens()->where('id', $tokenId)->first();
abort_unless($token, 404);
$token->revoke();
$token->refreshToken?->revoke();
return response()->noContent();
}
private function requireUser(Request $request): User
{
return $request->user('api') ?? abort(422, __('messages.self_service_requires_user'));
}
}

View File

@@ -0,0 +1,170 @@
<?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\Str;
use Illuminate\Validation\Rule;
use Laravel\Passport\Client;
use Laravel\Passport\Http\Middleware\CheckToken;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Client',
type: 'object',
properties: [
new OA\Property(property: 'id', type: 'string', format: 'uuid'),
new OA\Property(property: 'name', type: 'string', example: 'Mi app Angular'),
new OA\Property(property: 'grant_types', type: 'array', items: new OA\Items(type: 'string'), example: ['authorization_code', 'refresh_token']),
new OA\Property(property: 'redirect_uris', type: 'array', items: new OA\Items(type: 'string'), example: ['https://mi-app.com/callback']),
new OA\Property(property: 'revoked', type: 'boolean'),
]
)]
class ClientController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
new Middleware(CheckToken::using('account:read'), only: ['index']),
new Middleware(CheckToken::using('account:write'), only: ['store', 'update', 'destroy']),
];
}
#[OA\Get(
path: '/clients',
summary: 'Listar tus clientes OAuth (apps que registraste)',
security: [['oauth2' => ['account:read']]],
tags: ['Account'],
responses: [
new OA\Response(response: 200, description: 'Listado de clientes', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Client'))),
new OA\Response(response: 422, description: 'El token no tiene un usuario asociado (ej. client_credentials)'),
]
)]
public function index(Request $request)
{
return response()->json(
$this->requireUser($request)->oauthApps()->where('revoked', false)->orderByDesc('created_at')->get()
);
}
#[OA\Post(
path: '/clients',
summary: 'Registrar un nuevo cliente OAuth (tu app)',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'grant_type'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'grant_type', type: 'string', enum: ['authorization_code', 'client_credentials', 'password']),
new OA\Property(property: 'redirect_uris', type: 'array', items: new OA\Items(type: 'string')),
new OA\Property(property: 'confidential', type: 'boolean', default: true),
]
)
),
responses: [
new OA\Response(response: 201, description: 'Cliente creado (incluye plain_secret una sola vez)'),
new OA\Response(response: 422, description: 'Datos inválidos o token sin usuario asociado'),
]
)]
public function store(Request $request)
{
$user = $this->requireUser($request);
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'grant_type' => ['required', Rule::in(['authorization_code', 'client_credentials', 'password'])],
'redirect_uris' => ['required_if:grant_type,authorization_code', 'array'],
'redirect_uris.*' => ['url'],
'confidential' => ['boolean'],
]);
$confidential = $request->boolean('confidential', true);
$grantTypes = match ($data['grant_type']) {
'authorization_code' => ['authorization_code', 'refresh_token'],
'password' => ['password', 'refresh_token'],
'client_credentials' => ['client_credentials'],
};
// client_credentials always needs a secret: there is no user present to prove identity.
if ($data['grant_type'] === 'client_credentials') {
$confidential = true;
}
$client = $user->oauthApps()->create([
'name' => $data['name'],
'secret' => $confidential ? Str::random(40) : null,
'provider' => null,
'redirect_uris' => $data['redirect_uris'] ?? [],
'grant_types' => $grantTypes,
'revoked' => false,
]);
return response()->json([
'client' => $client,
'plain_secret' => $client->plainSecret,
], 201);
}
#[OA\Put(
path: '/clients/{client}',
summary: 'Editar uno de tus clientes OAuth',
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: 'Cliente actualizado', content: new OA\JsonContent(ref: '#/components/schemas/Client')),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
]
)]
public function update(Request $request, Client $client)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'redirect_uris' => ['array'],
'redirect_uris.*' => ['url'],
]);
$client->update([
'name' => $data['name'],
'redirect_uris' => $data['redirect_uris'] ?? $client->redirect_uris,
]);
return response()->json($client);
}
#[OA\Delete(
path: '/clients/{client}',
summary: 'Revocar uno de tus clientes OAuth (y sus tokens)',
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: 204, description: 'Revocado'),
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
]
)]
public function destroy(Request $request, Client $client)
{
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
$client->update(['revoked' => true]);
$client->tokens()->update(['revoked' => true]);
return response()->noContent();
}
private function requireUser(Request $request): User
{
return $request->user('api') ?? abort(422, __('messages.self_service_requires_user'));
}
}

View File

@@ -0,0 +1,106 @@
<?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\Validation\Rule;
use Laravel\Passport\Http\Middleware\CheckToken;
use Laravel\Passport\Passport;
use Laravel\Passport\Token;
use OpenApi\Attributes as OA;
class PersonalAccessTokenController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
new Middleware(CheckToken::using('account:read'), only: ['index']),
new Middleware(CheckToken::using('account:write'), only: ['store', 'destroy']),
];
}
#[OA\Get(
path: '/personal-access-tokens',
summary: 'Listar tus Personal Access Tokens',
security: [['oauth2' => ['account:read']]],
tags: ['Account'],
responses: [new OA\Response(response: 200, description: 'Listado de tokens')]
)]
public function index(Request $request)
{
return response()->json(
$this->requireUser($request)->tokens()
->where('revoked', false)
->whereHas('client', fn ($q) => $q->whereJsonContains('grant_types', 'personal_access'))
->orderByDesc('created_at')
->get(['id', 'name', 'scopes', 'created_at', 'expires_at'])
);
}
#[OA\Post(
path: '/personal-access-tokens',
summary: 'Crear un Personal Access Token (tipo API key)',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'scopes', type: 'array', items: new OA\Items(type: 'string')),
]
)
),
responses: [new OA\Response(response: 201, description: 'Token creado (access_token solo se muestra esta vez)')]
)]
public function store(Request $request)
{
$user = $this->requireUser($request);
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'scopes' => ['array'],
'scopes.*' => [Rule::in(Passport::scopeIds())],
]);
$result = $user->createToken($data['name'], $data['scopes'] ?? []);
return response()->json([
'access_token' => $result->accessToken,
'token' => $result->token,
], 201);
}
#[OA\Delete(
path: '/personal-access-tokens/{tokenId}',
summary: 'Revocar un Personal Access Token',
security: [['oauth2' => ['account:write']]],
tags: ['Account'],
parameters: [new OA\Parameter(name: 'tokenId', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
responses: [
new OA\Response(response: 204, description: 'Revocado'),
new OA\Response(response: 404, description: 'No existe o no es tuyo'),
]
)]
public function destroy(Request $request, string $tokenId)
{
/** @var Token|null $token */
$token = $this->requireUser($request)->tokens()->where('id', $tokenId)->first();
abort_unless($token, 404);
$token->revoke();
return response()->noContent();
}
private function requireUser(Request $request): User
{
return $request->user('api') ?? abort(422, __('messages.self_service_requires_user'));
}
}

View File

@@ -0,0 +1,490 @@
<?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,
];
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\LoginEvent;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
public function setupStatus()
{
return response()->json([
'empty' => User::query()->count() === 0,
]);
}
public function register(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
// Instalación nueva (sin usuarios todavía): el primero en registrarse
// queda como admin, aprobado y logueado de una — no hay nadie más que
// pueda aprobarlo. De ahí en más, todo registro vuelve a requerir
// aprobación de un admin como siempre.
$isFirstUser = User::query()->count() === 0;
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
'is_active' => true,
'approved_at' => $isFirstUser ? now() : null,
]);
if ($isFirstUser) {
(new RolesAndPermissionsSeeder)->run();
$user->assignRole('admin');
Auth::login($user);
$request->session()->regenerate();
$this->recordSessionLogin($request, $user);
return response()->json([
'bootstrap' => true,
'user' => $this->present($user),
'intended' => $request->session()->pull('url.intended'),
]);
}
$user->assignRole('user');
return response()->json([
'pending' => true,
'message' => __('messages.registration_pending'),
], 201);
}
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
]);
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => __('messages.invalid_credentials'),
]);
}
if (! Auth::user()->is_active) {
Auth::logout();
throw ValidationException::withMessages([
'email' => __('messages.account_deactivated'),
]);
}
if (Auth::user()->isPending()) {
Auth::logout();
throw ValidationException::withMessages([
'email' => __('messages.account_pending_approval'),
]);
}
$request->session()->regenerate();
$this->recordSessionLogin($request, Auth::user());
return response()->json([
'user' => $this->present(Auth::user()),
'intended' => $request->session()->pull('url.intended'),
]);
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->noContent();
}
public function me(Request $request)
{
return response()->json(['user' => $this->present($request->user())]);
}
private function recordSessionLogin(Request $request, User $user): void
{
LoginEvent::create([
'user_id' => $user->id,
'client_id' => null,
'type' => 'session',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'created_at' => now(),
]);
}
private function present(User $user): array
{
return [
...$user->toArray(),
'roles' => $user->getRoleNames(),
'permissions' => $user->getAllPermissions()->pluck('name'),
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use OpenApi\Attributes as OA;
#[OA\Info(
version: '1.0.0',
title: 'OAuth2 Identity Server API',
description: 'Servidor de identidad/OAuth2 (Laravel Passport) para que aplicaciones de terceros se autentiquen y gestionen usuarios. Pensada para ser consumida por aplicaciones cliente de terceros (Angular, Flutter, NestJS, etc.) que quieran construir su propio panel sin depender de la sesión de Laravel.'
)]
#[OA\Server(url: '/api', description: 'API base URL')]
#[OA\SecurityScheme(
securityScheme: 'oauth2',
type: 'oauth2',
description: 'Servidor de autorización OAuth2 (Laravel Passport). Soporta Authorization Code + PKCE, Client Credentials y Password Grant.',
flows: [
new OA\Flow(
flow: 'authorizationCode',
authorizationUrl: '/oauth/authorize',
tokenUrl: '/oauth/token',
refreshUrl: '/oauth/token',
scopes: [
'users:read' => 'Leer usuarios',
'users:write' => 'Crear, editar y borrar usuarios',
'account:read' => 'Leer tus clientes OAuth, tokens y apps autorizadas',
'account:write' => 'Gestionar tus clientes OAuth, tokens y apps autorizadas',
]
),
new OA\Flow(
flow: 'clientCredentials',
tokenUrl: '/oauth/token',
scopes: [
'users:read' => 'Leer usuarios',
'users:write' => 'Crear, editar y borrar usuarios',
'account:read' => 'Leer tus clientes OAuth, tokens y apps autorizadas',
'account:write' => 'Gestionar tus clientes OAuth, tokens y apps autorizadas',
]
),
new OA\Flow(
flow: 'password',
tokenUrl: '/oauth/token',
scopes: [
'users:read' => 'Leer usuarios',
'users:write' => 'Crear, editar y borrar usuarios',
'account:read' => 'Leer tus clientes OAuth, tokens y apps autorizadas',
'account:write' => 'Gestionar tus clientes OAuth, tokens y apps autorizadas',
]
),
]
)]
abstract class Controller
{
//
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\OAuth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class AuthorizedAppController extends Controller
{
/**
* List third-party apps (non first-party clients) the user has granted access to.
*/
public function index(Request $request)
{
$tokens = $request->user()->tokens()
->where('revoked', false)
->with('client')
->get()
->reject(fn ($token) => $token->client->revoked || $token->client->hasGrantType('personal_access'))
->values();
return response()->json($tokens);
}
public function destroy(Request $request, string $tokenId)
{
$token = $request->user()->tokens()->where('id', $tokenId)->first();
abort_unless($token, 404);
$token->revoke();
$token->refreshToken?->revoke();
return response()->noContent();
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\OAuth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Laravel\Passport\Client;
class ClientController extends Controller
{
/**
* List the OAuth clients owned by the authenticated user.
*/
public function index(Request $request)
{
return response()->json(
$request->user()->oauthApps()->where('revoked', false)->orderByDesc('created_at')->get()
);
}
/**
* Create a new OAuth client (authorization_code+PKCE, client_credentials or password).
*/
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'grant_type' => ['required', Rule::in(['authorization_code', 'client_credentials', 'password'])],
'redirect_uris' => ['required_if:grant_type,authorization_code', 'array'],
'redirect_uris.*' => ['url'],
'confidential' => ['boolean'],
]);
$confidential = $request->boolean('confidential', true);
$grantTypes = match ($data['grant_type']) {
'authorization_code' => ['authorization_code', 'refresh_token'],
'password' => ['password', 'refresh_token'],
'client_credentials' => ['client_credentials'],
};
// client_credentials always needs a secret: there is no user present to prove identity.
if ($data['grant_type'] === 'client_credentials') {
$confidential = true;
}
$client = $request->user()->oauthApps()->create([
'name' => $data['name'],
'secret' => $confidential ? Str::random(40) : null,
'provider' => null,
'redirect_uris' => $data['redirect_uris'] ?? [],
'grant_types' => $grantTypes,
'revoked' => false,
]);
return response()->json([
'client' => $client,
'plain_secret' => $client->plainSecret,
], 201);
}
/**
* Update an existing client's name/redirect URIs.
*/
public function update(Request $request, Client $client)
{
abort_unless($client->owner_id === $request->user()->id, 404);
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'redirect_uris' => ['array'],
'redirect_uris.*' => ['url'],
]);
$client->update([
'name' => $data['name'],
'redirect_uris' => $data['redirect_uris'] ?? $client->redirect_uris,
]);
return response()->json($client);
}
/**
* Revoke (soft-delete) a client and its tokens.
*/
public function destroy(Request $request, Client $client)
{
abort_unless($client->owner_id === $request->user()->id, 404);
$client->update(['revoked' => true]);
$client->tokens()->update(['revoked' => true]);
return response()->noContent();
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\OAuth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Laravel\Passport\Passport;
use Laravel\Passport\Token;
class PersonalAccessTokenController extends Controller
{
public function index(Request $request)
{
return response()->json(
$request->user()->tokens()
->where('revoked', false)
->whereHas('client', fn ($q) => $q->whereJsonContains('grant_types', 'personal_access'))
->orderByDesc('created_at')
->get(['id', 'name', 'scopes', 'created_at', 'expires_at'])
);
}
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'scopes' => ['array'],
'scopes.*' => [Rule::in(Passport::scopeIds())],
]);
$result = $request->user()->createToken($data['name'], $data['scopes'] ?? []);
return response()->json([
'access_token' => $result->accessToken,
'token' => $result->token,
], 201);
}
public function destroy(Request $request, string $tokenId)
{
/** @var Token|null $token */
$token = $request->user()->tokens()->where('id', $tokenId)->first();
abort_unless($token, 404);
$token->revoke();
return response()->noContent();
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
private const SUPPORTED = ['es', 'en', 'ca'];
public function handle(Request $request, Closure $next): Response
{
$locale = $request->session()->get('locale', config('app.locale'));
App::setLocale(in_array($locale, self::SUPPORTED, true) ? $locale : config('app.locale'));
return $next($request);
}
}