Spaces:
Sleeping
Sleeping
File size: 6,953 Bytes
70e641d e26316b 70e641d 1339cdc 70e641d 2f5c422 70e641d 5118fc3 70e641d 2f5c422 70e641d 1339cdc 70e641d 1339cdc 70e641d e26316b 5118fc3 e26316b 70e641d e26316b 70e641d e26316b 1339cdc 70e641d 61cd0db e26316b 70e641d e26316b 1339cdc 70e641d 2f5c422 70e641d 2f5c422 70e641d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | """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
@router.get("/auth")
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}
@router.post("/auth/login")
@limiter.limit(obtener_config().limite_login)
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}
@router.post("/auth/registro")
@limiter.limit(obtener_config().limite_login)
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)
@router.post("/auth/logout")
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}
@router.post("/auth/logout-todas")
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}
|