jfastframework
Documentación English PyPI GitHub

Cuentas

Usuarios, login con contraseña, roles y permisos: el almacén de usuarios que el plugin auth deja fuera a propósito, como un plugin que se activa.

[plugins]
enabled = ["observability", "database", "cache", "auth", "accounts"]

[plugin.auth]
mode = "secret"
algorithms = ["HS256"]
issue_tokens = true

[plugin.accounts]
bootstrap_admin_email = "admin@example.com"
pip install "jfastframework[accounts]"
JFAST_AUTH_SECRET=...                           # al menos 32 bytes
JFAST_ACCOUNTS_BOOTSTRAP_ADMIN_PASSWORD=...     # solo el primer arranque

En el primer arranque el plugin crea sus tablas, un rol llamado admin con el permiso accounts:admin, y al administrador. Después quita la contraseña del entorno: el usuario ya existe, y la variable solo se lee cuando no existe.


El modelo

Cosa Qué es
Permiso Un texto: invoices:write, reports:read. Los nombras tú.
Rol Un conjunto con nombre de permisos: billing = invoices:read + invoices:write.
Usuario Un email, una contraseña (o una identidad de un proveedor) y roles.

Los permisos del usuario viajan en el access token como scopes, así que la verificación en una ruta es una dependencia y ninguna consulta a la base:

from fastapi import Depends
from jfastframework.accounts import require_permission
from jfastframework.auth import Principal

@router.post("/invoices")
async def create(caller: Principal = Depends(require_permission("invoices:write"))):
    ...

require_permission es require_scopes con el nombre que dice para qué sirve aquí. Sin token es 401; con un token sin el permiso es 403.


Endpoints

Bajo prefix (por defecto /auth), junto a /auth/refresh y /auth/logout del propio auth:

Método Ruta Hace
POST /auth/login {email, password} → un par de tokens, o el segundo paso (abajo)
POST /auth/login/mfa {mfa_token, code} → un par de tokens. Con mfa = true
POST /auth/register Crea una cuenta. Solo con allow_registration = true
GET /auth/account El usuario con sesión, con roles, permisos, email_verified y mfa_enabled
POST /auth/password {current_password, new_password}
POST /auth/logout/all Termina todas las sesiones de quien llama, esta incluida
GET /auth/features Qué está activo: registro, verificación, reset, MFA -- para un frontend
POST /auth/verify {token} del correo. Con email_verification activo
POST /auth/verify/resend {email} → 202, siempre
POST /auth/password/forgot {email} → 202, siempre. Con password_reset = true
POST /auth/password/reset {token, new_password} → 204, y terminan todas las sesiones
POST /auth/mfa/setup Empieza la inscripción: {secret, otpauth_uri}
POST /auth/mfa/confirm {code} lo activa y devuelve los códigos de recuperación, una vez
POST /auth/mfa/disable {password, code}
POST /auth/mfa/recovery-codes {password, code} → un juego nuevo; los anteriores dejan de servir

Los errores son RFC 7807 como en todo lo demás, y los que un frontend tiene que distinguir traen un code: email_not_verified, token_invalid, mfa_code_invalid, mfa_token_invalid, password_invalid, mfa_required_by_role.

Bajo admin_prefix (por defecto /accounts), para quien tenga accounts:admin:

Método Ruta Hace
GET /accounts/users Usuarios, paginados
POST /accounts/users {email, password?, display_name?, roles}
PATCH /accounts/users/{id} {is_active?, display_name?, roles?}
GET /accounts/roles Roles y sus permisos
POST /accounts/roles {name, description?, permissions}
PATCH /accounts/roles/{id} {description?, permissions?}
DELETE /accounts/roles/{id} Quita el rol a todos los que lo tenían
DELETE /accounts/users/{id}/mfa Quita el MFA de un usuario: perdió el teléfono y los códigos

POST /accounts/users acepta email_verified: true cuando el administrador responde por la dirección; si no, con la verificación activa, al usuario le llega el link. Desactivar a un usuario (PATCH ... {is_active: false}) termina sus sesiones en ese momento -- el access token incluido, no cuando venza.


Verificación de email

[plugins]
enabled = ["observability", "database", "cache", "queue", "auth", "mail", "accounts"]

[plugin.accounts]
allow_registration = true
email_verification = "required"          # off | optional | required
frontend_url = "https://app.example.com" # a dónde apunta el link del correo

optional manda el link al registrarse y la cuenta funciona mientras tanto; /auth/account dice email_verified: false hasta que se use. required no da sesión hasta entonces: el registro contesta 202 sin tokens, y un sign-in con la contraseña correcta contesta 403 email_not_verified -- solo con la contraseña correcta, así que la respuesta no le dice a nadie más que la cuenta existe.

El link es frontend_url + verify_email_path (/verify-email) + ?token=.... El frontend postea el token a /auth/verify. El token son 256 bits aleatorios, guardados solo como su SHA-256, válidos verification_token_minutes (un día) y de un solo uso: consumirlo es un UPDATE condicional, así que dos clics en el mismo link dan un solo éxito. Un reenvío deja funcionando el primer link -- el primer correo suele ser el que se abre.

El correo sale por el plugin mail, encolado cuando queue está activo. Los tres mensajes -- accounts/verify_email, accounts/reset_password, accounts/already_registered -- tienen versiones sencillas integradas; pon <nombre>.html (y .txt) en el templates_dir de mail para reemplazar uno. Reciben app_name, link, email, display_name y expires_minutes.

Registrarse con una dirección ocupada, en modo required, contesta el mismo 202 que una nueva, y al dueño de la dirección le llega un correo que lo dice, con un link de reset. El formulario nunca dice "ese email ya existe". En off y optional el registro inicia sesión, así que una dirección ocupada es un 409, como antes: no hay forma de entregar una sesión y ocultarlo.

Los administradores y el admin de arranque quedan verificados al crearse (el operador escribió la dirección), un email verificado por un proveedor cuenta, y completar un reset de contraseña también -- el link llegó al buzón.

Activar la verificación en un servicio con usuarios: todas las cuentas que ya existían quedan sin verificar. Con required reciben el 403 y pueden pedir un link. Para tratar como verificadas las cuentas anteriores, corre una vez: UPDATE jfast_users SET email_verified_at = created_at WHERE email_verified_at IS NULL.

Recuperar la contraseña

[plugin.accounts]
password_reset = true
frontend_url = "https://app.example.com"

POST /auth/password/forgot {email} contesta 202, sea cual sea la dirección -- y en el mismo momento: la búsqueda, el token y el correo pasan después de enviada la respuesta, así que el tiempo de respuesta no dice si la dirección tiene cuenta. El link es reset_password_path (/reset-password), válido reset_token_minutes (30), de un solo uso, hasheado en reposo.

POST /auth/password/reset {token, new_password} revisa la política de contraseña antes de gastar el token (una contraseña demasiado corta no cuesta el link), fija la contraseña, quita un bloqueo y termina todas las sesiones de la cuenta: cada familia de refresh registrada al iniciar sesión se revoca en el store de auth, lo que también detiene los access tokens que la llevan. No inicia sesión: eso se saltaría el segundo factor.

Como mucho un correo por dirección cada email_cooldown_seconds (60), por más veces que se envíe el formulario.

Autenticación de dos factores

[plugin.accounts]
mfa = true
mfa_required_roles = ["admin"]    # opcional
JFAST_ENCRYPTION_KEYS=k1:...      # ver encryption.md; obligatoria con mfa = true

TOTP (RFC 6238): seis dígitos, treinta segundos, SHA-1 -- lo que lee cualquier app autenticadora. Escrito con la biblioteca estándar (hmac, hashlib, base64), sin dependencia nueva, y probado contra los vectores del propio RFC.

La inscripción tiene dos pasos. POST /auth/mfa/setup (con la contraseña actual) guarda un secreto nuevo y lo devuelve con una URI otpauth://; todavía no protege nada. POST /auth/mfa/confirm {code} lo activa con un código que calculó la app, y devuelve diez códigos de recuperación -- se muestran una vez, se guardan como SHA-256 y cada uno sirve para un sign-in.

Iniciar sesión pasa a tener dos pasos:

POST /auth/login       {email, password}   -> {"mfa_required": true, "mfa_token": "...", "expires_in": 300}
POST /auth/login/mfa   {mfa_token, code}   -> el par de tokens

code son los seis dígitos o un código de recuperación. El token MFA es de un solo uso y dura mfa_token_minutes (5). Se acepta un código un paso antes o después del actual (un teléfono con treinta segundos de desfase sigue funcionando); el mismo código nunca se acepta dos veces, ni uno anterior -- se guarda el último paso aceptado y se avanza con un UPDATE condicional, así que dos requests que compiten con un código obtienen un solo sí. Un código equivocado cuenta contra el token MFA (mfa_max_attempts, 5, y luego a iniciar sesión otra vez) y contra el bloqueo de la cuenta, así que saber la contraseña no compra intentos extra con el código: el contador de fallos solo se limpia cuando de verdad se emite una sesión.

Por rol. Un usuario con un rol de mfa_required_roles y sin MFA va a la inscripción al iniciar sesión -- {"mfa_enrollment_required": true, "mfa_token": ...}; /auth/mfa/setup y /confirm aceptan ese token en el body en lugar de una sesión, y confirm devuelve entonces la sesión junto con los códigos de recuperación. Una sesión que gana ese rol termina en su siguiente refresh, y el MFA no se puede quitar mientras se tenga el rol.

El secreto es la única credencial que no se puede hashear -- el servidor calcula el código a partir de él -- así que se guarda cifrado con JFAST_ENCRYPTION_KEYS, atado a su usuario. El plugin no arranca con mfa = true y sin llave.

Un sign-in por proveedor (login social de auth) de una cuenta con MFA devuelve el mismo reto mfa_required en vez de tokens: un proveedor es el primer factor.

Límites de frecuencia

Con el plugin cache activo, el sign-in queda limitado sin configurar nada, además del bloqueo:

Cubeta Por defecto Aplica a
por dirección IP 20 cada 5 min (login_limit_per_ip) login, el paso MFA, verify, reset
por cuenta 10 cada 5 min (login_limit_per_account) login (según el email escrito), el paso MFA
correos por IP 5 cada 15 min (email_limit_per_ip) registro, reenvío, forgot

Un token bucket en Redis -- el limitador del propio plugin ratelimit, sobre la conexión del cache; no hace falta activar el plugin ratelimit. Pasarse es un 429 con Retry-After. La cubeta por cuenta usa lo que se escribió, así que contesta igual para una dirección sin cuenta. Si Redis deja de contestar, deja pasar y el bloqueo sigue aplicando. rate_limit = false lo apaga; sin cache está apagado y lo dice al arrancar.


Contra qué protege

Adivinar contraseñas. Después de max_failed_logins contraseñas o códigos incorrectos (5) la cuenta se bloquea lockout_minutes minutos (15). Se cuenta por cuenta y no por dirección IP, así que cambiar de IP no sirve para saltárselo; los límites de arriba frenan el intento de adivinar en muchas cuentas a la vez.

Averiguar quién tiene cuenta. Una contraseña incorrecta, un email desconocido y una cuenta bloqueada responden el mismo 401 con el mismo mensaje -- y tardan lo mismo, porque un email desconocido también se verifica contra un hash señuelo. "Mándame un link" (reset, reenvío) contesta 202 antes de buscar la dirección, y el registro con verificación obligatoria contesta igual para una dirección ocupada.

Un permiso que sobrevive a su retiro. accounts registra el hook on_refresh de auth, así que cada refresh vuelve a leer los roles del usuario. Quita un permiso, o desactiva al usuario, y aplica en su siguiente refresh -- la desactivación termina la sesión ahí -- en vez de al final de un refresh token de treinta días. El access token ya emitido sigue sirviendo hasta que vence (access_lifetime_minutes, 15), y por eso conviene que sea corto -- salvo al desactivar, al hacer un reset de contraseña y con /auth/logout/all, que revocan todas las sesiones en ese momento, access tokens incluidos.

Una tabla de tokens robada. Los tokens de verificación y de reset y los códigos de recuperación se guardan como SHA-256; el secreto TOTP, cifrado. Una copia de la base no resetea la contraseña de nadie. Ninguno se escribe jamás en el log.

Una tabla de contraseñas robada. Las contraseñas se hashean con argon2id, fuera del event loop, y se vuelven a hashear en el siguiente login cuando cambian los parámetros.

Tomar una cuenta a través de un proveedor. Con login social configurado en auth, una identidad de proveedor se enlaza a una cuenta existente solo por un email verificado. Uno sin verificar dejaría entrar como dueño a cualquiera que pueda escribir esa dirección en un proveedor.


Tenants

Cada usuario y cada rol pertenece a un tenant, o a ninguno. El login, el registro y el login social leen el tenant de la request -- la fuente de subdominio o de ruta del plugin tenancy; la del token no puede, porque todavía no hay token. El mismo email en dos tenants son dos personas, y un administrador ve y cambia solo los usuarios y roles de su propio tenant.

Un SaaS donde cada cuenta es su propio tenant no necesita tabla de tenants: agrega el plugin tenancy con sources = ["token", "user"] y el id del usuario con sesión se vuelve el tenant de todo lo que crea. Los usuarios en sí no pertenecen a ningún tenant, y es lo correcto: la cuenta es la frontera. Ver la fuente user.


Configuración

Setting Por defecto
prefix / admin_prefix /auth / /accounts
allow_registration false También decide si el login social puede crear usuarios
default_roles [] Roles con los que empieza un usuario que se registra solo
min_password_length 10
max_failed_logins / lockout_minutes 5 / 15
admin_permission accounts:admin
bootstrap_admin_email "" Contraseña desde JFAST_ACCOUNTS_BOOTSTRAP_ADMIN_PASSWORD
social_login true Solo actúa si auth tiene proveedores
email_verification off off, optional o required. Necesita mail
verification_token_minutes 1440
password_reset false Necesita mail
reset_token_minutes 30
frontend_url "" Obligatorio si cualquiera de los dos de arriba está activo (JFAST_ACCOUNTS_FRONTEND_URL)
verify_email_path / reset_password_path / login_path /verify-email / /reset-password / /login Las rutas del frontend a las que apuntan los links
email_cooldown_seconds 60 Un correo de cada tipo por dirección en ese lapso
mfa false Necesita JFAST_ENCRYPTION_KEYS
mfa_required_roles [] Necesita mfa = true
mfa_issuer el nombre de la app Lo que muestra la app autenticadora
mfa_token_minutes / mfa_max_attempts 5 / 5 El segundo paso de un sign-in
recovery_codes 10
rate_limit true Solo actúa con el plugin cache
login_limit_per_ip / login_limit_per_account / login_window_seconds 20 / 10 / 300
email_limit_per_ip / email_window_seconds 5 / 900

Cada combinación que no puede funcionar detiene el servicio al arrancar, nombrando el arreglo: verificación o reset sin mail o sin frontend_url, mfa_required_roles sin mfa, mfa sin llave de cifrado.

Las tablas (jfast_users, jfast_roles, jfast_role_permissions, jfast_user_roles, jfast_account_tokens, jfast_recovery_codes, jfast_user_sessions) son del plugin: se crean al arrancar y el autogenerate de Alembic del servicio las ignora. Una jfast_users creada por una versión anterior también recibe sus columnas nuevas al arrancar (todas nullable, así que el ALTER es instantáneo): no hay migración que escribir.

Lo que no está