"""Dependencias de autorización y CSRF para FastAPI. Cierra el agujero crítico de la versión PHP: /api/interpret y /api/papers estaban abiertos. Aquí requieren sesión válida. Las peticiones mutantes exigen doble-token CSRF. """ from __future__ import annotations import hmac from datetime import datetime from fastapi import Cookie, Header, HTTPException, Request, status from ..config import TENANT_POR_DEFECTO from .session import CABECERA_CSRF, COOKIE_CSRF, COOKIE_SESION, leer_sesion def usuario_actual(request: Request) -> dict: """Devuelve la sesión o 401. Usar como dependencia en rutas protegidas. La firma válida ya no basta: una cookie firmada valía hasta caducar mirándola sólo a ella, así que una copiada no se podía invalidar. Se comprueban además las dos revocaciones (`db.revocar_sesion` para una, `db.revocar_todas_las_sesiones` para las de una cuenta). Es `def` y no `async def` a propósito: FastAPI ejecuta las dependencias síncronas en un hilo, así que estas dos consultas no bloquean el bucle de eventos (ver §3.1). """ token = request.cookies.get(COOKIE_SESION) sesion = leer_sesion(token) if not sesion or not sesion.get("email"): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No autenticado.") if _revocada(sesion): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sesión revocada.") return sesion def _revocada(sesion: dict) -> bool: from .. import db jti = sesion.get("jti") # Sin `jti` es una cookie anterior a la revocación. Se acepta hasta que caduque: invalidarlas # de golpe echaría a todo el mundo en el despliegue, y su vida máxima ya es acotada. if jti and db.sesion_revocada(jti): return True corte = db.sesiones_validas_desde(sesion["email"]) if not corte: return False emitida = sesion.get("emitida_en") if not emitida: return True # hay corte y la sesión no dice cuándo nació: se descarta # Ambas ISO-8601 UTC con microsegundos: a resolución de segundo, una sesión emitida en el # mismo segundo que el corte sobrevivía. try: return datetime.fromisoformat(emitida) < datetime.fromisoformat(corte) except ValueError: return True def tenant_de_sesion(sesion: dict) -> str: """Clínica del usuario de la sesión. Las sesiones emitidas ANTES de que existiera el tenant no lo llevan en la cookie firmada, y duran hasta `session_max_age_s`. Caen al tenant por defecto, que es donde también viven los dispositivos que no declaran clínica: un despliegue de una sola clínica —el caso normal— sigue funcionando durante la transición sin que nadie tenga que volver a entrar. """ return sesion.get("tenant") or TENANT_POR_DEFECTO def verificar_csrf( request: Request, x_csrf_token: str | None = Header(default=None, alias=CABECERA_CSRF), morphos_csrf: str | None = Cookie(default=None, alias=COOKIE_CSRF), ) -> None: """Double-submit cookie: la cabecera debe coincidir con la cookie CSRF.""" if request.method in ("GET", "HEAD", "OPTIONS"): return # Comparación en tiempo constante: el token es un secreto de sesión, así que se compara con # compare_digest por costumbre defensiva (no con `!=`, que corta en el primer byte distinto). if not x_csrf_token or not morphos_csrf or not hmac.compare_digest(x_csrf_token, morphos_csrf): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF inválido.")