Saltar a contenido

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_KEY de 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_users y si encuentra match emite un JWT con user_type='saas'. Si el mismo email también está en users con role owner/admin, ese segundo registro queda inalcanzable por login normal y el dashboard del comprador rechazará con 403 todos los endpoints que exigen owner. Ver 17-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_users no 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

  1. SaasUser.objects.get(email=email) — si existe, valida password y devuelve JWT con user_type='saas'.
  2. Si no existe en saas_users, busca en AccountUser.objects.get(email=email).
  3. Valida password.
  4. Si status='pending_verification' → 403 con instrucción de verificar email.
  5. Si status != 'active' → 403 (suspendido).
  6. Si el Account asociado tiene closed_at y han pasado más de 30 días → 403 (cuenta cerrada).
  7. En cualquier otro caso, devuelve JWT con user_type='account'.
  8. 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

POST /api/auth/token/refresh/
{ "refresh": "eyJ..." }
→ 200 { "access": "eyJnueva..." }

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

POST /api/auth/logout/
{ "refresh": "eyJ..." }
→ 200 { "ok": true }

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 / IsClientOwner son residuos del legacy. Mantienen su nombre por compatibilidad pero internamente operan sobre AccountUser (role owner/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 si role 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 en bot_user_permissions si 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 un prelaunch_code vá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_slug que venga en el body de la request; siempre re-derívalos del JWT vía request.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_VERSION o invalida en blacklist) para que los tokens viejos no fallen al deserializar.