Autenticación y autorización¶
milpa trae auth propia (login, hash de password, emisión de tokens, sesión, roles) y, además, validación de tokens externos de Laravel Passport para migrar. El modelo es el de Laravel Sanctum: dos carriles a la vez —
- JWT (API) — bearer tokens que milpa emite (HS256). Para frontends SEPARADOS (SPA/móvil).
- Sesión cookie + CSRF (browser) — cookie firmada (Secure/HttpOnly/SameSite=Lax). Para HTMX/ server-rendered de primera-parte.
Todo vive en milpa/Core/Auth. Hay un demo corrible que usa los dos carriles: ver el
Quickstart del demo.
Guards¶
Un guard resuelve el usuario autenticado desde el request. milpa trae tres:
| Guard | Mecanismo | Para |
|---|---|---|
jwt |
Authorization: Bearer <jwt> (propio, HS256) |
API / frontend separado |
session |
cookie de sesión firmada | browser / HTMX |
passport |
Authorization: Bearer <jwt> (RS256 EXTERNO de Laravel Passport) |
migración |
AUTH_GUARD (.env) fija el default; o eliges el guard por ruta con guarded("jwt") /
guarded("session") (útil porque la misma app sirve los dos carriles).
Hashing¶
from milpa.Core.Auth import Hash
hashed = Hash.make("secreto") # argon2id
Hash.verify("secreto", hashed) # True — verifica argon2 y también bcrypt ($2y$ de Laravel)
Hash.verify acepta hashes bcrypt de Laravel (normaliza $2y$↔$2b$), así puedes migrar
los passwords existentes sin re-hashear a todos de golpe.
El modelo User¶
milpa NO impone el esquema. Tu modelo cumple el contrato Authenticatable (id, hash de password,
roles); lo más fácil es heredar AuthenticatableMixin (asume columnas id/password/roles):
class User(TimestampMixin, AuthenticatableMixin, Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(unique=True, index=True)
password: Mapped[str] = mapped_column()
roles: Mapped[str] = mapped_column(default="") # CSV: "admin,editor"
AUTH_USER_MODEL (.env) apunta a ese modelo. El SqlAlchemyUserProvider lo usa por default; si
migras de una BD legacy, registra tu propio provider con set_user_provider(...).
Login¶
API (JWT):
from milpa.Core.Auth import Auth
token = Auth.attempt(email, password) # JWT (str) o None si las credenciales fallan
# -> el cliente manda luego: Authorization: Bearer <token>
Browser (sesión cookie):
user = Auth.validate_credentials(email, password)
if user:
Auth.login(request, user) # guarda el id en la sesión firmada
# ...
Auth.logout(request) # cierra la sesión
Proteger rutas (autenticación)¶
from milpa.Core.Auth import CurrentUser, authenticated, guarded, Authenticatable
from fastapi import Depends
# Guard por default (AUTH_GUARD):
def me(user: Authenticatable = CurrentUser): ... # CurrentUser = Depends(authenticated)
# Guard EXPLÍCITO por carril:
def api_me(user: Authenticatable = Depends(guarded("jwt"))): ...
def web_me(user: Authenticatable = Depends(guarded("session"))): ...
- Sin/!con token →
401(UnauthorizedError→application/problem+json). Auth.user()/Auth.id()/Auth.check()leen el usuario del request actual (contextvar), útil en servicios y templates sin pasarlo a mano.
Autorización: RBAC (roles) + ABAC (policies)¶
RBAC — por rol:
from milpa.Core.Auth import require_roles, Roles
from fastapi import Depends
def admin_only(user = Depends(require_roles("admin", guard="jwt"))): ... # 403 si no tiene el rol
@Controller("/admin")
class AdminController:
@Get("/users")
@Roles("admin") # azúcar para controllers @Controller
def users(self): ...
ABAC — por policy (atributos del recurso):
from milpa.Core.Auth import Gate
# 1) Registra la policy (típicamente en app/Modules/<X>/Policies.py):
Gate.define("note.update", lambda user, note: note.owner_id == user.get_auth_identifier())
# 2) Autoriza DENTRO del handler/servicio, tras cargar el recurso:
Gate.authorize("note.update", note) # ForbiddenError (403) si no aplica
# Gate.allows("note.update", note) -> bool
Para abilities SIN recurso (p. ej. note.create), usa @Can("note.create") sobre el método del
controller. Sin policy registrada para una ability → denegado (seguro por default).
CSRF (solo carril sesión)¶
El carril cookie va con protección CSRF double-submit automática (CsrfMiddleware):
- En cada método NO-seguro (POST/PUT/PATCH/DELETE) con cookie de sesión, exige el header
X-CSRF-Tokenigual a la cookiemilpa_csrf. El front/HTMX la reenvía solo (ver ellayoutdel demo). EXENTAS: requests conAuthorization: Bearer(API) y sin sesión (login/registro, clientes API por JSON). Rechazo →403 problem+json. - Config:
CSRF_ENABLED,CSRF_COOKIE,CSRF_HEADER; sesión:SESSION_SECRET(obligatorio para el guardsession),SESSION_SECURE(=trueen prod/HTTPS),SESSION_SAME_SITE.
Security headers + CSP (por default, report-only)¶
milpa inyecta un set de security headers sin que configures nada (SecurityHeadersMiddleware,
defaults seguros): X-Content-Type-Options: nosniff, Referrer-Policy, X-Frame-Options, y —si lo
prendes— Strict-Transport-Security (HSTS).
Desde 0.6.6 trae además Content-Security-Policy por default, en modo Report-Only:
# .env (defaults — no necesitas ponerlos, vienen así)
CONTENT_SECURITY_POLICY="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
CSP_REPORT_ONLY=true # default: el navegador REPORTA violaciones pero NO bloquea
CSP_REPORT_URI="" # opcional: a dónde mandar los reportes
Por qué importa (el caso real de la flota legacy): si una app guarda el token en un cookie
legible por JS (error de diseño de hace años — no HttpOnly), un XSS puede leerlo y exfiltrarlo.
CSP es la mitigación que NO toca el flujo de auth: al restringir script-src a 'self', un
<script> inyectado de un origen externo no corre y un connect-src 'self' corta la salida del
token a un dominio del atacante. Es defensa en profundidad mientras se migra el cookie a HttpOnly.
Report-Only es seguro para apps existentes: el navegador observa y (si pones CSP_REPORT_URI)
reporta lo que SE bloquearía, pero no rompe nada. Afinas la política con esos reportes y, cuando
esté limpia, pasas a enforcing con CSP_REPORT_ONLY=false. El header cambia solo
(Content-Security-Policy-Report-Only → Content-Security-Policy).
En modo enforcing, un
<script>inline necesitanonce/hashpara correr. milpa inyecta un inline para__ENV(Vite): en report-only lo verás reportado; el nonce para enforcing llega en una mejora siguiente. Por eso el default es report-only.
jornal scan --only auth te dice en qué punto estás (sin CSP / report-only / enforcing, y CSRF
on/off) — el copiloto que enseña el camino sin romper el demo. Ver
Imports perezosos y análisis.
Migrar desde Laravel (Passport, RS256)¶
Cuando el emisor de tokens sigue siendo el legacy, milpa valida (no emite): copia la llave
pública RS256 (storage/oauth-public.key) a secrets/ y apunta PASSPORT_PUBLIC_KEY_PATH. Usa
el guard passport (resuelve el user por el claim sub vía tu provider) o las dependencies
clásicas de scopes:
from milpa.Core.Auth import get_current_token, require_scopes, require_any_scope, TokenPrincipal
def profile(principal: TokenPrincipal = Depends(get_current_token)): ...
def admin(principal: TokenPrincipal = Depends(require_scopes("admin"))): ...
# require_scopes = TODOS los scopes (all-of, el `scopes:a,b` / CheckScopes de Passport)
# require_any_scope = ALGUNO (any-of, el `scope:a,b` / CheckForAnyScope de Passport):
def facturas(principal: TokenPrincipal = Depends(require_any_scope("op_site", "invoice_read"))): ...
Para @Controller, el azúcar de ruta @Scope("op_site", "invoice_read") aplica el any-of
sobre el método (en paralelo a @Roles/@Can).
| Situación | Código |
|---|---|
| Sin llave pública configurada | 503 (infra: te falta el secret) |
| Token inválido / expirado / firma mala | 401 |
| Faltan scopes | 403 |
La revocación es un punto de extensión: por default solo se valida firma/expiración/audiencia.
Para conectar tu verificación contra oauth_access_tokens (estilo strangler), registra una
función con set_revocation_check(fn) — fn(token_id) -> True significa REVOCADO y el token
recibe 401:
from milpa.Core.Auth import set_revocation_check
set_revocation_check(lambda jti: jti is None or not mi_servicio.is_active(jti))
Llámalo una vez en el boot de la app (p. ej. un service provider / __init__ del shared kernel)
para que aplique antes del primer request.
Variables de entorno¶
AUTH_GUARD=jwt # jwt | session | passport (default)
AUTH_USER_MODEL=app.Models.User.User
JWT_SECRET= # OBLIGATORIO para emitir/validar JWT propios
JWT_ALGORITHM=HS256
JWT_TTL_SECONDS=3600
SESSION_SECRET= # OBLIGATORIO para el guard 'session'
SESSION_SECURE=false # true en prod (HTTPS)
SESSION_SAME_SITE=lax
CSRF_ENABLED=true
PASSPORT_PUBLIC_KEY_PATH=/secrets/oauth-public.key # solo para migrar (guard passport)