green snake
Photo by Pixabay on Pexels.com

Guía para Principiantes de Diseño de Seguridad Serio con FastAPI: Autenticación y Autorización — JWT/OAuth2, Cookies de Sesión, RBAC/Scopes, Protección CSRF y Trampas del Mundo Real


Resumen (visión general)

  • Almacena contraseñas como hashes; mantén los tokens de acceso de corta duración y los tokens de refresco de larga duración + almacenados de forma segura como regla general.
  • Para SPAs o móvil, usa JWT (Bearer); para aplicaciones web centradas en navegador, Cookies + protección CSRF son una buena combinación.
  • Separa la autorización en roles (RBAC) y scopes, e incrusta decisiones usando Security / Depends de FastAPI.
  • Implementa contramedidas concretas contra amenazas comunes: fijación de sesión, reutilización de tokens, XSS, CSRF, contraseñas débiles, etc.
  • Incluye pruebas, rotación, gestión de claves y registros de auditoría para construir un sistema listo para operaciones.

Quién se beneficia

  • Aprendiz A (tesis / proyecto en solitario): quiere una configuración mínima y fiable para formulario de autenticación y JWT.
  • Equipo pequeño B (desarrollo por contrato de 3 personas): no está seguro sobre operaciones con Cookies para API y panel de administración; quiere finalizar diseño de CSRF y SameSite.
  • Equipo SaaS C (startup): quiere límites claros con RBAC + scopes, y ejecutar rotación y revocación de tokens en producción.

Evaluación de Accesibilidad

  • Puntos clave organizados con encabezados y listas; el código usa bloques monoespaciados con comentarios cortos para legibilidad.
  • Los términos técnicos incluyen una breve explicación en su primera mención. Puntos de decisión al final de secciones ayudan a personas con lectores de pantalla a seguir el flujo.
  • Nivel general: aproximadamente AA.

1. Política fundamental: descomponer Autenticación, Autorización y Sesión

  • Autenticación: verificar quién es la persona usuaria (contraseñas, SAML/OIDC, claves de API, etc.)
  • Autorización: determinar qué puede hacer (RBAC = roles, scopes = unidades funcionales)
  • Sesión: mecanismo para persistir el estado autenticado (JWT, Cookies, sesiones de servidor)

Puntos de decisión

  • Si el front-end es SPA/móvil, elige JWT (acceso de corta duración; guarda el refresh con seguridad).
  • Si es principalmente web de mismo origen, Cookies de sesión son naturales. Aplica siempre HttpOnly/Secure/SameSite y protección CSRF.
  • Usa roles para límites amplios; protege endpoints de API con scopes para controles finos.

2. Modelo de usuario y hashing de contraseñas

2.1 Ejemplo de modelo (SQLAlchemy 2.x)

# app/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Boolean
from typing import Optional

class Base(DeclarativeBase): pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    password_hash: Mapped[str] = mapped_column(String(255))
    is_active: Mapped[bool] = mapped_column(default=True)
    role: Mapped[str] = mapped_column(String(50), default="user")  # p. ej., admin/user

2.2 Hashing y verificación

# app/security/passwords.py
from passlib.context import CryptContext

pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(plain: str) -> str:
    return pwd_ctx.hash(plain)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_ctx.verify(plain, hashed)

Puntos de decisión

  • Nunca almacenes texto plano. Usa hashes intencionalmente lentos como bcrypt o argon2.
  • Habilita política de contraseñas (longitud, clases de caracteres, comprobación de brechas) en la validación.

3. Implementación mínima de JWT (Bearer)

3.1 Ajustes y gestión de claves

# app/core/settings.py
from pydantic_settings import BaseSettings
from pydantic import Field

class Settings(BaseSettings):
    jwt_secret: str = Field(..., description="Clave secreta HS256")
    jwt_issuer: str = "example-api"
    access_token_expires_minutes: int = 15
    refresh_token_expires_days: int = 30
    class Config:
        env_file = ".env"
        extra = "ignore"

def get_settings():
    return Settings()

3.2 Emisión y verificación (usando PyJWT / libs tipo JOSE)

# app/security/jwt.py
from datetime import datetime, timedelta, timezone
import jwt  # pyjwt
from typing import Any, Dict
from app.core.settings import get_settings

ALGO = "HS256"

def create_token(sub: str, scope: str, expires_delta: timedelta, token_type: str) -> str:
    s = get_settings()
    now = datetime.now(timezone.utc)
    payload: Dict[str, Any] = {
        "iss": s.jwt_issuer,
        "sub": sub,
        "scope": scope,           # p. ej., "articles:read users:write" (separado por espacios)
        "type": token_type,       # "access" o "refresh"
        "iat": int(now.timestamp()),
        "exp": int((now + expires_delta).timestamp())
    }
    return jwt.encode(payload, s.jwt_secret, algorithm=ALGO)

def decode_token(token: str) -> Dict[str, Any]:
    s = get_settings()
    return jwt.decode(token, s.jwt_secret, algorithms=[ALGO], options={"require": ["exp","sub","type"]})

3.3 Router de autenticación (login / refresh)

# app/routers/auth.py
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel
from datetime import timedelta
from app.security.passwords import verify_password
from app.security.jwt import create_token, decode_token
from app.core.settings import get_settings

router = APIRouter(prefix="/auth", tags=["auth"])

class LoginRequest(BaseModel):
    email: str
    password: str

class TokenPair(BaseModel):
    access_token: str
    refresh_token: str
    token_type: str = "bearer"

# Función temporal de búsqueda en DB
def get_user_by_email(email: str):
    # Implementación omitida. Devuelve un usuario que incluya password_hash
    ...

@router.post("/login", response_model=TokenPair)
def login(payload: LoginRequest):
    user = get_user_by_email(payload.email)
    if not user or not verify_password(payload.password, user.password_hash) or not user.is_active:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")

    s = get_settings()
    access = create_token(
        sub=str(user.id),
        scope="articles:read users:read",  # ensamblar dinámicamente según necesidad
        expires_delta=timedelta(minutes=s.access_token_expires_minutes),
        token_type="access"
    )
    refresh = create_token(
        sub=str(user.id),
        scope="refresh",
        expires_delta=timelta(days=s.refresh_token_expires_days),
        token_type="refresh"
    )
    return TokenPair(access_token=access, refresh_token=refresh)

class RefreshRequest(BaseModel):
    refresh_token: str

@router.post("/refresh", response_model=TokenPair)
def refresh(req: RefreshRequest):
    data = decode_token(req.refresh_token)
    if data.get("type") != "refresh":
        raise HTTPException(401, "invalid token type")
    s = get_settings()
    access = create_token(sub=data["sub"], scope="articles:read users:read",
                          expires_delta=timedelta(minutes=s.access_token_expires_minutes), token_type="access")
    # En producción, rota los refresh tokens (reemitir + invalidar el anterior)
    new_refresh = create_token(sub=data["sub"], scope="refresh",
                               expires_delta=timedelta(days=s.refresh_token_expires_days), token_type="refresh")
    return TokenPair(access_token=access, refresh_token=new_refresh)

Puntos de decisión

  • Los tokens de acceso son de corta duración; los de refresco deben soportar rotación y listas negras para invalidar fugas.
  • Los scopes se separan por espacios. Mantén un esquema consistente como recurso:verbo (articles:read).

4. Autorización: combinando RBAC y Scopes

4.1 Decodificador y verificación de scopes

# app/security/deps.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Sequence
from app.security.jwt import decode_token
from app.models import User

bearer = HTTPBearer(auto_error=False)

def get_current_user(creds: HTTPAuthorizationCredentials = Depends(bearer)) -> User:
    if creds is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing token")
    data = decode_token(creds.credentials)
    # Recuperar usuario de DB (omitido) y verificar estado activo
    ...
    return user

def require_scopes(required: Sequence[str]):
    def checker(creds: HTTPAuthorizationCredentials = Depends(bearer)):
        if creds is None:
            raise HTTPException(401, "missing token")
        data = decode_token(creds.credentials)
        token_scopes = set(str(data.get("scope","")).split())
        if not set(required).issubset(token_scopes):
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="insufficient scope")
        return data
    return checker

4.2 Aplicarlo a rutas

# app/routers/articles.py
from fastapi import APIRouter, Depends
from app.security.deps import get_current_user, require_scopes

router = APIRouter(prefix="/articles", tags=["articles"])

@router.get("", dependencies=[Depends(require_scopes(["articles:read"]))])
def list_articles():
    return [{"id": 1, "title": "Hello"}]

@router.post("", dependencies=[Depends(require_scopes(["articles:write"]))])
def create_article():
    return {"ok": True}

Puntos de decisión

  • Las rutas se mantienen legibles si declaras solo los scopes requeridos ahí.
  • Mapea roles → scopes al emitir tokens, o traduce roles a scopes en el servidor.

5. Cookies de sesión + CSRF (token doble)

Para apps web centradas en navegador, una cookie de sesión HttpOnly es conveniente. Puedes almacenar estado del lado servidor o poner un JWT en una cookie (pero siempre usa HttpOnly/Secure/SameSite).

5.1 Guardar en una cookie (p. ej., token de acceso)

# app/routers/web_auth.py
from fastapi import APIRouter, Response, HTTPException, Depends
from pydantic import BaseModel
from datetime import timedelta
from app.security.passwords import verify_password
from app.security.jwt import create_token, decode_token
from app.core.settings import get_settings

router = APIRouter(prefix="/web", tags=["web-auth"])

class WebLogin(BaseModel):
    email: str
    password: str

@router.post("/login")
def web_login(payload: WebLogin, response: Response):
    user = ...  # omitido
    if not user or not verify_password(payload.password, user.password_hash):
        raise HTTPException(401, "invalid credentials")
    s = get_settings()
    access = create_token(str(user.id), "articles:read", timedelta(minutes=s.access_token_expires_minutes), "access")
    # `HttpOnly` evita lecturas desde JS; `Secure` restringe a HTTPS; configura SameSite según el caso
    response.set_cookie("access_token", access, httponly=True, secure=True, samesite="Lax", max_age=60*15, path="/")
    # Distribuye un `XSRF-TOKEN` en una cookie separada (JS la lee y la envía en una cabecera)
    response.set_cookie("XSRF-TOKEN", "random-csrf", httponly=False, secure=True, samesite="Lax", path="/")
    return {"ok": True}

5.2 Verificación CSRF (cookie de doble envío)

# app/middlewares/csrf.py
from fastapi import Request, HTTPException, status

async def verify_csrf(request: Request):
    if request.method in ("POST","PUT","PATCH","DELETE"):
        cookie = request.cookies.get("XSRF-TOKEN")
        header = request.headers.get("X-CSRF-TOKEN")
        if not cookie or not header or cookie != header:
            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="csrf failed")

Aplicación de ejemplo:

# app/routers/protected_web.py
from fastapi import APIRouter, Depends
from app.middlewares.csrf import verify_csrf

router = APIRouter(prefix="/web-api", dependencies=[Depends(verify_csrf)])

@router.post("/profile")
def update_profile():
    return {"ok": True}

Puntos de decisión

  • HttpOnly ayuda a evitar la lectura del token pero no previene CSRF—protégelo por separado.
  • SameSite="Lax" es un valor por defecto sensato. Para necesidades cross-site, usa None + HTTPS.

6. Operaciones de refresh y revocación

  • Para abordar refresh tokens robados, implementa rotación (reemitir) e invalida el token previamente emitido mediante una tabla de gestión.
  • En logout explícito, añade el jti del access token (ID del token) y los refresh tokens a una lista negra.

Ejemplo (conceptual):

# app/security/revoke.py
# Mantén un almacén de revoked_tokens (por jti o hash de firma); tras decodificar, comprueba y rechaza si está presente

Puntos de decisión

  • Usar un almacén con TTL como Redis simplifica la implementación.
  • Detecta reutilización de refresh (refresh antiguo usado otra vez tras la rotación) y alerta.

7. Registro de auditoría y limitación de tasa

  • Emite logs de auditoría estructurados para eventos clave (login éxito/fallo, cambio de contraseña, cambio de permisos, reemisión de tokens).
  • Limita la tasa de intentos de login (IP × usuario) y añade demora para suprimir fuerza bruta.

Ejemplo: reutiliza una implementación de “token-bucket” de tu toolkit de caché.


8. Estrategia de pruebas (perspectiva de seguridad)

  • Unit tests para verificación de contraseñas y rechazo de contraseñas débiles.
  • E2E: /auth/login → API protegida; prueba expiración, manipulación y casos de scope incorrecto.
  • CSRF: asegura que discrepancia cookie/cabecera devuelve 403.
  • Revocación: los tokens en lista negra deben ser rechazados.
  • Auditoría: verifica que los eventos se registren sin huecos.

9. Trampas comunes y contramedidas

Síntoma Causa Contramedida
Tokens robados por JS Almacenamiento en localStorage + XSS Cookies HttpOnly, CSP, sanitización, auditorías XSS
CSRF exitoso Autenticación solo por cookies, sin defensa CSRF Cookie doble, SameSite, comprobaciones Origin/Referer
Expansión de scopes Parseo de strings laxo Trata scopes como conjunto y compara estrictamente
Abuso de refresh Vida muy larga + sin rotación Rotación + lista negra; IDs por dispositivo y revocación
Fuga en rutas Tokens de acceso en logs Enmascara cabeceras de auth; omite PII

10. Boilerplate: app mínima extremo a extremo

# app/main.py
from fastapi import FastAPI
from app.routers import auth, articles, web_auth, protected_web

app = FastAPI(title="Secure API")

# APIs para JWT (Bearer)
app.include_router(auth.router)
app.include_router(articles.router)

# APIs para Web (Cookie + CSRF)
app.include_router(web_auth.router)
app.include_router(protected_web.router)

@app.get("/health")
def health():
    return {"ok": True}

11. Hoja de ruta de adopción

  1. Implementa hashing de contraseñas y el modelo de usuario; pon a funcionar el JWT mínimo en /auth/login.
  2. Protege endpoints con scopes vía require_scopes.
  3. Agrega rotación de refresh, lista negra, auditoría y rate limiting.
  4. Introduce Cookie + CSRF para web; verifica HttpOnly/Secure/SameSite.
  5. Operacionaliza rotación de claves, separación de entornos y tableros de auditoría.

12. Referencias


Conclusión

  • Con JWT, usa tokens de acceso de corta duración más tokens de refresco rotables. Aclara la autorización con roles y scopes; declarar solo los requisitos mínimos en cada ruta mejora la mantenibilidad.
  • En la web, Cookies + CSRF son efectivos. Aplica el trío HttpOnly/Secure/SameSite, y superpone defensas con el token de doble envío.
  • Incorpora revocación, limitación de tasa, auditoría y rotación de claves/tokens en operaciones, y verifica continuamente la seguridad con pruebas. Empieza pequeño hoy y expande de forma constante tu zona segura.

por greeden

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)