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

108
docs/despliegue.md Normal file
View File

@@ -0,0 +1,108 @@
# Despliegue en producción (Docker)
Stack: `app` (PHP-FPM 8.3) + `nginx` (estáticos y proxy) + `db` (PostgreSQL 16) + `redis`
(cache/sesión/colas) + `queue` (worker) + `scheduler`.
## 1. Preparar el `.env`
```bash
cp .env.production.example .env
```
Editar `.env` y completar como mínimo:
- `APP_URL` — dominio real, con `https://`.
- `DB_PASSWORD` — contraseña de PostgreSQL (usada también por el servicio `db`).
- `SESSION_DOMAIN` — dominio (o `.tu-dominio.com` si hay subdominios).
- `APP_KEY` — generar uno nuevo, no reutilizar el de desarrollo:
```bash
docker run --rm -v "$PWD":/app -w /app composer:2 sh -c \
"php -r \"echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;\""
```
Pegar el resultado en `APP_KEY=`.
## 2. Levantar el stack
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
En el primer arranque, el contenedor `app` (ver `docker/php/entrypoint.sh`):
1. Genera las claves RSA de Passport (`storage/oauth-private.key` / `.pub`) si no existen —
quedan persistidas en el volumen `app_storage`, **no se regeneran en cada deploy** (regenerarlas
invalidaría todos los tokens ya emitidos).
2. Corre `php artisan migrate --force`.
3. Cachea config/rutas/vistas y regenera la documentación de Swagger.
```bash
docker compose -f docker-compose.prod.yml logs -f app
```
## 3. Crear el primer usuario admin
**Recomendado para producción:** no correr `db:seed` (ese seeder crea datos de demo con
contraseñas conocidas). En su lugar, con la base de usuarios vacía, andá a `/register` y creá una
cuenta — al ser la primera, el sistema la marca automáticamente como **admin**, aprobada y con la
sesión iniciada (no hace falta que nadie la apruebe, porque todavía no existe ningún admin). Cualquier
registro posterior vuelve a requerir aprobación manual desde el panel de usuarios.
Alternativa solo para entornos de prueba/demo, con datos de ejemplo (usuarios, clientes OAuth, historial
de logins falso):
```bash
docker compose -f docker-compose.prod.yml exec app php artisan db:seed --force
```
Esto sí crea `admin@example.com` / `password` — **cambiar la contraseña de inmediato** si se usa
fuera de un entorno descartable.
## 4. TLS
Por defecto `nginx` sirve solo HTTP en el puerto 80 (`docker/nginx/default.conf`). Para HTTPS:
1. Conseguir certificado (por ejemplo con `certbot --webroot` apuntando a
`docker/nginx/certs`, o cualquier CA propia).
2. Colocar `fullchain.pem` y `privkey.pem` en `docker/nginx/certs/`.
3. Descomentar el bloque `server { listen 443 ... }` en `docker/nginx/default.conf` y el
`location /` que redirige a HTTPS.
4. Descomentar `"443:443"` en `docker-compose.prod.yml` (servicio `nginx`).
5. `docker compose -f docker-compose.prod.yml up -d --build nginx`.
## 5. Desplegar una actualización
```bash
git pull # o el mecanismo que uses para traer código nuevo
docker compose -f docker-compose.prod.yml up -d --build
```
Las claves de Passport, la base de datos y Redis viven en volúmenes con nombre
(`app_storage`, `db_data`, `redis_data`) — sobreviven a los rebuilds. El código y los assets del
frontend, en cambio, se hornean dentro de la imagen en cada build (no hay bind mounts de código en
producción).
## 6. Backups
Mínimo indispensable a respaldar:
- Volumen `db_data` (o mejor, `pg_dump` periódico contra el servicio `db`).
- Volumen `app_storage` — contiene, entre otras cosas, las claves de Passport. Perderlas invalida
todos los tokens emitidos hasta ese momento.
```bash
docker compose -f docker-compose.prod.yml exec db \
pg_dump -U "$DB_USERNAME" "$DB_DATABASE" > backup-$(date +%F).sql
```
## Servicios del compose
| Servicio | Rol |
|-------------|-----|
| `app` | PHP-FPM. Corre migraciones y cacheo al arrancar (`CONTAINER_ROLE=app`). |
| `queue` | `php artisan queue:work redis` — misma imagen que `app`, sin el paso de migración. |
| `scheduler` | `php artisan schedule:work` — reemplaza al cron del sistema. |
| `nginx` | Sirve `public/build` y hace proxy de `*.php` a `app:9000`. |
| `db` | PostgreSQL 16, datos en el volumen `db_data`. |
| `redis` | Cache, sesiones y colas, datos en el volumen `redis_data` (AOF activado). |

602
docs/integracion-iam.html Normal file
View File

@@ -0,0 +1,602 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Integrar tu app con este servidor de identidad (IAM)</title>
<style>
@page {
margin: 22mm 18mm;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
color: #1f2430;
font-size: 12.5px;
line-height: 1.55;
}
.cover {
height: 245mm;
display: flex;
flex-direction: column;
justify-content: center;
page-break-after: always;
}
.cover .badge {
display: inline-block;
background: #4f46e5;
color: white;
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
padding: 4px 12px;
border-radius: 999px;
margin-bottom: 18px;
width: fit-content;
}
.cover h1 {
font-size: 32px;
margin: 0 0 10px;
color: #111827;
}
.cover p.subtitle {
font-size: 15px;
color: #4b5563;
max-width: 440px;
margin: 0 0 28px;
}
.cover .meta {
font-size: 11.5px;
color: #9ca3af;
border-top: 1px solid #e5e7eb;
padding-top: 12px;
width: fit-content;
}
h1, h2, h3 { color: #111827; }
h2 {
font-size: 19px;
margin-top: 0;
padding-bottom: 8px;
border-bottom: 2px solid #4f46e5;
}
h3 {
font-size: 14.5px;
margin-bottom: 6px;
color: #4338ca;
}
section {
page-break-before: always;
padding-top: 4mm;
}
section:first-of-type { page-break-before: avoid; }
p { margin: 6px 0 10px; }
ul, ol { margin: 6px 0 12px; padding-left: 20px; }
li { margin-bottom: 4px; }
code {
background: #f1f0fb;
color: #4338ca;
padding: 1px 5px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
}
pre {
background: #1e1e2e;
color: #e2e2f5;
padding: 12px 14px;
border-radius: 8px;
overflow-x: auto;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 10.3px;
line-height: 1.5;
page-break-inside: avoid;
}
pre code { background: none; color: inherit; padding: 0; }
.lang-tag {
display: inline-block;
background: #111827;
color: #fff;
font-size: 10px;
font-weight: 600;
letter-spacing: .03em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 4px 4px 0 0;
margin-bottom: -1px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 16px;
font-size: 11.5px;
page-break-inside: avoid;
}
th, td {
border: 1px solid #e5e7eb;
padding: 6px 9px;
text-align: left;
vertical-align: top;
}
th {
background: #f5f5fb;
color: #374151;
font-weight: 600;
}
tr:nth-child(even) td { background: #fafafa; }
.box {
background: #f5f5fb;
border-left: 4px solid #4f46e5;
padding: 10px 14px;
border-radius: 6px;
margin: 12px 0;
font-size: 11.8px;
}
.box.warn {
background: #fffbeb;
border-left-color: #d97706;
}
.box.danger {
background: #fef2f2;
border-left-color: #dc2626;
}
.box strong { color: #111827; }
.toc { list-style: none; padding-left: 0; }
.toc li {
display: flex;
justify-content: space-between;
border-bottom: 1px dotted #d1d5db;
padding: 5px 0;
font-size: 13px;
}
.toc .n { color: #4f46e5; font-weight: 600; margin-right: 8px; }
.pill {
display: inline-block;
background: #eef2ff;
color: #4338ca;
border-radius: 4px;
padding: 1px 7px;
font-size: 10.5px;
font-weight: 600;
margin-right: 4px;
}
.stack-grid {
display: flex;
gap: 10px;
margin: 12px 0 18px;
}
.stack-card {
flex: 1;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 10px 12px;
}
.stack-card h4 {
margin: 0 0 4px;
font-size: 12px;
color: #4338ca;
}
.stack-card p {
margin: 0;
font-size: 10.8px;
color: #6b7280;
}
footer.pagefoot {
margin-top: 22px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
font-size: 10px;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="cover">
<span class="badge">Guía para desarrolladores</span>
<h1>Integrar tu app con este servidor de identidad</h1>
<p class="subtitle">Cómo conectar Angular, NestJS, Flutter (o cualquier otro stack) a este servidor OAuth2
para usarlo como IAM centralizado: login, usuarios, roles globales y roles por-app.</p>
<div class="meta">
Laravel 12 · Passport 13 (OAuth2, tokens JWT firmados RS256)<br>
Generado el 25 de julio de 2026
</div>
</div>
<section>
<h2>Índice</h2>
<ul class="toc">
<li><span><span class="n">1.</span>Qué te da este servidor</span></li>
<li><span><span class="n">2.</span>Paso 1 — Registrar tu app como cliente OAuth</span></li>
<li><span><span class="n">3.</span>Elegí el flujo según tu arquitectura</span></li>
<li><span><span class="n">4.</span>Angular (SPA) — Authorization Code + PKCE</span></li>
<li><span><span class="n">5.</span>NestJS — backend con login de usuarios</span></li>
<li><span><span class="n">6.</span>NestJS — servicio backend-a-backend (Client Credentials)</span></li>
<li><span><span class="n">7.</span>Flutter / apps móviles</span></li>
<li><span><span class="n">8.</span>Validar el token en tu backend</span></li>
<li><span><span class="n">9.</span>Roles: global vs. por-app</span></li>
<li><span><span class="n">10.</span>Refrescar y revocar tokens</span></li>
<li><span><span class="n">11.</span>Checklist de seguridad</span></li>
<li><span><span class="n">12.</span>Referencia rápida</span></li>
</ul>
</section>
<section>
<h2>1. Qué te da este servidor</h2>
<p>
Es un <strong>servidor de identidad OAuth2</strong> (Laravel Passport). En vez de que cada app tuya tenga su
propia tabla de usuarios y su propio login, todas hablan con este servidor: acá vive el usuario, la
contraseña, y quién es qué.
</p>
<ul>
<li><strong>Login centralizado</strong> — un solo lugar donde el usuario pone su contraseña. Tu app nunca
la ve: recibe un <em>access token</em> después de que el usuario aprueba el acceso.</li>
<li><strong>Datos de usuario vía API</strong> — <code>GET /api/user</code> con el token te devuelve nombre,
email, y sus roles.</li>
<li><strong>Roles globales y roles por-app</strong> — un usuario puede ser <code>admin</code> en el sistema
en general, pero además tener un rol <em>específico de tu app</em> (por ejemplo <code>editor</code> en la
tuya y nada en la de otro equipo). Ver sección 9.</li>
<li><strong>Gestión completa por API</strong> — si querés armar tu propio panel de administración de
usuarios (en vez de usar el que trae este servidor), toda la funcionalidad está expuesta en
<code>/api/*</code> con Bearer token. No hace falta sesión ni cookies de este servidor.</li>
</ul>
<div class="box">
<strong>Dos capas de permisos, no confundirlas:</strong> los <em>scopes</em> del token OAuth (
<code>users:read</code>, <code>users:write</code>...) definen qué puede hacer <em>ese token</em> contra la
API de este servidor. Los <em>roles por-app</em> definen qué puede hacer <em>ese usuario dentro de tu app</em>
— son datos que vos leés e interpretás como quieras, este servidor no te impone nada sobre cómo usarlos.
</div>
</section>
<section>
<h2>2. Paso 1 — Registrar tu app como cliente OAuth</h2>
<p>Cada aplicación que se conecte necesita ser un <em>cliente</em> registrado. Se hace una sola vez, desde el
panel (<code>/clients</code>, con cualquier cuenta) o por API:</p>
<pre><code>curl -X POST http://localhost:8000/api/clients \
-H "Authorization: Bearer {tu_access_token}" \
-H "Content-Type: application/json" \
-d '{
"name": "Mi App Angular",
"grant_type": "authorization_code",
"redirect_uris": ["https://mi-app.com/callback"],
"confidential": false
}'</code></pre>
<p>La respuesta trae <code>client.id</code> (el <code>client_id</code>) y, si es confidencial,
<code>plain_secret</code> — <strong>se muestra una sola vez</strong>, guardalo ya.</p>
<table>
<tr><th>Campo</th><th>Qué significa</th></tr>
<tr><td><code>grant_type</code></td><td><code>authorization_code</code>, <code>client_credentials</code> o <code>password</code> — ver sección 3 para elegir.</td></tr>
<tr><td><code>redirect_uris</code></td><td>A dónde te devuelve el navegador después del login. Tiene que matchear exacto con lo que mandes en <code>/oauth/authorize</code>.</td></tr>
<tr><td><code>confidential</code></td><td><code>false</code> = cliente público, sin secret (SPA, apps móviles — usan PKCE en su lugar). <code>true</code> = confidencial, con secret (backends que pueden guardar un secreto de forma segura).</td></tr>
</table>
</section>
<section>
<h2>3. Elegí el flujo según tu arquitectura</h2>
<div class="stack-grid">
<div class="stack-card">
<h4>SPA pura (Angular, React, Vue)</h4>
<p>Sin backend propio, corre 100% en el navegador. <strong>Authorization Code + PKCE</strong>, cliente
público. El token queda en el navegador.</p>
</div>
<div class="stack-card">
<h4>App con backend (NestJS, Express)</h4>
<p>El backend maneja el login por vos (patrón BFF). <strong>Authorization Code</strong>, cliente
confidencial — el secret vive en el backend, nunca llega al navegador.</p>
</div>
<div class="stack-card">
<h4>App móvil (Flutter, RN)</h4>
<p><strong>Authorization Code + PKCE</strong>, cliente público, con un redirect por deep link / custom
scheme.</p>
</div>
<div class="stack-card">
<h4>Servicio sin usuario final</h4>
<p>Un cron, un worker, otro backend que llama a la API por su cuenta. <strong>Client Credentials</strong>.</p>
</div>
</div>
<div class="box">
<strong>Password Grant existe pero evitalo</strong> salvo que sea estrictamente first-party (una app que vos
mismo controlás de punta a punta): tu app le pide el usuario y contraseña directamente al usuario y se los
manda a este servidor. Ningún cliente de terceros debería tocar la contraseña del usuario.
</div>
</section>
<section>
<h2>4. Angular (SPA) — Authorization Code + PKCE</h2>
<p>Ejemplo con <code>angular-oauth2-oidc</code> (también podés implementarlo a mano, son cuatro pasos: generar
verifier/challenge, redirigir, recibir el <code>code</code>, canjearlo).</p>
<span class="lang-tag">app.config.ts</span>
<pre><code>import { provideOAuthClient, OAuthModule } from 'angular-oauth2-oidc';
export const authConfig = {
issuer: 'http://localhost:8000',
loginUrl: 'http://localhost:8000/oauth/authorize',
tokenEndpoint: 'http://localhost:8000/oauth/token',
redirectUri: window.location.origin + '/callback',
clientId: '{tu_client_id}',
responseType: 'code',
scope: 'users:read account:read',
useHttpBasicAuth: false,
disablePKCE: false, // clave: PKCE prendido, es cliente público
oidc: false, // este servidor es OAuth2 puro, no expone id_token/OIDC
};</code></pre>
<span class="lang-tag">auth.service.ts</span>
<pre><code>import { Injectable } from '@angular/core';
import { OAuthService } from 'angular-oauth2-oidc';
@Injectable({ providedIn: 'root' })
export class AuthService {
constructor(private oauth: OAuthService) {}
login() {
this.oauth.initCodeFlow(); // redirige a /oauth/authorize con PKCE
}
async me() {
const token = this.oauth.getAccessToken();
const res = await fetch('http://localhost:8000/api/user', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json(); // { id, name, email, roles, app_roles, ... }
}
}</code></pre>
<p>Si preferís no traer una librería, el flujo manual es: generar <code>code_verifier</code> (random) y
<code>code_challenge</code> (SHA-256 + base64url del verifier), armar la URL de
<code>/oauth/authorize</code> con esos parámetros + <code>state</code>, y en el callback hacer
<code>POST /oauth/token</code> con <code>grant_type=authorization_code</code>, el <code>code</code> recibido y
el <code>code_verifier</code> original.</p>
</section>
<section>
<h2>5. NestJS — backend con login de usuarios</h2>
<p>Tu backend actúa de cliente <strong>confidencial</strong> (guarda el secret) y maneja el flujo Authorization
Code por el usuario — patrón "backend for frontend": el navegador nunca ve el access token, solo una cookie de
sesión propia de tu app.</p>
<span class="lang-tag">auth.controller.ts</span>
<pre><code>@Controller('auth')
export class AuthController {
private readonly IAM = 'http://localhost:8000';
private readonly CLIENT_ID = process.env.IAM_CLIENT_ID;
private readonly CLIENT_SECRET = process.env.IAM_CLIENT_SECRET;
@Get('login')
login(@Res() res: Response) {
const params = new URLSearchParams({
client_id: this.CLIENT_ID,
redirect_uri: 'https://mi-backend.com/auth/callback',
response_type: 'code',
scope: 'users:read',
state: crypto.randomUUID(),
});
res.redirect(`${this.IAM}/oauth/authorize?${params}`);
}
@Get('callback')
async callback(@Query('code') code: string, @Session() session: Record<string, any>) {
const { data } = await axios.post(`${this.IAM}/oauth/token`, {
grant_type: 'authorization_code',
client_id: this.CLIENT_ID,
client_secret: this.CLIENT_SECRET,
code,
redirect_uri: 'https://mi-backend.com/auth/callback',
});
session.accessToken = data.access_token; // guardalo del lado del servidor
session.refreshToken = data.refresh_token;
}
}</code></pre>
<p>Como es cliente confidencial, no hace falta PKCE (aunque no molesta agregarlo igual). El token queda
guardado en la sesión del backend, nunca expuesto al navegador.</p>
</section>
<section>
<h2>6. NestJS — servicio backend-a-backend (Client Credentials)</h2>
<p>Para un servicio que consulta/gestiona usuarios por su cuenta, sin un usuario logueado de por medio —
scheduler, worker, integración entre sistemas.</p>
<span class="lang-tag">iam-client.service.ts</span>
<pre><code>@Injectable()
export class IamClientService {
private token: string | null = null;
private expiresAt = 0;
private async getToken(): Promise<string> {
if (this.token && Date.now() < this.expiresAt) return this.token;
const { data } = await axios.post('http://localhost:8000/oauth/token', {
grant_type: 'client_credentials',
client_id: process.env.IAM_SERVICE_CLIENT_ID,
client_secret: process.env.IAM_SERVICE_CLIENT_SECRET,
scope: 'users:read users:write',
});
this.token = data.access_token;
this.expiresAt = Date.now() + (data.expires_in - 60) * 1000; // renovar 1 min antes
return this.token;
}
async listUsers() {
const token = await this.getToken();
const { data } = await axios.get('http://localhost:8000/api/users', {
headers: { Authorization: `Bearer ${token}` },
});
return data;
}
}</code></pre>
<div class="box">
Un token de <code>client_credentials</code> no tiene usuario asociado: actúa como "servicio de confianza".
En este servidor eso significa que puede usar <code>/api/users</code> y <code>/api/apps</code> con el
scope como único chequeo (sin necesitar un permiso de usuario admin) — pero <strong>no</strong> puede tocar
recursos "de cuenta propia" (<code>/api/clients</code>, personal access tokens, apps autorizadas), porque
esos son inherentemente de un usuario y no tiene ninguno asociado.
</div>
</section>
<section>
<h2>7. Flutter / apps móviles</h2>
<p>Con el paquete <code>flutter_appauth</code> (implementa Authorization Code + PKCE con el navegador del
sistema, que es lo recomendado — evita usar un WebView embebido).</p>
<span class="lang-tag">auth_service.dart</span>
<pre><code>final appAuth = FlutterAppAuth();
Future&lt;String?&gt; login() async {
final result = await appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
'{tu_client_id}',
'com.miapp://callback', // custom scheme registrado en tu app
serviceConfiguration: const AuthorizationServiceConfiguration(
authorizationEndpoint: 'http://localhost:8000/oauth/authorize',
tokenEndpoint: 'http://localhost:8000/oauth/token',
),
scopes: ['users:read'],
),
);
return result.accessToken;
}
Future&lt;Map&lt;String, dynamic&gt;&gt; me(String token) async {
final res = await http.get(
Uri.parse('http://localhost:8000/api/user'),
headers: {'Authorization': 'Bearer $token'},
);
return jsonDecode(res.body);
}</code></pre>
<p>El <code>redirect_uri</code> (<code>com.miapp://callback</code>) tiene que estar registrado exactamente
igual en el cliente OAuth (sección 2) y configurado en tu app nativa (Android: intent filter; iOS: URL scheme
en el <code>Info.plist</code>).</p>
</section>
<section>
<h2>8. Validar el token en tu backend</h2>
<p>Cuando tu backend recibe un request con <code>Authorization: Bearer {token}</code> y necesita confirmar que
es válido, hay dos caminos:</p>
<h3>Opción A — Preguntarle a este servidor (más simple)</h3>
<pre><code>GET /api/user
Authorization: Bearer {token}</code></pre>
<p>Si responde <code>200</code> con los datos del usuario, el token es válido. Es un request de red extra por
cada llamada, pero es siempre exacto: refleja revocaciones al instante.</p>
<h3>Opción B — Validar el JWT localmente (sin red)</h3>
<p>Los access tokens de este servidor son <strong>JWT firmados con RS256</strong> (no opacos). Tu backend puede
verificar la firma con la clave pública de Passport (<code>storage/oauth-public.key</code> en este proyecto —
pedísela al equipo que administra este servidor) sin llamarlo por red:</p>
<pre><code>// Node.js, con la librería "jsonwebtoken"
import jwt from 'jsonwebtoken';
import fs from 'fs';
const publicKey = fs.readFileSync('oauth-public.key');
function verify(token: string) {
return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// -> { aud, jti, iat, nbf, exp, sub, scopes: string[] }
}</code></pre>
<p>Claims relevantes: <code>sub</code> = id del usuario (ausente en tokens de <code>client_credentials</code>),
<code>aud</code> = <code>client_id</code> que lo pidió, <code>scopes</code> = array de scopes otorgados,
<code>exp</code> = expiración (unix timestamp).</p>
<div class="box warn">
<strong>Trade-off:</strong> la validación local es más rápida (sin red) pero no se entera si el token fue
<em>revocado</em> antes de su expiración natural — solo valida firma y expiración. Para casos sensibles
(borrar acceso ya mismo), usá la Opción A o combiná: validación local + chequeo periódico contra el
servidor.
</div>
</section>
<section>
<h2>9. Roles: global vs. por-app</h2>
<p><code>GET /api/user</code> (o <code>/api/users/{id}</code>) devuelve, entre otros campos:</p>
<pre><code>{
"id": 2,
"name": "Carla Gómez",
"email": "carla@example.com",
"roles": ["user"],
"app_roles": ["editor"]
}</code></pre>
<ul>
<li><code>roles</code> — el rol <strong>global</strong> del usuario en este servidor (<code>admin</code> o
<code>user</code>). No dice nada sobre qué puede hacer en <em>tu</em> app.</li>
<li><code>app_roles</code> — los roles que un admin le asignó a ese usuario <strong>específicamente para el
cliente OAuth que hizo la request</strong> (o sea: tu app). Se resuelve solo, según el <code>client_id</code>
del token usado — no hace falta pedir nada extra.</li>
</ul>
<p>Un admin asigna estos roles desde <code>/admin/users</code> → editar usuario → "Roles por app", eligiendo tu
app de la lista. Interpretalos como quieras del lado tuyo (un guard de NestJS, un route guard de Angular, etc.)
— este servidor solo los guarda y te los devuelve.</p>
<span class="lang-tag">nestjs guard (ejemplo)</span>
<pre><code>@Injectable()
export class AppRoleGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise&lt;boolean&gt; {
const req = context.switchToHttp().getRequest();
const user = await this.iam.me(req.headers.authorization);
return user.app_roles.includes('editor');
}
}</code></pre>
</section>
<section>
<h2>10. Refrescar y revocar tokens</h2>
<p>Los access tokens expiran (15 días por defecto en este servidor). Si tu cliente pidió el scope
<code>refresh_token</code> implícito del grant (viene incluido en <code>authorization_code</code> y
<code>password</code>), la respuesta de <code>/oauth/token</code> trae un <code>refresh_token</code> — usalo
así, sin volver a pasar por el login:</p>
<pre><code>curl -X POST http://localhost:8000/oauth/token \
-d grant_type=refresh_token \
-d refresh_token={tu_refresh_token} \
-d client_id={tu_client_id} \
-d client_secret={tu_client_secret} # si tu cliente es confidencial</code></pre>
<p><code>client_credentials</code> no tiene refresh token — simplemente pedís uno nuevo cuando el anterior
expira (ver el ejemplo de NestJS en la sección 6).</p>
<p>El usuario puede revocarte el acceso en cualquier momento desde <code>/authorized-apps</code> en el panel de
este servidor — a partir de ahí tus requests con ese token empiezan a devolver <code>401</code>.</p>
</section>
<section>
<h2>11. Checklist de seguridad</h2>
<ul>
<li>☐ Clientes públicos (SPA, móvil) — <strong>siempre PKCE</strong>, nunca les asignes un secret que
tendrían que embeber en código que corre en el dispositivo del usuario.</li>
<li>☐ Pedí solo los <strong>scopes que realmente necesitás</strong> — no hay scope por defecto en este
servidor, es intencional.</li>
<li>☐ En una SPA, si podés, no guardes el access token en <code>localStorage</code> (vulnerable a XSS) —
preferí el patrón backend-for-frontend (sección 5) con cookie <code>httpOnly</code>.</li>
<li>☐ Verificá siempre el parámetro <code>state</code> en el callback de Authorization Code (protección
CSRF del flujo).</li>
<li>☐ Los <code>client_secret</code> de clientes confidenciales van en variables de entorno del backend,
nunca en el repo ni en el bundle del frontend.</li>
<li>☐ Si tu servicio backend-a-backend solo necesita leer, pedile a un admin que te dé un cliente con scope
<code>users:read</code> nada más (no <code>users:write</code>).</li>
</ul>
</section>
<section>
<h2>12. Referencia rápida</h2>
<table>
<tr><th>Endpoint</th><th>Para qué</th></tr>
<tr><td><code>GET /oauth/authorize</code></td><td>Inicio del flujo Authorization Code (redirige al login/consentimiento).</td></tr>
<tr><td><code>POST /oauth/token</code></td><td>Canjear código, credenciales o refresh token por un access token.</td></tr>
<tr><td><code>GET /api/user</code></td><td>Whoami — usuario del token actual + roles global y por-app.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /api/users</code></td><td>Directorio de usuarios (scope <code>users:read</code>/<code>write</code>).</td></tr>
<tr><td><code>GET/POST/DELETE /api/users/{id}/app-roles</code></td><td>Roles por-app de un usuario (para asignarlos vos mismo por API, si tenés permiso).</td></tr>
<tr><td><code>GET /api/apps</code></td><td>Listado de apps (clientes OAuth) registradas.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /api/clients</code></td><td>Gestionar tus propios clientes OAuth por API.</td></tr>
</table>
<table>
<tr><th>Scope</th><th>Da acceso a</th></tr>
<tr><td><code>users:read</code></td><td>Leer usuarios y sus roles (global + por-app).</td></tr>
<tr><td><code>users:write</code></td><td>Crear/editar/borrar usuarios, asignar roles por-app.</td></tr>
<tr><td><code>account:read</code></td><td>Leer tus propios clientes OAuth, tokens y apps autorizadas.</td></tr>
<tr><td><code>account:write</code></td><td>Gestionar tus propios clientes OAuth, tokens y apps autorizadas.</td></tr>
</table>
<p>Documentación interactiva (Swagger, para probar todo esto desde el navegador) en
<code>/api/documentation</code>. Manual completo del panel de administración en
<code>docs/manual-usuario.pdf</code>.</p>
<footer class="pagefoot">Guía de integración IAM — generada automáticamente a partir del estado actual del proyecto.</footer>
</section>
</body>
</html>

BIN
docs/integracion-iam.pdf Normal file

Binary file not shown.

620
docs/manual-usuario.html Normal file
View File

@@ -0,0 +1,620 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>OAuth2 Server — Manual de uso</title>
<style>
@page {
margin: 22mm 18mm;
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
color: #1f2430;
font-size: 12.5px;
line-height: 1.55;
}
.cover {
height: 245mm;
display: flex;
flex-direction: column;
justify-content: center;
page-break-after: always;
}
.cover .badge {
display: inline-block;
background: #4f46e5;
color: white;
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
padding: 4px 12px;
border-radius: 999px;
margin-bottom: 18px;
width: fit-content;
}
.cover h1 {
font-size: 34px;
margin: 0 0 10px;
color: #111827;
}
.cover p.subtitle {
font-size: 15px;
color: #4b5563;
max-width: 420px;
margin: 0 0 28px;
}
.cover .meta {
font-size: 11.5px;
color: #9ca3af;
border-top: 1px solid #e5e7eb;
padding-top: 12px;
width: fit-content;
}
h1, h2, h3 { color: #111827; }
h2 {
font-size: 19px;
margin-top: 0;
padding-bottom: 8px;
border-bottom: 2px solid #4f46e5;
}
h3 {
font-size: 14.5px;
margin-bottom: 6px;
color: #4338ca;
}
section {
page-break-before: always;
padding-top: 4mm;
}
section:first-of-type { page-break-before: avoid; }
p { margin: 6px 0 10px; }
ul, ol { margin: 6px 0 12px; padding-left: 20px; }
li { margin-bottom: 4px; }
code {
background: #f1f0fb;
color: #4338ca;
padding: 1px 5px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
}
pre {
background: #1e1e2e;
color: #e2e2f5;
padding: 12px 14px;
border-radius: 8px;
overflow-x: auto;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 10.8px;
line-height: 1.5;
page-break-inside: avoid;
}
pre code { background: none; color: inherit; padding: 0; }
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 16px;
font-size: 11.5px;
page-break-inside: avoid;
}
th, td {
border: 1px solid #e5e7eb;
padding: 6px 9px;
text-align: left;
vertical-align: top;
}
th {
background: #f5f5fb;
color: #374151;
font-weight: 600;
}
tr:nth-child(even) td { background: #fafafa; }
.box {
background: #f5f5fb;
border-left: 4px solid #4f46e5;
padding: 10px 14px;
border-radius: 6px;
margin: 12px 0;
font-size: 11.8px;
}
.box.warn {
background: #fffbeb;
border-left-color: #d97706;
}
.box strong { color: #111827; }
.credentials {
display: flex;
gap: 12px;
margin: 12px 0 18px;
}
.cred-card {
flex: 1;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px 14px;
}
.cred-card h4 {
margin: 0 0 8px;
font-size: 12.5px;
color: #4338ca;
}
.cred-card div { margin-bottom: 3px; font-size: 11.5px; }
.cred-card .label { color: #6b7280; display: inline-block; width: 62px; }
.toc { list-style: none; padding-left: 0; }
.toc li {
display: flex;
justify-content: space-between;
border-bottom: 1px dotted #d1d5db;
padding: 5px 0;
font-size: 13px;
}
.toc .n { color: #4f46e5; font-weight: 600; margin-right: 8px; }
.pill {
display: inline-block;
background: #eef2ff;
color: #4338ca;
border-radius: 4px;
padding: 1px 7px;
font-size: 10.5px;
font-weight: 600;
margin-right: 4px;
}
footer.pagefoot {
margin-top: 22px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
font-size: 10px;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="cover">
<span class="badge">Documentación técnica</span>
<h1>OAuth2 Identity Server</h1>
<p class="subtitle">Manual de uso: puesta en marcha, panel de administración, flujos OAuth2 y API REST completa para construir cualquier cliente propio (Angular, Flutter, NestJS, etc.).</p>
<div class="meta">
Laravel 12 · Passport 13 · Vue 3 + TypeScript · SCSS/BEM · spatie/laravel-permission · L5-Swagger<br>
Generado el 25 de julio de 2026
</div>
</div>
<section>
<h2>Índice</h2>
<ul class="toc">
<li><span><span class="n">1.</span>Qué es esta app</span></li>
<li><span><span class="n">2.</span>Puesta en marcha</span></li>
<li><span><span class="n">3.</span>Usuarios por defecto (seed)</span></li>
<li><span><span class="n">4.</span>Uso de la interfaz web (panel de administración)</span></li>
<li><span><span class="n">5.</span>Analíticas: sesiones, logins y actividad</span></li>
<li><span><span class="n">6.</span>Roles, permisos y roles por app</span></li>
<li><span><span class="n">7.</span>Idioma (español / inglés / catalán)</span></li>
<li><span><span class="n">8.</span>Cómo conectar tu app (Angular, NestJS, Flutter, etc.)</span></li>
<li><span><span class="n">9.</span>API REST completa: construí tu propio panel admin</span></li>
<li><span><span class="n">10.</span>Documentación interactiva (Swagger)</span></li>
<li><span><span class="n">11.</span>Referencia rápida de rutas</span></li>
</ul>
</section>
<section>
<h2>1. Qué es esta app</h2>
<p>
Es un <strong>servidor de identidad / OAuth2</strong> (Laravel Passport) pensado para ser el centro único de
usuarios, autenticación y permisos de un conjunto de aplicaciones propias. No es una app de negocio con datos
propios: su único "recurso" son los <strong>usuarios</strong> y sus <strong>roles</strong>.
</p>
<p>Provee tres piezas:</p>
<ul>
<li><strong>Servidor de autorización OAuth2</strong> — login, pantalla de consentimiento, y emisión de tokens
con los cuatro flujos estándar: Authorization Code + PKCE, Client Credentials, Password Grant y Personal
Access Tokens.</li>
<li><strong>Panel de administración en Vue 3 + TypeScript</strong> — la UI que trae la app "de fábrica",
servida por el propio Laravel, autenticada por sesión. Estilos en SCSS con nomenclatura BEM
(<code>resources/scss/</code>), sin frameworks de utility-classes.</li>
<li><strong>API REST completa</strong> (<code>/api/*</code>) — el <em>mismo</em> conjunto de funciones del
panel Vue (usuarios, roles por app, clientes OAuth, personal access tokens, apps autorizadas), pero
autenticada 100% por token OAuth2 (sin sesión ni cookies). Con esto se puede construir <strong>otro admin
entero</strong> en Angular, Flutter, o cualquier otro cliente — no hace falta usar la SPA Vue.</li>
</ul>
<div class="box">
<strong>Idea central:</strong> cada aplicación externa que quiera loguear usuarios se registra acá como un
<em>cliente OAuth</em> ("app"). Un mismo usuario puede tener un rol global (admin/user) y, además, un rol
distinto <em>dentro de cada app</em> (por ejemplo: admin en la app de Angular, usuario normal en la de NestJS).
</div>
<div class="box">
<strong>Dos frentes, misma API subyacente:</strong> la SPA Vue (sesión + cookies, pensada para uso interno
en el navegador de este mismo servidor) y la API REST (Bearer token, pensada para cualquier cliente externo)
exponen las <em>mismas</em> capacidades. Elegí el que corresponda: sesión para la UI que ya viene incluida,
token para construir la tuya propia.
</div>
</section>
<section>
<h2>2. Puesta en marcha</h2>
<h3>Requisitos</h3>
<ul>
<li>PHP 8.4+, Composer</li>
<li>Node.js + npm</li>
<li>SQLite (por defecto, cero configuración — se puede cambiar en <code>.env</code>)</li>
</ul>
<h3>Instalación (primera vez)</h3>
<pre><code>composer install
npm install
php artisan migrate --seed
npm run build</code></pre>
<div class="box">
<strong>Frontend:</strong> TypeScript (<code>npm run type-check</code> corre <code>vue-tsc</code> sin
emitir archivos) y SCSS compilado por Vite (paquete <code>sass</code>) desde
<code>resources/scss/app.scss</code> — no hay ningún framework de CSS utility-first instalado.
</div>
<h3>Levantar todo en desarrollo</h3>
<pre><code>composer run dev</code></pre>
<p>Este comando levanta en paralelo: el servidor PHP (<code>php artisan serve</code>), Vite en modo watch, la
cola de trabajos y los logs. La app queda disponible en <code>http://localhost:8000</code>.</p>
<div class="box warn">
<strong>Reiniciar todo de cero:</strong> <code>php artisan migrate:fresh --seed</code> borra la base y
vuelve a crear los usuarios/clientes de ejemplo descriptos en la sección 3. Los <code>client_secret</code>
de los clientes OAuth de ejemplo se imprimen en la consola en ese momento — solo se muestran una vez.
</div>
</section>
<section>
<h2>3. Usuarios por defecto (seed)</h2>
<p>El seeder (<code>database/seeders/DatabaseSeeder.php</code>) crea una cuenta admin y algunos usuarios de
ejemplo para poder probar la app de inmediato:</p>
<div class="credentials">
<div class="cred-card">
<h4>🛡 Administrador</h4>
<div><span class="label">Email:</span> <code>admin@example.com</code></div>
<div><span class="label">Password:</span> <code>password</code></div>
<div><span class="label">Rol global:</span> <span class="pill">admin</span></div>
<div style="margin-top:6px; color:#6b7280;">Acceso al panel de administración de usuarios.</div>
</div>
<div class="cred-card">
<h4>👤 Usuario normal</h4>
<div><span class="label">Email:</span> <code>carla@example.com</code></div>
<div><span class="label">Password:</span> <code>password</code></div>
<div><span class="label">Rol global:</span> <span class="pill">user</span></div>
<div style="margin-top:6px; color:#6b7280;">Sin acceso al panel admin. Es <strong>admin</strong> dentro de la app "Demo SPA" (rol por-app de ejemplo). Junto con <code>martin@example.com</code> y <code>bea@example.com</code>, solo están para que la sección de analíticas tenga datos de entrada.</div>
</div>
</div>
<div class="box">
El seeder también crea <code>nuevo@example.com</code> / <code>password</code> sin aprobar, para que la
pestaña <strong>"Pendientes"</strong> del panel de usuarios (sección 4) no arranque vacía.
</div>
<div class="box">
<strong>Instalación sin seed (producción):</strong> si nunca corriste <code>db:seed</code>, no hay ningún
usuario en la base. En ese caso, la <strong>primera</strong> cuenta que se registre por <code>/register</code>
queda automáticamente como <strong>admin</strong>, aprobada y con la sesión iniciada — no hace falta que
nadie más la apruebe, porque todavía no existe ningún admin que pudiera hacerlo. La pantalla de registro
avisa de esto con un mensaje cuando detecta que la base está vacía. A partir del segundo registro, vuelve
a regir el flujo normal (pendiente de aprobación por un admin).
</div>
<h3>Clientes OAuth (apps) de ejemplo</h3>
<p>El seeder también registra apps de prueba, una por cada flujo soportado. El <code>client_id</code> es fijo
por ejecución (UUID nuevo cada <code>migrate:fresh</code>); el <code>client_secret</code> solo se muestra por
consola al sembrar — si lo perdiste, hay que crear un cliente nuevo desde el panel (sección 4) o volver a
sembrar.</p>
<table>
<tr><th>Nombre</th><th>Grant type</th><th>Uso</th></tr>
<tr><td>Demo SPA (Authorization Code + PKCE)</td><td><code>authorization_code</code></td><td>Cliente público (sin secret), para apps tipo SPA/móvil.</td></tr>
<tr><td>Demo Service (Client Credentials)</td><td><code>client_credentials</code></td><td>Comunicación máquina-a-máquina, sin usuario.</td></tr>
<tr><td>Demo First-Party App (Password Grant)</td><td><code>password</code></td><td>Apps propias de confianza, login directo usuario/contraseña.</td></tr>
<tr><td>Swagger UI</td><td><code>authorization_code</code>, <code>client_credentials</code></td><td>Para probar el botón "Authorize" de la documentación interactiva.</td></tr>
<tr><td>Personal Access Client</td><td><code>personal_access</code></td><td>Interno — necesario para poder emitir Personal Access Tokens.</td></tr>
</table>
</section>
<section>
<h2>4. Uso de la interfaz web (panel de administración)</h2>
<p>Todo se maneja desde <code>http://localhost:8000</code> (Vue Router, sin recargar página).</p>
<div class="box">
<strong>Modo oscuro:</strong> el ícono de sol/luna en la barra de navegación alterna entre claro y oscuro.
La preferencia se guarda en <code>localStorage</code> (por navegador, no por cuenta) y, si nunca la tocaste,
arranca siguiendo el modo del sistema operativo. El cambio se aplica antes del primer render — no hay
parpadeo del tema equivocado al cargar la página.
</div>
<h3>Login / Registro</h3>
<p><code>/login</code> y <code>/register</code>. El registro crea la cuenta con rol global <code>user</code>
automáticamente, pero <strong>no loguea al usuario</strong>: queda pendiente de aprobación. La pantalla muestra
un mensaje de "Cuenta creada" y el login devuelve error hasta que un admin la aprueba desde el panel de
usuarios (sección 4). <strong>Excepción:</strong> si la base de usuarios está vacía, esa primera cuenta se
crea como admin y queda logueada de una (ver sección 3).</p>
<h3>Dashboard</h3>
<p><code>/</code> — accesos rápidos a Clientes OAuth, Personal Access Tokens, Apps autorizadas y (si sos admin)
Usuarios.</p>
<h3>Clientes OAuth</h3>
<p><code>/clients</code> — cada usuario ve y gestiona <em>sus propios</em> clientes (apps que registró). Al
crear uno se elige:</p>
<ul>
<li><strong>Authorization Code + PKCE</strong>: hay que indicar redirect URIs; opcionalmente "confidencial"
(con secret) o público (solo PKCE, sin secret).</li>
<li><strong>Client Credentials</strong>: siempre confidencial, sin redirect URI.</li>
<li><strong>Password Grant</strong>: para apps propias de confianza.</li>
</ul>
<div class="box warn">El <code>client_secret</code> se muestra <strong>una sola vez</strong>, justo después de
crear el cliente. Copiarlo en ese momento.</div>
<h3>Personal Access Tokens</h3>
<p><code>/tokens</code> — tokens tipo "API key" para el propio usuario, sin pasar por el flujo de consentimiento.
Se elige nombre y scopes. El token completo también se muestra una única vez.</p>
<h3>Apps autorizadas</h3>
<p><code>/authorized-apps</code> — lista de apps de terceros a las que el usuario le dio acceso a su cuenta vía
<code>/oauth/authorize</code>, con opción de revocar el acceso en cualquier momento.</p>
<h3>Usuarios (solo admins)</h3>
<p><code>/admin/users</code> — alta, edición (nombre, email, contraseña, rol global, activo/desactivado) y baja
de cualquier usuario del sistema. Protecciones incluidas: no podés borrarte a vos mismo, no podés quitarte el
rol admin a vos mismo, no se puede borrar al último administrador. Un usuario desactivado no puede iniciar
sesión.</p>
<div class="box">
<strong>Borrado = papelera (soft delete).</strong> "Borrar" un usuario no lo elimina de la base: lo oculta
(desaparece de logins, listados y de cualquier chequeo de permisos) pero queda recuperable. El botón
<strong>"Ver papelera"</strong> arriba a la derecha del listado muestra los usuarios borrados, con dos
acciones por fila: <strong>Restaurar</strong> (vuelve a aparecer tal cual estaba) o <strong>Borrar
definitivamente</strong> (ahí sí, sin vuelta atrás).
</div>
<div class="box">
<strong>Registros pendientes de aprobación.</strong> Arriba a la derecha del listado hay tres pestañas:
<em>Ver activos</em>, <strong>Pendientes</strong> (con un contador si hay alguno) y <em>Ver papelera</em>.
Todo el que se registra por <code>/register</code> aparece en "Pendientes" hasta que un admin lo
<strong>aprueba</strong> (ahí ya puede iniciar sesión) o lo <strong>rechaza</strong> (va a la papelera,
igual que un borrado normal — se puede restaurar). Un usuario dado de alta directamente por un admin
(el formulario "Nuevo usuario" de esta misma pantalla) queda aprobado automáticamente.
</div>
<h3>Analíticas (solo admins)</h3>
<p><code>/admin/analytics</code> — ver sección 5.</p>
</section>
<section>
<h2>5. Analíticas: sesiones, logins y actividad</h2>
<p>
Pantalla de monitoreo para el admin: cada vez que Passport emite un access token (cualquier grant type) o alguien
inicia sesión en el panel, queda un registro en <code>login_events</code>. Con eso se arman:
</p>
<ul>
<li><strong>KPIs</strong>: usuarios totales, sesiones activas (tokens OAuth vigentes, no revocados ni
expirados), logins de hoy y de los últimos 7 días.</li>
<li><strong>Logins por día</strong> (últimos 7/30/90 días, elegible arriba a la derecha): barras apiladas —
logins de apps (OAuth) vs. logins al panel admin (sesión) — con tooltip al pasar el mouse sobre cada día.</li>
<li><strong>Logins por app</strong>: qué apps se usan más.</li>
<li><strong>Usuarios más activos</strong>: ranking por cantidad de logins en el período.</li>
<li><strong>Actividad reciente</strong>: tabla con los últimos eventos (usuario, app, tipo, IP, cuándo).</li>
</ul>
<div class="box">
El seeder de demo genera 30 días de actividad sintética para que estos gráficos no arranquen vacíos. En un
despliegue real, los datos son 100% eventos reales — no hay nada que "activar".
</div>
<p>Misma info disponible por API: <code>GET /api/analytics/summary?days=30</code> (scope
<code>users:read</code>, requiere el permiso <code>users.view</code> — igual que el resto del panel admin).</p>
</section>
<section>
<h2>6. Roles, permisos y roles por app</h2>
<h3>Roles globales</h3>
<p>Gobiernan el panel de administración de esta app (<code>spatie/laravel-permission</code>):</p>
<table>
<tr><th>Rol</th><th>Permisos</th></tr>
<tr><td><span class="pill">admin</span></td><td><code>users.view</code>, <code>users.create</code>, <code>users.update</code>, <code>users.delete</code></td></tr>
<tr><td><span class="pill">user</span></td><td>Ninguno extra — gestiona su propio perfil, sus clientes OAuth y sus tokens.</td></tr>
</table>
<h3>Roles por app</h3>
<p>Independientes del rol global: un admin le puede asignar a cualquier usuario un rol (<code>admin</code> o
<code>user</code>) <strong>específico de una app registrada</strong>, desde el panel de edición de usuario
("Roles por app"). Sirve para que, por ejemplo, un usuario sea <code>admin</code> dentro de la app Angular
pero solo <code>user</code> dentro de la app NestJS — sin tocar su rol global.</p>
<p>Esa información viaja en la respuesta de la API (<code>app_roles</code>, ver sección 8): cada app ve
únicamente el rol que ese usuario tiene <em>en ella</em>, resuelto automáticamente según el <code>client_id</code>
del token usado en la request.</p>
</section>
<section>
<h2>7. Idioma (español / inglés / catalán)</h2>
<p>El selector está arriba a la derecha en la barra de navegación (<strong>ES / EN / CA</strong>). Cambiarlo
hace un POST a <code>/web-api/locale</code>, guarda el idioma en la sesión y recarga la página — el servidor
decide el idioma con el que renderiza esa carga (mensajes de validación/autenticación vía
<code>laravel-lang</code>, más los textos propios de la app en <code>lang/{es,en,ca}/messages.php</code>).</p>
<table>
<tr><th>Idioma</th><th>Mensajes del servidor</th><th>Interfaz (SPA Vue)</th></tr>
<tr><td><strong>ES</strong> (español, por defecto)</td><td>✓</td><td>✓</td></tr>
<tr><td><strong>EN</strong> (inglés)</td><td>✓</td><td>✓ — traducción completa (nav, botones, formularios, tablas, gráficos)</td></tr>
<tr><td><strong>CA</strong> (catalán)</td><td>✓</td><td>✓ — traducción completa</td></tr>
</table>
<p>La interfaz usa <code>vue-i18n</code> con diccionarios en <code>resources/js/i18n/{es,en,ca}.js</code>
(168 claves cada uno). El locale de arranque del frontend se inyecta desde el servidor
(<code>window.__LOCALE__</code> en <code>app.blade.php</code>), así que la SPA nace ya en el idioma correcto
sin parpadeo.</p>
</section>
<section>
<h2>8. Cómo conectar tu app (Angular, NestJS, Flutter, etc.)</h2>
<h3>Paso 1 — Registrar la app</h3>
<p>Desde <code>/clients</code>, crear un cliente OAuth eligiendo el grant type según el tipo de app:</p>
<table>
<tr><th>Tipo de app</th><th>Grant recomendado</th></tr>
<tr><td>SPA (Angular, React) servida al navegador</td><td>Authorization Code + PKCE, cliente <strong>público</strong></td></tr>
<tr><td>Backend con secret propio (NestJS, Express)</td><td>Authorization Code, cliente <strong>confidencial</strong>, o Client Credentials si no hay usuario final</td></tr>
<tr><td>Servicio interno máquina-a-máquina</td><td>Client Credentials</td></tr>
</table>
<h3>Paso 2 — Login del usuario (Authorization Code + PKCE, ej. Angular)</h3>
<p>Redirigir el navegador a:</p>
<pre><code>GET /oauth/authorize
?client_id={client_id}
&redirect_uri={tu_callback}
&response_type=code
&scope=users:read users:write
&state={valor_random}
&code_challenge={challenge_pkce}
&code_challenge_method=S256</code></pre>
<p>El usuario loguea (o ya tiene sesión) y aprueba el acceso. Passport redirige a <code>redirect_uri</code>
con <code>?code=...&state=...</code>.</p>
<h3>Paso 3 — Canjear el código por un token</h3>
<pre><code>curl -X POST http://localhost:8000/oauth/token \
-d grant_type=authorization_code \
-d client_id={client_id} \
-d code={code} \
-d redirect_uri={tu_callback} \
-d code_verifier={verifier_pkce}</code></pre>
<h3>Alternativa — Client Credentials (ej. NestJS backend-a-backend)</h3>
<pre><code>curl -X POST http://localhost:8000/oauth/token \
-d grant_type=client_credentials \
-d client_id={client_id} \
-d client_secret={client_secret} \
-d scope="users:read"</code></pre>
<div class="box">Un token de <code>client_credentials</code> no tiene usuario asociado: actúa como
"servicio de confianza" y puede listar/crear/editar usuarios sin más chequeo que el scope del token.</div>
</section>
<section>
<h2>9. API REST completa: construí tu propio panel admin</h2>
<p>
Todo lo que hace la SPA Vue por sesión también está disponible por token OAuth2. Esto significa que un cliente
Angular, Flutter, o lo que sea, puede ser un <strong>admin completo</strong> — no solo consultar usuarios —
sin depender de la sesión de Laravel. Todos los endpoints requieren <code>Authorization: Bearer {access_token}</code>.
</p>
<h3>Usuarios y roles (scopes <code>users:read</code> / <code>users:write</code>)</h3>
<table>
<tr><th>Método</th><th>Ruta</th><th>Descripción</th></tr>
<tr><td>GET</td><td><code>/api/user</code></td><td>Whoami: usuario del token actual, con <code>roles</code> y <code>app_roles</code>.</td></tr>
<tr><td>GET</td><td><code>/api/users</code></td><td>Listar usuarios (requiere <code>users.view</code>, salvo client_credentials).</td></tr>
<tr><td>POST</td><td><code>/api/users</code></td><td>Crear usuario (requiere <code>users.create</code>).</td></tr>
<tr><td>GET</td><td><code>/api/users/{id}</code></td><td>Ver un usuario. Siempre permitido sobre uno mismo.</td></tr>
<tr><td>PUT</td><td><code>/api/users/{id}</code></td><td>Editar. Uno mismo puede cambiar nombre/email/contraseña; rol/estado y otros usuarios requieren <code>users.update</code>.</td></tr>
<tr><td>DELETE</td><td><code>/api/users/{id}</code></td><td>Borrar — soft delete, va a la papelera (requiere <code>users.delete</code>; no se puede autoeliminar).</td></tr>
<tr><td>GET</td><td><code>/api/users/trashed</code></td><td>Listar usuarios en la papelera (requiere <code>users.view</code>).</td></tr>
<tr><td>POST</td><td><code>/api/users/{id}/restore</code></td><td>Restaurar un usuario borrado (requiere <code>users.update</code>).</td></tr>
<tr><td>DELETE</td><td><code>/api/users/{id}/force</code></td><td>Borrado definitivo, sin vuelta atrás (requiere <code>users.delete</code>; el usuario tiene que estar en la papelera).</td></tr>
<tr><td>GET</td><td><code>/api/users/pending</code></td><td>Listar registros pendientes de aprobación (requiere <code>users.view</code>).</td></tr>
<tr><td>POST</td><td><code>/api/users/{id}/approve</code></td><td>Aprobar un registro pendiente (requiere <code>users.update</code>).</td></tr>
<tr><td>GET</td><td><code>/api/users/{id}/app-roles</code></td><td>Roles del usuario por app (propio siempre permitido; otros requieren <code>users.view</code>).</td></tr>
<tr><td>POST</td><td><code>/api/users/{id}/app-roles</code></td><td>Asignar un rol en una app (requiere <code>users.update</code>, sin excepción para uno mismo).</td></tr>
<tr><td>DELETE</td><td><code>/api/users/{id}/app-roles/{clientId}/{role}</code></td><td>Quitar un rol en una app (requiere <code>users.update</code>).</td></tr>
<tr><td>GET</td><td><code>/api/apps</code></td><td>Listar todas las apps registradas (requiere <code>users.view</code>) — para armar el selector de roles-por-app.</td></tr>
<tr><td>GET</td><td><code>/api/analytics/summary?days=30</code></td><td>KPIs, logins por día/app, usuarios más activos y actividad reciente (requiere <code>users.view</code>).</td></tr>
</table>
<h3>Ejemplo de respuesta de usuario</h3>
<pre><code>{
"id": 2,
"name": "Carla Gómez",
"email": "carla@example.com",
"is_active": true,
"roles": ["user"],
"app_roles": ["admin"],
"created_at": "2026-07-25T08:52:39.000000Z"
}</code></pre>
<p><code>roles</code> es el rol global; <code>app_roles</code> es el rol de ese usuario específicamente en la
app dueña del token usado para la request (útil para que cada app decida qué mostrar/permitir sin tener su
propio sistema de permisos). El campo <code>deleted_at</code> solo aparece (no-null) en las respuestas de
<code>/api/users/trashed</code>.</p>
<h3>Cuenta propia: clientes, tokens y apps autorizadas (scopes <code>account:read</code> / <code>account:write</code>)</h3>
<p>Equivalente por token a <code>/web-api/clients</code>, <code>/personal-access-tokens</code> y
<code>/authorized-apps</code> de la sección 4 — siempre sobre el usuario dueño del token (no aplica a
client_credentials, que no tiene usuario asociado):</p>
<table>
<tr><th>Método</th><th>Ruta</th><th>Descripción</th></tr>
<tr><td>GET</td><td><code>/api/clients</code></td><td>Listar tus clientes OAuth (apps que registraste).</td></tr>
<tr><td>POST</td><td><code>/api/clients</code></td><td>Registrar un cliente nuevo (devuelve <code>plain_secret</code> una sola vez).</td></tr>
<tr><td>PUT</td><td><code>/api/clients/{id}</code></td><td>Editar nombre/redirect URIs.</td></tr>
<tr><td>DELETE</td><td><code>/api/clients/{id}</code></td><td>Revocar el cliente y sus tokens.</td></tr>
<tr><td>GET</td><td><code>/api/personal-access-tokens</code></td><td>Listar tus Personal Access Tokens.</td></tr>
<tr><td>POST</td><td><code>/api/personal-access-tokens</code></td><td>Crear uno (el token se muestra una sola vez).</td></tr>
<tr><td>DELETE</td><td><code>/api/personal-access-tokens/{id}</code></td><td>Revocarlo.</td></tr>
<tr><td>GET</td><td><code>/api/authorized-apps</code></td><td>Apps de terceros a las que le diste acceso.</td></tr>
<tr><td>DELETE</td><td><code>/api/authorized-apps/{tokenId}</code></td><td>Revocarle el acceso a una.</td></tr>
</table>
<h3>Scopes disponibles</h3>
<table>
<tr><th>Scope</th><th>Permite</th></tr>
<tr><td><code>users:read</code></td><td>Leer usuarios y roles por app</td></tr>
<tr><td><code>users:write</code></td><td>Crear, editar y borrar usuarios; asignar roles por app</td></tr>
<tr><td><code>account:read</code></td><td>Leer tus clientes OAuth, tokens y apps autorizadas</td></tr>
<tr><td><code>account:write</code></td><td>Gestionar tus clientes OAuth, tokens y apps autorizadas</td></tr>
</table>
<p>No hay scope por defecto: cada app debe pedir explícitamente los que necesita al iniciar el flujo OAuth
(ej. <code>scope=users:read users:write account:read account:write</code> para un admin completo).</p>
<div class="box warn">
<strong>Client credentials y recursos self-service:</strong> un token <code>client_credentials</code> no
tiene usuario asociado, así que <code>/api/clients</code>, <code>/api/personal-access-tokens</code> y
<code>/api/authorized-apps</code> le devuelven <code>422</code> (no hay "cuenta propia" que gestionar).
Sí puede usar <code>/api/users</code> y <code>/api/apps</code> como servicio de confianza.
</div>
</section>
<section>
<h2>10. Documentación interactiva (Swagger)</h2>
<p>Disponible en <code>http://localhost:8000/api/documentation</code>. Incluye el botón <strong>Authorize</strong>
para probar los tres flujos (Authorization Code, Client Credentials, Password) directamente desde el navegador,
usando el cliente de ejemplo <em>"Swagger UI"</em> sembrado (sección 3).</p>
<p>El JSON crudo de la especificación OpenAPI está en <code>/docs</code>.</p>
</section>
<section>
<h2>11. Referencia rápida de rutas</h2>
<h3>Interfaz web (sesión, uso interno de esta app)</h3>
<table>
<tr><th>Ruta</th><th>Qué hace</th></tr>
<tr><td><code>POST /web-api/register</code></td><td>Registro — crea la cuenta pendiente, no loguea.</td></tr>
<tr><td><code>POST /web-api/login</code> / <code>/logout</code></td><td>Autenticación por sesión.</td></tr>
<tr><td><code>GET /web-api/me</code></td><td>Usuario logueado (con roles/permisos).</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /web-api/clients</code></td><td>Clientes OAuth propios.</td></tr>
<tr><td><code>GET/POST/DELETE /web-api/personal-access-tokens</code></td><td>Personal Access Tokens propios.</td></tr>
<tr><td><code>GET/DELETE /web-api/authorized-apps</code></td><td>Apps de terceros autorizadas.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /web-api/admin/users</code></td><td>Administración de usuarios (admin).</td></tr>
<tr><td><code>GET /web-api/admin/users/trashed</code></td><td>Usuarios en la papelera (admin).</td></tr>
<tr><td><code>POST /web-api/admin/users/{id}/restore</code></td><td>Restaurar un usuario borrado (admin).</td></tr>
<tr><td><code>DELETE /web-api/admin/users/{id}/force</code></td><td>Borrado definitivo (admin).</td></tr>
<tr><td><code>GET /web-api/admin/users/pending</code></td><td>Registros pendientes de aprobación (admin).</td></tr>
<tr><td><code>POST /web-api/admin/users/{id}/approve</code></td><td>Aprobar un registro pendiente (admin).</td></tr>
<tr><td><code>GET /web-api/admin/apps</code></td><td>Listado de todas las apps registradas (admin).</td></tr>
<tr><td><code>GET/POST/DELETE /web-api/admin/users/{id}/app-roles</code></td><td>Roles por app de un usuario (admin).</td></tr>
<tr><td><code>GET /web-api/admin/analytics/summary</code></td><td>Sesiones, logins y actividad (admin).</td></tr>
<tr><td><code>POST /web-api/locale</code></td><td>Cambiar idioma (es/en/ca).</td></tr>
</table>
<h3>OAuth2 (Passport)</h3>
<table>
<tr><th>Ruta</th><th>Qué hace</th></tr>
<tr><td><code>GET /oauth/authorize</code></td><td>Inicio del flujo Authorization Code (pantalla de consentimiento).</td></tr>
<tr><td><code>POST /oauth/token</code></td><td>Emisión/renovación de tokens (todos los grant types).</td></tr>
</table>
<h3>API REST (Bearer token, para apps externas — Angular, Flutter, lo que sea)</h3>
<table>
<tr><th>Ruta</th><th>Qué hace</th></tr>
<tr><td><code>GET /api/user</code></td><td>Whoami.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /api/users</code></td><td>Gestión de usuarios (DELETE = papelera).</td></tr>
<tr><td><code>GET /api/users/trashed</code></td><td>Usuarios en la papelera.</td></tr>
<tr><td><code>POST /api/users/{id}/restore</code></td><td>Restaurar un usuario borrado.</td></tr>
<tr><td><code>DELETE /api/users/{id}/force</code></td><td>Borrado definitivo.</td></tr>
<tr><td><code>GET /api/users/pending</code></td><td>Registros pendientes de aprobación.</td></tr>
<tr><td><code>POST /api/users/{id}/approve</code></td><td>Aprobar un registro pendiente.</td></tr>
<tr><td><code>GET/POST/DELETE /api/users/{id}/app-roles</code></td><td>Roles por app de un usuario.</td></tr>
<tr><td><code>GET /api/apps</code></td><td>Listado de apps registradas.</td></tr>
<tr><td><code>GET /api/analytics/summary</code></td><td>Sesiones, logins y actividad.</td></tr>
<tr><td><code>GET/POST/PUT/DELETE /api/clients</code></td><td>Tus clientes OAuth.</td></tr>
<tr><td><code>GET/POST/DELETE /api/personal-access-tokens</code></td><td>Tus Personal Access Tokens.</td></tr>
<tr><td><code>GET/DELETE /api/authorized-apps</code></td><td>Apps de terceros que autorizaste.</td></tr>
</table>
<footer class="pagefoot">OAuth2 Identity Server — Manual de uso · generado automáticamente a partir del estado actual del proyecto.</footer>
</section>
</body>
</html>

BIN
docs/manual-usuario.pdf Normal file

Binary file not shown.