Saltar a contenido

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 (UnauthorizedErrorapplication/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-Token igual a la cookie milpa_csrf. El front/HTMX la reenvía solo (ver el layout del demo). EXENTAS: requests con Authorization: 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 guard session), SESSION_SECURE (=true en 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-OnlyContent-Security-Policy).

En modo enforcing, un <script> inline necesita nonce/hash para 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)

Siguiente paso

Base de datos.