Auth, API keys, webhooks, DI y multi-tenancy¶
Autenticación¶
Login¶
POST /auth/login
Content-Type: application/json
{"usuario": "email@example.com", "clave": "password"}
Devuelve access_token (JWT) y refresh_token (opaco, guardado en DB).
El payload del JWT contiene sub (ID de usuario), tenant_id y expiración. TTL por defecto: 86400 segundos (configurable vía JWT_TTL).
Refresh de token¶
Emite un nuevo par access_token + refresh_token. El refresh token viejo se borra de inmediato (rotación — cada token es de un solo uso).
Logout¶
POST /auth/logout
Authorization: Bearer <access_token>
Content-Type: application/json
{"refresh_token": "a8f3c1d9..."} ← opcional, también invalida el refresh token
Impersonación (solo admin)¶
POST /auth/impersonate
Authorization: Bearer <admin_access_token>
Content-Type: application/json
{"target_id": 42}
- Requiere
AuthMiddleware + AdminMiddleware + TenantMiddleware - El admin solo puede suplantar usuarios dentro de su propio tenant
- Devuelve un JWT firmado como el usuario objetivo
Rate limiting¶
Los intentos de login se trackean por usuario usando APCu. Tras 5 intentos fallidos la cuenta queda bloqueada 5 minutos (RateLimitException → 429). Si APCu no está instalado, el rate limiting se saltea silenciosamente.
Petición autenticada¶
AuthMiddleware resuelve la petición a través de guards (JWT primero, luego API
key) y produce un Principal unificado. Por retrocompatibilidad sigue llamando
$request->setUser($payload), así que $request->user(),
TenantMiddleware y PermissionMiddleware funcionan sin cambios. Usá
$request->principal() para leer el tipo de auth, el tenant y los scopes.
API keys (auth de terceros)¶
Para acceso server-to-server por desarrolladores externos, las mismas rutas protegidas aceptan una API key en vez de un JWT de usuario — sin cambiar el código de la ruta:
Las keys se emiten con App\Support\Auth\ApiKeyManager::issue($tenantId, $name, $scopes),
que devuelve el token una sola vez (solo se guardan prefix + un hash SHA-256).
Los tenants gestionan sus propias keys con el módulo ApiKeys incluido (CRUD):
POST /api-keys { "name": "...", "scopes": ["clientes.read"] } → 201, token visible una vez
GET /api-keys → lista (nunca expone el hash)
GET /api-keys/{id} → metadata de una key
DELETE /api-keys/{id} → revocar
Estas rutas requieren AuthMiddleware + TenantMiddleware + ScopeMiddleware:apikeys.manage,
así que los usuarios de la app (scope *) gestionan keys de forma transparente, mientras que
una API key solo puede administrar otras si se le concedió explícitamente apikeys.manage
(previene la escalada de privilegios). Toda operación queda acotada al tenant del que llama.
La key lleva su tenant y una lista de scopes; protegé por ellos en cada ruta con el middleware parametrizado:
$router->get('/clientes', [ClienteController::class, 'index'],
[AuthMiddleware::class, TenantMiddleware::class, 'App\Http\Middleware\ScopeMiddleware:clientes.read']);
Los scopes (qué puede tocar una credencial) son ortogonales a los permisos RBAC (qué puede
hacer un rol de usuario) y a los entitlements del tenant (qué tiene un tenant) — ver
docs/adr/0001-saas-identity-entitlements-billing.md.
Firmas de webhooks¶
App\Support\Webhook\WebhookVerifier (vinculado a WebhookVerifierInterface) endurece los
webhooks entrantes/salientes con un esquema propio sin dependencias:
verify($request, $secret, $tolerance = 300) devuelve true solo si el HMAC coincide
(en tiempo constante), el timestamp está dentro de la ventana y la firma no se vio
antes (anti-replay vía CacheInterface, TTL = ventana). sign($payload, $secret)
produce la cabecera para webhooks salientes. Inyectá la interfaz en cualquier controller que
reciba callbacks de proveedores (p. ej. pasarelas de pago) y verificá contra el secret de esa
integración antes de actuar. La lectura del body crudo usa Request::rawBody().
El anti-replay exige un store operativo. El nonce vive en CacheInterface, cuyo binding
por defecto es ApcuCache. Si el cache no es operativo (available() === false, p. ej. APCu
deshabilitado), verify() falla cerrado — rechaza la firma en vez de aceptarla sin
protección — y se loguea un aviso al bootear. Además, APCu es por proceso: en un deploy
multi-instancia, vinculá CacheInterface a un store compartido (Redis/DB) implementando la
interfaz, o un reenvío podría no detectarse al caer en otra instancia. El esquema HMAC no
cambia. Ver docs/adr/0001-saas-identity-entitlements-billing.md §1.3.
Container de DI¶
Compatible con PSR-11, con autowiring por reflexión.
// Registrar un factory
$app->bind(MyService::class, fn ($c) => new MyService($c->get(PDO::class)));
// Registrar un singleton (se resuelve una vez, se reutiliza)
$app->singleton(MyService::class, fn ($c) => new MyService($c->get(PDO::class)));
// Registrar una instancia ya construida
$app->instance(\PDO::class, $existingPdo);
// Resolver
$service = $app->get(MyService::class);
// Autowiring sin registro (usa reflexión)
$service = $app->make(MyService::class);
// Autowiring + inyectar escalares extra en parámetros builtin del constructor
$middleware = $app->makeWith(PermissionMiddleware::class, 'facturas.delete');
El autowiring resuelve los parámetros del constructor por nombre de tipo. Si no hay binding para un tipo, resuelve la clase recursivamente. Los parámetros escalares sin default lanzan ContainerException. makeWith inyecta escalares adicionales de forma posicional en los parámetros de tipo builtin — lo usa internamente el Router para los middlewares parametrizados.
Multi-tenancy¶
El framework trae multi-tenancy a nivel de fila. Es opt-in — si no incluís TenantMiddleware en una ruta, el tenantId nunca se setea y no hay scoping por tenant.
Cómo funciona¶
- La tabla
usuariostiene una columnatenant_id CHAR(36)(FK →tenants.id) - En el login, el
tenant_idse embebe en el payload del JWT TenantMiddlewarelee eltenant_iddel JWT decodificado y llama$request->setTenantId()- Los controllers leen
$request->tenantId()y lo pasan a los repositorios - Los repositorios agregan
AND tenant_id = ?a sus queries cuando$tenantId !== null
// Ruta — agregá TenantMiddleware para habilitar el scoping
$router->group([AuthMiddleware::class, TenantMiddleware::class], function ($router) {
$router->get('/productos', [ProductoController::class, 'index']);
});
// Controller
public function index(Request $request): Response
{
return Response::success($this->service->getAllForTenant($request->tenantId()));
}
// Repository — scoping condicional
public function findAll(?string $tenantId = null): array
{
$sql = 'SELECT * FROM productos';
$params = [];
if ($tenantId !== null) {
$sql .= ' WHERE tenant_id = ?';
$params[] = $tenantId;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
Correr sin multi-tenancy¶
Simplemente no agregues TenantMiddleware a ninguna ruta. La columna tenant_id en usuarios se puede omitir. Los repositorios reciben null y saltean el filtro de tenant. No hace falta ningún otro cambio.
Impersonación de admin entre tenants¶
Un admin solo puede suplantar usuarios dentro de su propio tenant. Intentar una suplantación cross-tenant lanza AuthException(403). Pasar $adminTenantId = null saltea este chequeo (uso interno solamente — la ruta siempre pasa el tenant ID real vía TenantMiddleware).