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:
17
app/Http/Controllers/Admin/AnalyticsController.php
Normal file
17
app/Http/Controllers/Admin/AnalyticsController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
20
app/Http/Controllers/Admin/AppController.php
Normal file
20
app/Http/Controllers/Admin/AppController.php
Normal 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'])
|
||||
);
|
||||
}
|
||||
}
|
||||
198
app/Http/Controllers/Admin/UserController.php
Normal file
198
app/Http/Controllers/Admin/UserController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/Api/AnalyticsController.php
Normal file
43
app/Http/Controllers/Api/AnalyticsController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Api/AppController.php
Normal file
41
app/Http/Controllers/Api/AppController.php
Normal 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'])
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/Api/AuthorizedAppController.php
Normal file
71
app/Http/Controllers/Api/AuthorizedAppController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
170
app/Http/Controllers/Api/ClientController.php
Normal file
170
app/Http/Controllers/Api/ClientController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Api/PersonalAccessTokenController.php
Normal file
106
app/Http/Controllers/Api/PersonalAccessTokenController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
490
app/Http/Controllers/Api/UserController.php
Normal file
490
app/Http/Controllers/Api/UserController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
141
app/Http/Controllers/Auth/AuthController.php
Normal file
141
app/Http/Controllers/Auth/AuthController.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Controller.php
Normal file
55
app/Http/Controllers/Controller.php
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
36
app/Http/Controllers/OAuth/AuthorizedAppController.php
Normal file
36
app/Http/Controllers/OAuth/AuthorizedAppController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/OAuth/ClientController.php
Normal file
97
app/Http/Controllers/OAuth/ClientController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/OAuth/PersonalAccessTokenController.php
Normal file
51
app/Http/Controllers/OAuth/PersonalAccessTokenController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user