Spaces:
Sleeping
Sleeping
| """Autenticación: /api/auth (login, registro, logout) y estado de sesión. | |
| Mejoras de seguridad frente a auth.php: | |
| - Throttling de intentos de login por email+IP (fuerza bruta). | |
| - Cookies de sesión firmadas, HttpOnly, SameSite=Strict, Secure en prod. | |
| - Token CSRF de doble envío emitido al autenticar. | |
| - Contraseña mínima de 8 caracteres. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| from fastapi import APIRouter, Depends, HTTPException, Request, Response, status | |
| from pydantic import BaseModel, EmailStr, Field | |
| from ..config import TENANT_POR_DEFECTO, obtener_config | |
| from ..db import ( | |
| buscar_usuario, | |
| crear_usuario, | |
| intentos_recientes, | |
| limpiar_intentos, | |
| registrar_intento, | |
| revocar_sesion, | |
| revocar_todas_las_sesiones, | |
| verificar_password, | |
| ) | |
| from ..security.authz import usuario_actual | |
| from ..security.rate_limit import ip_cliente, limiter | |
| from ..security.session import ( | |
| COOKIE_CSRF, | |
| COOKIE_SESION, | |
| caducidad_de, | |
| firmar_sesion, | |
| nuevo_token_csrf, | |
| ) | |
| router = APIRouter() | |
| _VENTANA_THROTTLE_S = 900 | |
| _MAX_INTENTOS = 8 | |
| class LoginBody(BaseModel): | |
| email: EmailStr | |
| password: str = Field(min_length=1) | |
| class RegistroBody(BaseModel): | |
| nombre: str = Field(min_length=1, max_length=100) | |
| apellido: str = Field(min_length=1, max_length=100) | |
| email: EmailStr | |
| password: str = Field(min_length=8, max_length=200) | |
| def _emitir_sesion(resp: Response, email: str, nombre: str, tenant: str) -> str: | |
| cfg = obtener_config() | |
| # El tenant viaja en la cookie FIRMADA: el cliente no puede cambiarlo sin romper la firma, | |
| # y así leerlo no cuesta una consulta a la BD en cada petición. | |
| token = firmar_sesion({"email": email, "nombre": nombre, "tenant": tenant}) | |
| csrf = nuevo_token_csrf() | |
| resp.set_cookie( | |
| COOKIE_SESION, token, httponly=True, secure=cfg.cookie_secure, | |
| samesite="strict", max_age=cfg.session_max_age_s, | |
| ) | |
| # La cookie CSRF NO es HttpOnly: el JS la lee y la reenvía en la cabecera. | |
| resp.set_cookie( | |
| COOKIE_CSRF, csrf, httponly=False, secure=cfg.cookie_secure, | |
| samesite="strict", max_age=cfg.session_max_age_s, | |
| ) | |
| return csrf | |
| async def estado(request: Request) -> dict: | |
| from ..security.session import leer_sesion | |
| sesion = leer_sesion(request.cookies.get(COOKIE_SESION)) | |
| return {"autenticado": bool(sesion), "nombre": sesion.get("nombre") if sesion else None} | |
| async def login(request: Request, body: LoginBody, response: Response) -> dict: | |
| # Todo lo que toca SQLite o scrypt va a un hilo: son llamadas SÍNCRONAS dentro de un | |
| # endpoint `async`, así que en el bucle de eventos bloqueaban el proceso entero. scrypt es | |
| # además caro A PROPÓSITO (n=2**14, decenas de ms): es justo el trabajo que no puede vivir | |
| # en el bucle, y el login es el endpoint que más veces lo ejecuta. | |
| # `ip_cliente` y no `request.client.host`: detrás del proxy este último es el proxy, así | |
| # que el throttle por email+IP degeneraba en un contador global. | |
| ip = ip_cliente(request) | |
| if await asyncio.to_thread(intentos_recientes, body.email, ip, _VENTANA_THROTTLE_S) >= _MAX_INTENTOS: | |
| raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Demasiados intentos. Espera unos minutos.") | |
| usuario = await asyncio.to_thread(buscar_usuario, body.email) | |
| if not usuario or not await asyncio.to_thread(verificar_password, body.password, usuario["password"]): | |
| await asyncio.to_thread(registrar_intento, body.email, ip) | |
| # Mensaje genérico: no revela si el email existe. | |
| raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Email o contraseña incorrectos.") | |
| await asyncio.to_thread(limpiar_intentos, body.email) | |
| csrf = _emitir_sesion( | |
| response, usuario["email"], usuario["nombre"], usuario["tenant"] or TENANT_POR_DEFECTO | |
| ) | |
| return {"ok": True, "nombre": usuario["nombre"], "csrf": csrf} | |
| async def registro(request: Request, body: RegistroBody, response: Response) -> dict: | |
| # ANTES que la comprobación de existencia, a propósito: si se hiciera después, un email | |
| # fuera de la lista distinguiría «ya existe» (409) de «no existe» (403) y el alta se | |
| # convertiría en un oráculo de qué cuentas hay. Fuera de la lista, siempre 403. | |
| if not obtener_config().registro_permitido(body.email): | |
| raise HTTPException( | |
| status.HTTP_403_FORBIDDEN, | |
| "El alta de cuentas está restringida. Solicita acceso al administrador.", | |
| ) | |
| if await asyncio.to_thread(buscar_usuario, body.email): | |
| raise HTTPException(status.HTTP_409_CONFLICT, "Ya existe una cuenta con ese email.") | |
| # `crear_usuario` hashea con scrypt además de escribir: doble motivo para salir del bucle. | |
| # La clínica sale de la allowlist (`email=tenant`), no del cuerpo de la petición: dejar | |
| # elegir tenant al que se registra permitiría entrar en los datos de otra clínica. | |
| tenant = obtener_config().tenant_de_email(body.email) | |
| await asyncio.to_thread( | |
| crear_usuario, body.nombre, body.apellido, body.email, body.password, tenant | |
| ) | |
| csrf = _emitir_sesion(response, body.email, body.nombre, tenant) | |
| return {"ok": True, "nombre": body.nombre, "csrf": csrf} | |
| def _borrar_cookies(resp: Response) -> None: | |
| """Borra las cookies con los MISMOS atributos con que se pusieron. | |
| `delete_cookie()` a secas emite un Set-Cookie sin `samesite`/`secure`/`path`, y varios | |
| navegadores lo tratan como una cookie DISTINTA de la original: la sesión seguía en el | |
| navegador después de «cerrar sesión». | |
| """ | |
| cfg = obtener_config() | |
| for nombre in (COOKIE_SESION, COOKIE_CSRF): | |
| resp.delete_cookie(nombre, path="/", samesite="strict", secure=cfg.cookie_secure) | |
| async def logout(response: Response, sesion: dict = Depends(usuario_actual)) -> dict: | |
| """Cierra ESTA sesión. Borrar la cookie no bastaba: el token seguía siendo válido allá | |
| donde se hubiera copiado, hasta `session_max_age_s` (8h por defecto).""" | |
| if jti := sesion.get("jti"): | |
| await asyncio.to_thread(revocar_sesion, jti, caducidad_de(sesion)) | |
| _borrar_cookies(response) | |
| return {"ok": True} | |
| async def logout_todas(response: Response, sesion: dict = Depends(usuario_actual)) -> dict: | |
| """Cierra la sesión en TODOS los dispositivos. | |
| Es la respuesta a «me han robado el portátil» o a un cambio de contraseña: sin esto, la | |
| única forma de invalidar una sesión filtrada era rotar `MORPHOS_SESSION_SECRET`, que echa a | |
| todos los usuarios de la instancia. | |
| """ | |
| await asyncio.to_thread(revocar_todas_las_sesiones, sesion["email"]) | |
| _borrar_cookies(response) | |
| return {"ok": True} | |