09 — Autenticación y autorización¶
Modelo de auth¶
JWT stateless con Simple JWT (djangorestframework-simplejwt 5.3.0).
- Access token: 60 min de vida (configurable con
JWT_ACCESS_TOKEN_LIFETIME). - Refresh token: 24 h (configurable con
JWT_REFRESH_TOKEN_LIFETIME). - Algoritmo: HS256 firmado con
SECRET_KEYde Django. - Blacklist: los refresh tokens revocados se persisten (app
token_blacklist).
Tokens viajan en el header Authorization: Bearer <token>. No se usan cookies — el frontend los guarda en localStorage y los maneja explícitamente.
Dos modelos de usuario, dos tablas¶
El SaaS distingue dos poblaciones de usuarios con tablas y modelos separados:
| Modelo | Tabla | Quiénes son | Roles válidos |
|---|---|---|---|
authentication.models.SaasUser |
saas_users |
Operadores de la plataforma SaaS (vosotros, los que vendéis) | superadmin, staff, support, developer |
authentication.models.AccountUser |
users |
Compradores del SaaS y los usuarios que invitan a sus bots | owner, admin, bot_user |
Las dos tablas comparten únicamente la columna email como identificador humano. Las contraseñas, FKs y permisos son independientes.
Regla operativa inviolable: un mismo email solo puede existir en una de las dos tablas, nunca en las dos.
El flujo de login (ver más abajo) busca primero en
saas_usersy si encuentra match emite un JWT conuser_type='saas'. Si el mismo email también está enuserscon roleowner/admin, ese segundo registro queda inalcanzable por login normal y el dashboard del comprador rechazará con 403 todos los endpoints que exigenowner. Ver17-troubleshooting.md§ "Login devuelve JWT con rol equivocado".
Por qué dos tablas y no una sola¶
- Aislamiento de blast radius: un bug en la app del comprador no puede escalar privilegios a superadmin porque
saas_usersno es modificable desde/api/dashboard/*. - Claims JWT distintos: cada tabla emite un JWT con shape distinto (
user_type='saas'vs'account') y los permission classes ramifican. - Borrar la cuenta de un comprador no toca al staff que la atendió.
Flujo de login¶
POST /api/auth/login/
Request: { "email": "...", "password": "..." }
Response 200 (saas user):
{
"access": "eyJ...",
"refresh": "eyJ...",
"user": { "id", "email", "full_name", "role", "is_superuser", "is_staff",
"user_type": "saas", "avatar_url" },
"tenants": [],
"plan": "admin",
"account_id": null
}
Response 200 (account user):
{
"access": "eyJ...",
"refresh": "eyJ...",
"user": { "id", "email", "role", "bot_slug", "plan", "status", "account_id" },
"tenants": [
{ "id", "bot_slug", "name", "domain", "plan_type" }, ...
],
"allowed_bot_slugs": [], // solo para role='bot_user'
"bot_permissions": {} // map bot_slug → permissions JSON
}
Response 401: { "error": "Credenciales inválidas." }
Response 403: { "error": "Cuenta no verificada. Por favor revisa tu email." }
Response 403: { "error": "Usuario inactivo o suspendido." }
Response 403: { "error": "This account has been permanently closed." }
Precedencia exacta¶
SaasUser.objects.get(email=email)— si existe, valida password y devuelve JWT conuser_type='saas'.- Si no existe en
saas_users, busca enAccountUser.objects.get(email=email). - Valida password.
- Si
status='pending_verification'→ 403 con instrucción de verificar email. - Si
status != 'active'→ 403 (suspendido). - Si el
Accountasociado tieneclosed_aty han pasado más de 30 días → 403 (cuenta cerrada). - En cualquier otro caso, devuelve JWT con
user_type='account'. - Si no existe en ninguna tabla → 401.
Claims que viaja en el JWT¶
Saas user:
| Claim | Valor |
|---|---|
user_type |
'saas' |
user_id |
SaasUser.id (int) |
role |
'superadmin' \| 'staff' \| 'support' \| 'developer' |
is_superuser |
bool |
permissions |
array de strings (permisos granulares) |
bot_slug |
null |
account_id |
null |
Account user:
| Claim | Valor |
|---|---|
user_type |
'account' |
user_id, user_v3_id |
AccountUser.id (int) — ambos por compatibilidad |
role |
'owner' \| 'admin' \| 'bot_user' |
bot_slug |
slug del bot donde el user fue creado (string) |
account_id |
UUID del Account dueño |
plan |
nombre del plan del bot del user (string) |
allowed_bot_slugs |
array (solo si role='bot_user') |
Diseño deliberado: todos los identificadores de bot que viajan en JWT y en claims públicos son slugs (strings legibles), no UUIDs. Los UUIDs se quedan en las FKs internas de la base.
Otros endpoints de auth¶
Registro + verificación de email¶
1. POST /api/auth/signup/
{ email, password, first_name, last_name }
→ crea AccountUser con status='pending_verification'
→ envía email con link: https://app.tu-dominio.com/verify-email?token=<jwt>
→ 201 { "ok": true }
2. POST /api/auth/verify-email/
{ token }
→ status='active'
→ 200 { "ok": true }
Hasta verificar, el login devuelve 403 con código EMAIL_NOT_VERIFIED.
Refresh¶
El frontend (apps/dashboard/src/api/axios-instance.ts) llama a este endpoint automáticamente cuando una petición recibe 401, reintenta la original con el nuevo access token, y solo redirige a login si el refresh también falla.
Logout¶
El refresh token entra en token_blacklist_blacklistedtoken y queda inválido permanentemente.
Password reset¶
1. POST /api/auth/password-reset-request/ { email }
→ envía email con link: /reset-password?token=<jwt>
2. POST /api/auth/password-reset-confirm/ { token, new_password }
Tokens de reset son single-use y caducan en 1h.
Cambio de email autenticado¶
PATCH /api/auth/update-my-email/
{ new_email, current_password }
→ envía verificación al nuevo email
→ hasta el click el cambio queda pending
Login social (Google)¶
1. Frontend abre popup OAuth → recibe id_token
2. POST /api/auth/social/ { provider: "google", id_token: "..." }
→ verifica id_token contra Google
→ busca AccountUser por email; si no existe, lo crea con status='active'
→ crea SocialAuthLink para que sepamos que está vinculado
→ devuelve par JWT igual que login normal
Autorización¶
Las permission classes están en authentication/permissions.py. Las usadas en producción:
| Permission | Aplica a | Comprueba |
|---|---|---|
IsAuthenticated (DRF) |
Todo /api/dashboard/*, /api/account*/* |
Hay JWT válido |
IsClient |
/api/dashboard/* y mixins de bot |
user_type='account' (cualquier role de cuenta) |
IsClientOwner |
Acciones destructivas / billing | role in ('owner','admin') |
IsSuperAdmin |
Todo /api/sa/* |
user_type='saas' y role='superadmin' |
IsOwnerOrSuperAdmin |
Endpoints duales (settings sensibles) | union de los dos anteriores |
Los nombres
IsClient/IsClientOwnerson residuos del legacy. Mantienen su nombre por compatibilidad pero internamente operan sobreAccountUser(roleowner/admin/bot_user). No vuelven a introducir el concepto de "client".
Helpers internos¶
Dentro de los ViewSets del dashboard se usan helpers (en api/views.py y mixins):
_assert_owner_or_admin(request)— levanta 403 sirole not in ('owner','admin'). Lo usan los endpoints de gestión de usuarios y billing.apply_bot_filter(queryset, user, bot_param)— filtra cualquier queryset por bots accesibles al user (todos los bots de su Account si es owner/admin, o solo los listados enbot_user_permissionssi es bot_user).
Permisos granulares por bot (rol bot_user)¶
La tabla bot_user_permissions tiene la forma:
id bigint PK
user_id int FK → users.id
bot_id uuid FK → bots.id
permissions jsonb ej: {"faqs":"edit","config":"view","billing":"none","users":"none"}
Cada granularidad acepta 'view' | 'edit' | 'admin' | 'none'. Las views que protegen acciones específicas (por ejemplo crear FAQ) leen permissions.faqs y comparan; si el bot_user tiene solo 'view', la creación devuelve 403.
Modo "prelaunch"¶
Permite cerrar el registro al público y abrirlo solo por invitación.
SystemSetting(key='prelaunch_mode', value='on')activa el modo./api/auth/signup/rechaza si no se pasa unprelaunch_codeválido.- Los códigos los genera el superadmin en
/api/sa/prelaunch-codes/. - También se aceptan
invitation_request: el visitante pide ser invitado, el superadmin aprueba/rechaza desde/api/sa/invitation-requests/.
Detalle del flujo y endpoints en 21-prelaunch-invitations.md.
Buenas prácticas para extender¶
- Nuevo endpoint autenticado: decora con
@authentication_classes([JWTAuthentication])+@permission_classes([IsAuthenticated, IsClient])o mixin equivalente. - Nuevo endpoint público: decora explícitamente con
@authentication_classes([])+@permission_classes([AllowAny])— si no, las defaults globales lo bloquean. - Nunca confíes en
account_id/bot_slugque venga en el body de la request; siempre re-derívalos del JWT víarequest.user. - Si un endpoint necesita actuar sobre un bot concreto, valida con
apply_bot_filter(...)antes de cualquier write. - Si añades un nuevo claim al JWT, regenera tokens en producción (cambia
JWT_TOKEN_VERSIONo invalida en blacklist) para que los tokens viejos no fallen al deserializar.