Spaces:
Sleeping
Sesiones revocables: logout real y cierre en todos los dispositivos
Browse filesARCHITECTURE_REVIEW §2.3. Las sesiones son cookies firmadas sin estado y el
logout se limitaba a borrar la cookie del navegador: el token seguía siendo
VÁLIDO allá donde se hubiera copiado, hasta `session_max_age_s` (8h). No había
forma de invalidar una sesión filtrada ni de echar a nadie tras un incidente,
salvo rotar MORPHOS_SESSION_SECRET, que tira a todos los usuarios a la vez.
Dos mecanismos, porque resuelven cosas distintas (migración 4):
- `sesiones_revocadas`: una sesión concreta, por `jti`. Se recuerda sólo hasta que
su firma caduca por sí sola; a partir de ahí la fila sobra y se poda.
- `usuarios.sesiones_validas_desde`: TODAS las de una cuenta a la vez, con el
nuevo POST /api/auth/logout-todas. Un sello temporal en vez de un contador de
versión porque la pregunta es «¿se emitió antes del corte?».
`jti` y `emitida_en` los añade `firmar_sesion`, no quien llama: si se generaran
fuera, una ruta podría emitir por descuido una sesión irrevocable.
TIMESTAMPS EN PYTHON, NO EN SQLITE. `datetime('now')` tiene resolución de SEGUNDO,
y con ella una sesión emitida en el mismo segundo que el corte SOBREVIVÍA a la
revocación —lo detectó la prueba de logout-todas—. Además SQLite separa fecha y
hora con un espacio y las sesiones usan 'T', así que comparar sus cadenas ordenaba
mal. Ahora ambos lados son ISO-8601 UTC con microsegundos.
Se arregla también la segunda mitad de §2.3: `delete_cookie()` a secas emite un
Set-Cookie sin samesite/secure/path y varios navegadores lo tratan como una cookie
DISTINTA de la original, así que la sesión se quedaba en el navegador.
Las cookies sin `jti` (emitidas antes de esto) se aceptan hasta caducar:
invalidarlas de golpe echaría a todo el mundo en el despliegue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- backend/app/db.py +66 -0
- backend/app/routers/auth.py +34 -3
- backend/app/security/authz.py +34 -1
- backend/app/security/session.py +24 -1
- backend/tests/test_revocacion_sesiones.py +97 -0
- backend/tests/test_session.py +17 -1
|
@@ -15,6 +15,7 @@ import secrets
|
|
| 15 |
import sqlite3
|
| 16 |
from collections.abc import Iterator
|
| 17 |
from contextlib import contextmanager
|
|
|
|
| 18 |
|
| 19 |
from .config import TENANT_POR_DEFECTO, VOLUMEN_PERSISTENTE, obtener_config
|
| 20 |
|
|
@@ -67,6 +68,22 @@ CREATE INDEX IF NOT EXISTS idx_intentos_email_ip_momento
|
|
| 67 |
# de una sola clínica no nota el cambio.
|
| 68 |
f"""
|
| 69 |
ALTER TABLE usuarios ADD COLUMN tenant TEXT NOT NULL DEFAULT '{TENANT_POR_DEFECTO}';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
""",
|
| 71 |
]
|
| 72 |
|
|
@@ -150,6 +167,55 @@ def crear_usuario(nombre: str, apellido: str, email: str, password: str, tenant:
|
|
| 150 |
)
|
| 151 |
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
# --- Registro de intentos de login (para throttling) ---
|
| 154 |
|
| 155 |
def registrar_intento(email: str, ip: str) -> None:
|
|
|
|
| 15 |
import sqlite3
|
| 16 |
from collections.abc import Iterator
|
| 17 |
from contextlib import contextmanager
|
| 18 |
+
from datetime import UTC, datetime
|
| 19 |
|
| 20 |
from .config import TENANT_POR_DEFECTO, VOLUMEN_PERSISTENTE, obtener_config
|
| 21 |
|
|
|
|
| 68 |
# de una sola clínica no nota el cambio.
|
| 69 |
f"""
|
| 70 |
ALTER TABLE usuarios ADD COLUMN tenant TEXT NOT NULL DEFAULT '{TENANT_POR_DEFECTO}';
|
| 71 |
+
""",
|
| 72 |
+
# 4 — revocación de sesiones. Las cookies firmadas son válidas hasta que caducan mirándolas
|
| 73 |
+
# sólo a ellas, así que no había forma de invalidar una copiada ni de echar a nadie tras un
|
| 74 |
+
# incidente. Dos mecanismos, porque resuelven cosas distintas:
|
| 75 |
+
# - `sesiones_revocadas`: una sesión concreta (logout). Se guarda hasta su caducidad; a
|
| 76 |
+
# partir de ahí la firma ya no vale por sí sola y la fila sobra.
|
| 77 |
+
# - `usuarios.sesiones_validas_desde`: TODAS las de una cuenta a la vez (cambio de
|
| 78 |
+
# contraseña, robo). Un sello temporal en vez de un contador de versión porque la
|
| 79 |
+
# pregunta que hay que responder es «¿se emitió antes del corte?».
|
| 80 |
+
"""
|
| 81 |
+
CREATE TABLE IF NOT EXISTS sesiones_revocadas (
|
| 82 |
+
jti TEXT PRIMARY KEY,
|
| 83 |
+
expira_en DATETIME NOT NULL
|
| 84 |
+
);
|
| 85 |
+
CREATE INDEX IF NOT EXISTS idx_sesiones_revocadas_expira ON sesiones_revocadas (expira_en);
|
| 86 |
+
ALTER TABLE usuarios ADD COLUMN sesiones_validas_desde DATETIME;
|
| 87 |
""",
|
| 88 |
]
|
| 89 |
|
|
|
|
| 167 |
)
|
| 168 |
|
| 169 |
|
| 170 |
+
# --- Revocación de sesiones ---
|
| 171 |
+
|
| 172 |
+
def _ahora_iso() -> str:
|
| 173 |
+
"""Instante actual en ISO-8601 UTC con microsegundos.
|
| 174 |
+
|
| 175 |
+
Se genera en Python y NO con `datetime('now')` de SQLite por dos motivos: SQLite tiene
|
| 176 |
+
resolución de SEGUNDO —una sesión emitida en el mismo segundo que un corte de revocación
|
| 177 |
+
sobrevivía— y usa un espacio en vez de 'T', así que comparar sus cadenas con las ISO de las
|
| 178 |
+
sesiones daba órdenes incorrectos.
|
| 179 |
+
"""
|
| 180 |
+
return datetime.now(UTC).isoformat()
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def revocar_sesion(jti: str, expira_en: str) -> None:
|
| 184 |
+
"""Invalida UNA sesión (logout) hasta que su firma caduque por sí sola."""
|
| 185 |
+
with _conexion() as con:
|
| 186 |
+
con.execute(
|
| 187 |
+
"INSERT OR REPLACE INTO sesiones_revocadas (jti, expira_en) VALUES (?, ?)",
|
| 188 |
+
(jti, expira_en),
|
| 189 |
+
)
|
| 190 |
+
# Poda oportunista: pasada su caducidad la firma ya no vale, así que la fila no aporta.
|
| 191 |
+
# El corte va como parámetro, en el MISMO formato que lo guardado.
|
| 192 |
+
con.execute("DELETE FROM sesiones_revocadas WHERE expira_en < ?", (_ahora_iso(),))
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def sesion_revocada(jti: str) -> bool:
|
| 196 |
+
with _conexion() as con:
|
| 197 |
+
cur = con.execute("SELECT 1 FROM sesiones_revocadas WHERE jti = ? LIMIT 1", (jti,))
|
| 198 |
+
return cur.fetchone() is not None
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def revocar_todas_las_sesiones(email: str) -> None:
|
| 202 |
+
"""Corta TODAS las sesiones de una cuenta: las emitidas antes de ahora dejan de valer."""
|
| 203 |
+
with _conexion() as con:
|
| 204 |
+
con.execute(
|
| 205 |
+
"UPDATE usuarios SET sesiones_validas_desde = ? WHERE email = ?",
|
| 206 |
+
(_ahora_iso(), email),
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def sesiones_validas_desde(email: str) -> str | None:
|
| 211 |
+
with _conexion() as con:
|
| 212 |
+
cur = con.execute(
|
| 213 |
+
"SELECT sesiones_validas_desde FROM usuarios WHERE email = ? LIMIT 1", (email,)
|
| 214 |
+
)
|
| 215 |
+
fila = cur.fetchone()
|
| 216 |
+
return fila["sesiones_validas_desde"] if fila else None
|
| 217 |
+
|
| 218 |
+
|
| 219 |
# --- Registro de intentos de login (para throttling) ---
|
| 220 |
|
| 221 |
def registrar_intento(email: str, ip: str) -> None:
|
|
@@ -21,6 +21,8 @@ from ..db import (
|
|
| 21 |
intentos_recientes,
|
| 22 |
limpiar_intentos,
|
| 23 |
registrar_intento,
|
|
|
|
|
|
|
| 24 |
verificar_password,
|
| 25 |
)
|
| 26 |
from ..security.authz import usuario_actual
|
|
@@ -28,6 +30,7 @@ from ..security.rate_limit import ip_cliente, limiter
|
|
| 28 |
from ..security.session import (
|
| 29 |
COOKIE_CSRF,
|
| 30 |
COOKIE_SESION,
|
|
|
|
| 31 |
firmar_sesion,
|
| 32 |
nuevo_token_csrf,
|
| 33 |
)
|
|
@@ -126,8 +129,36 @@ async def registro(request: Request, body: RegistroBody, response: Response) ->
|
|
| 126 |
return {"ok": True, "nombre": body.nombre, "csrf": csrf}
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
@router.post("/auth/logout")
|
| 130 |
-
async def logout(response: Response,
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
return {"ok": True}
|
|
|
|
| 21 |
intentos_recientes,
|
| 22 |
limpiar_intentos,
|
| 23 |
registrar_intento,
|
| 24 |
+
revocar_sesion,
|
| 25 |
+
revocar_todas_las_sesiones,
|
| 26 |
verificar_password,
|
| 27 |
)
|
| 28 |
from ..security.authz import usuario_actual
|
|
|
|
| 30 |
from ..security.session import (
|
| 31 |
COOKIE_CSRF,
|
| 32 |
COOKIE_SESION,
|
| 33 |
+
caducidad_de,
|
| 34 |
firmar_sesion,
|
| 35 |
nuevo_token_csrf,
|
| 36 |
)
|
|
|
|
| 129 |
return {"ok": True, "nombre": body.nombre, "csrf": csrf}
|
| 130 |
|
| 131 |
|
| 132 |
+
def _borrar_cookies(resp: Response) -> None:
|
| 133 |
+
"""Borra las cookies con los MISMOS atributos con que se pusieron.
|
| 134 |
+
|
| 135 |
+
`delete_cookie()` a secas emite un Set-Cookie sin `samesite`/`secure`/`path`, y varios
|
| 136 |
+
navegadores lo tratan como una cookie DISTINTA de la original: la sesión seguía en el
|
| 137 |
+
navegador después de «cerrar sesión».
|
| 138 |
+
"""
|
| 139 |
+
cfg = obtener_config()
|
| 140 |
+
for nombre in (COOKIE_SESION, COOKIE_CSRF):
|
| 141 |
+
resp.delete_cookie(nombre, path="/", samesite="strict", secure=cfg.cookie_secure)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
@router.post("/auth/logout")
|
| 145 |
+
async def logout(response: Response, sesion: dict = Depends(usuario_actual)) -> dict:
|
| 146 |
+
"""Cierra ESTA sesión. Borrar la cookie no bastaba: el token seguía siendo válido allá
|
| 147 |
+
donde se hubiera copiado, hasta `session_max_age_s` (8h por defecto)."""
|
| 148 |
+
if jti := sesion.get("jti"):
|
| 149 |
+
await asyncio.to_thread(revocar_sesion, jti, caducidad_de(sesion))
|
| 150 |
+
_borrar_cookies(response)
|
| 151 |
+
return {"ok": True}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@router.post("/auth/logout-todas")
|
| 155 |
+
async def logout_todas(response: Response, sesion: dict = Depends(usuario_actual)) -> dict:
|
| 156 |
+
"""Cierra la sesión en TODOS los dispositivos.
|
| 157 |
+
|
| 158 |
+
Es la respuesta a «me han robado el portátil» o a un cambio de contraseña: sin esto, la
|
| 159 |
+
única forma de invalidar una sesión filtrada era rotar `MORPHOS_SESSION_SECRET`, que echa a
|
| 160 |
+
todos los usuarios de la instancia.
|
| 161 |
+
"""
|
| 162 |
+
await asyncio.to_thread(revocar_todas_las_sesiones, sesion["email"])
|
| 163 |
+
_borrar_cookies(response)
|
| 164 |
return {"ok": True}
|
|
@@ -7,6 +7,7 @@ abiertos. Aquí requieren sesión válida. Las peticiones mutantes exigen doble-
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import hmac
|
|
|
|
| 10 |
|
| 11 |
from fastapi import Cookie, Header, HTTPException, Request, status
|
| 12 |
|
|
@@ -15,14 +16,46 @@ from .session import CABECERA_CSRF, COOKIE_CSRF, COOKIE_SESION, leer_sesion
|
|
| 15 |
|
| 16 |
|
| 17 |
def usuario_actual(request: Request) -> dict:
|
| 18 |
-
"""Devuelve la sesión o 401. Usar como dependencia en rutas protegidas.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
token = request.cookies.get(COOKIE_SESION)
|
| 20 |
sesion = leer_sesion(token)
|
| 21 |
if not sesion or not sesion.get("email"):
|
| 22 |
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No autenticado.")
|
|
|
|
|
|
|
| 23 |
return sesion
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def tenant_de_sesion(sesion: dict) -> str:
|
| 27 |
"""Clínica del usuario de la sesión.
|
| 28 |
|
|
|
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import hmac
|
| 10 |
+
from datetime import datetime
|
| 11 |
|
| 12 |
from fastapi import Cookie, Header, HTTPException, Request, status
|
| 13 |
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def usuario_actual(request: Request) -> dict:
|
| 19 |
+
"""Devuelve la sesión o 401. Usar como dependencia en rutas protegidas.
|
| 20 |
+
|
| 21 |
+
La firma válida ya no basta: una cookie firmada valía hasta caducar mirándola sólo a ella,
|
| 22 |
+
así que una copiada no se podía invalidar. Se comprueban además las dos revocaciones
|
| 23 |
+
(`db.revocar_sesion` para una, `db.revocar_todas_las_sesiones` para las de una cuenta).
|
| 24 |
+
|
| 25 |
+
Es `def` y no `async def` a propósito: FastAPI ejecuta las dependencias síncronas en un hilo,
|
| 26 |
+
así que estas dos consultas no bloquean el bucle de eventos (ver §3.1).
|
| 27 |
+
"""
|
| 28 |
token = request.cookies.get(COOKIE_SESION)
|
| 29 |
sesion = leer_sesion(token)
|
| 30 |
if not sesion or not sesion.get("email"):
|
| 31 |
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No autenticado.")
|
| 32 |
+
if _revocada(sesion):
|
| 33 |
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sesión revocada.")
|
| 34 |
return sesion
|
| 35 |
|
| 36 |
|
| 37 |
+
def _revocada(sesion: dict) -> bool:
|
| 38 |
+
from .. import db
|
| 39 |
+
|
| 40 |
+
jti = sesion.get("jti")
|
| 41 |
+
# Sin `jti` es una cookie anterior a la revocación. Se acepta hasta que caduque: invalidarlas
|
| 42 |
+
# de golpe echaría a todo el mundo en el despliegue, y su vida máxima ya es acotada.
|
| 43 |
+
if jti and db.sesion_revocada(jti):
|
| 44 |
+
return True
|
| 45 |
+
corte = db.sesiones_validas_desde(sesion["email"])
|
| 46 |
+
if not corte:
|
| 47 |
+
return False
|
| 48 |
+
emitida = sesion.get("emitida_en")
|
| 49 |
+
if not emitida:
|
| 50 |
+
return True # hay corte y la sesión no dice cuándo nació: se descarta
|
| 51 |
+
# Ambas ISO-8601 UTC con microsegundos: a resolución de segundo, una sesión emitida en el
|
| 52 |
+
# mismo segundo que el corte sobrevivía.
|
| 53 |
+
try:
|
| 54 |
+
return datetime.fromisoformat(emitida) < datetime.fromisoformat(corte)
|
| 55 |
+
except ValueError:
|
| 56 |
+
return True
|
| 57 |
+
|
| 58 |
+
|
| 59 |
def tenant_de_sesion(sesion: dict) -> str:
|
| 60 |
"""Clínica del usuario de la sesión.
|
| 61 |
|
|
@@ -8,6 +8,7 @@ cabecera X-CSRF-Token en peticiones mutantes.
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import secrets
|
|
|
|
| 11 |
|
| 12 |
from itsdangerous import BadData, URLSafeTimedSerializer
|
| 13 |
|
|
@@ -25,7 +26,29 @@ def _serializer() -> URLSafeTimedSerializer:
|
|
| 25 |
|
| 26 |
|
| 27 |
def firmar_sesion(datos: dict) -> str:
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def leer_sesion(token: str | None) -> dict | None:
|
|
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import secrets
|
| 11 |
+
from datetime import UTC, datetime, timedelta
|
| 12 |
|
| 13 |
from itsdangerous import BadData, URLSafeTimedSerializer
|
| 14 |
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def firmar_sesion(datos: dict) -> str:
|
| 29 |
+
"""Firma la sesión añadiéndole identidad (`jti`) e instante de emisión (`emitida_en`).
|
| 30 |
+
|
| 31 |
+
Sin `jti` no se puede revocar UNA sesión —no hay nada que nombrar—, y sin `emitida_en` no se
|
| 32 |
+
puede cortar un conjunto de ellas por fecha. Se generan aquí, y no en quien llama, para que
|
| 33 |
+
ninguna ruta pueda emitir por descuido una sesión irrevocable.
|
| 34 |
+
"""
|
| 35 |
+
completos = {
|
| 36 |
+
"jti": secrets.token_urlsafe(16),
|
| 37 |
+
"emitida_en": datetime.now(UTC).isoformat(),
|
| 38 |
+
**datos,
|
| 39 |
+
}
|
| 40 |
+
return _serializer().dumps(completos)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def caducidad_de(sesion: dict) -> str:
|
| 44 |
+
"""Cuándo deja de valer la firma por sí sola: hasta ahí hay que recordar una revocación."""
|
| 45 |
+
cfg = obtener_config()
|
| 46 |
+
emitida = sesion.get("emitida_en")
|
| 47 |
+
try:
|
| 48 |
+
base = datetime.fromisoformat(emitida) if emitida else datetime.now(UTC)
|
| 49 |
+
except ValueError:
|
| 50 |
+
base = datetime.now(UTC)
|
| 51 |
+
return (base + timedelta(seconds=cfg.session_max_age_s)).isoformat()
|
| 52 |
|
| 53 |
|
| 54 |
def leer_sesion(token: str | None) -> dict | None:
|
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Revocación de sesiones (ARCHITECTURE_REVIEW §2.3).
|
| 2 |
+
|
| 3 |
+
Las sesiones son cookies firmadas sin estado, y el logout se limitaba a borrar la cookie del
|
| 4 |
+
navegador: el token seguía siendo VÁLIDO allá donde se hubiera copiado, hasta
|
| 5 |
+
`session_max_age_s` (8h). No había forma de invalidar una sesión filtrada, ni de echar a nadie
|
| 6 |
+
tras un incidente, salvo rotar `MORPHOS_SESSION_SECRET` y tirar a todos los usuarios a la vez.
|
| 7 |
+
|
| 8 |
+
La prueba que importa no es «logout devuelve 200», sino que **la cookie de antes ya no sirve**.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
from fastapi.testclient import TestClient
|
| 15 |
+
|
| 16 |
+
from app.main import app
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def cliente():
|
| 21 |
+
with TestClient(app) as c:
|
| 22 |
+
yield c
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _alta(cliente, email):
|
| 26 |
+
r = cliente.post(
|
| 27 |
+
"/api/auth/registro",
|
| 28 |
+
json={"nombre": "V", "apellido": "Vet", "email": email, "password": "clave-segura-1"},
|
| 29 |
+
)
|
| 30 |
+
if r.status_code == 409:
|
| 31 |
+
r = cliente.post("/api/auth/login", json={"email": email, "password": "clave-segura-1"})
|
| 32 |
+
assert r.status_code == 200, r.text
|
| 33 |
+
return r
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_la_cookie_deja_de_valer_tras_el_logout(cliente, alta_abierta):
|
| 37 |
+
"""El agujero concreto: una copia de la cookie sobrevivía al «cerrar sesión»."""
|
| 38 |
+
_alta(cliente, "rev1@example.com")
|
| 39 |
+
galleta = cliente.cookies.get("morphos_sesion")
|
| 40 |
+
assert cliente.get("/api/auth").json()["autenticado"] is True
|
| 41 |
+
|
| 42 |
+
cliente.post("/api/auth/logout")
|
| 43 |
+
|
| 44 |
+
# Se reenvía la cookie a mano, como haría quien la hubiera copiado.
|
| 45 |
+
r = cliente.get("/api/lab/pendientes", cookies={"morphos_sesion": galleta})
|
| 46 |
+
assert r.status_code == 401
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_logout_todas_corta_las_demas_sesiones(cliente, alta_abierta):
|
| 50 |
+
"""«Me han robado el portátil»: cierra las sesiones abiertas en otros dispositivos."""
|
| 51 |
+
_alta(cliente, "rev2@example.com")
|
| 52 |
+
portatil = cliente.cookies.get("morphos_sesion")
|
| 53 |
+
|
| 54 |
+
# Segunda sesión (otro dispositivo) para la misma cuenta.
|
| 55 |
+
otro = TestClient(app)
|
| 56 |
+
r = otro.post("/api/auth/login", json={"email": "rev2@example.com", "password": "clave-segura-1"})
|
| 57 |
+
assert r.status_code == 200
|
| 58 |
+
|
| 59 |
+
otro.post("/api/auth/logout-todas")
|
| 60 |
+
|
| 61 |
+
r = cliente.get("/api/lab/pendientes", cookies={"morphos_sesion": portatil})
|
| 62 |
+
assert r.status_code == 401, "la sesión del otro dispositivo siguió viva"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_revocar_una_no_afecta_a_las_de_otras_cuentas(cliente, alta_abierta):
|
| 66 |
+
"""Aislar el corte: revocar no puede convertirse en una denegación de servicio ajena."""
|
| 67 |
+
_alta(cliente, "rev3@example.com")
|
| 68 |
+
ajena = TestClient(app)
|
| 69 |
+
_alta(ajena, "rev4@example.com")
|
| 70 |
+
|
| 71 |
+
cliente.post("/api/auth/logout-todas")
|
| 72 |
+
|
| 73 |
+
assert ajena.get("/api/auth").json()["autenticado"] is True
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_una_sesion_nueva_tras_el_corte_si_vale(cliente, alta_abierta):
|
| 77 |
+
"""El corte es por fecha de emisión: volver a entrar tiene que funcionar."""
|
| 78 |
+
_alta(cliente, "rev5@example.com")
|
| 79 |
+
cliente.post("/api/auth/logout-todas")
|
| 80 |
+
|
| 81 |
+
r = cliente.post(
|
| 82 |
+
"/api/auth/login", json={"email": "rev5@example.com", "password": "clave-segura-1"}
|
| 83 |
+
)
|
| 84 |
+
assert r.status_code == 200
|
| 85 |
+
assert cliente.get("/api/auth").json()["autenticado"] is True
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_el_logout_borra_las_cookies_con_sus_atributos(cliente, alta_abierta):
|
| 89 |
+
"""`delete_cookie()` a secas emite un Set-Cookie sin samesite/path y algunos navegadores lo
|
| 90 |
+
tratan como una cookie distinta: la sesión se quedaba en el navegador."""
|
| 91 |
+
_alta(cliente, "rev6@example.com")
|
| 92 |
+
r = cliente.post("/api/auth/logout")
|
| 93 |
+
|
| 94 |
+
borrados = [v for v in r.headers.get_list("set-cookie") if "morphos_sesion" in v]
|
| 95 |
+
assert borrados, "el logout no intentó borrar la cookie de sesión"
|
| 96 |
+
assert "Path=/" in borrados[0]
|
| 97 |
+
assert "samesite=strict" in borrados[0].lower()
|
|
@@ -14,7 +14,23 @@ from app.security import session as ses
|
|
| 14 |
|
| 15 |
def test_ida_y_vuelta():
|
| 16 |
token = ses.firmar_sesion({"email": "vet@example.com"})
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
@pytest.mark.parametrize(
|
|
|
|
| 14 |
|
| 15 |
def test_ida_y_vuelta():
|
| 16 |
token = ses.firmar_sesion({"email": "vet@example.com"})
|
| 17 |
+
sesion = ses.leer_sesion(token)
|
| 18 |
+
assert sesion["email"] == "vet@example.com"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_toda_sesion_nace_identificada_y_fechada():
|
| 22 |
+
"""`jti` y `emitida_en` los pone `firmar_sesion`, no quien llama.
|
| 23 |
+
|
| 24 |
+
Son lo que hace revocable una sesión: sin `jti` no hay nada que nombrar para cerrar UNA, y
|
| 25 |
+
sin `emitida_en` no se pueden cortar TODAS las de una cuenta por fecha. Si se generaran en
|
| 26 |
+
quien llama, una ruta podría emitir por descuido una sesión irrevocable.
|
| 27 |
+
"""
|
| 28 |
+
a = ses.leer_sesion(ses.firmar_sesion({"email": "vet@example.com"}))
|
| 29 |
+
b = ses.leer_sesion(ses.firmar_sesion({"email": "vet@example.com"}))
|
| 30 |
+
|
| 31 |
+
assert a["jti"] and b["jti"]
|
| 32 |
+
assert a["jti"] != b["jti"], "dos sesiones con el mismo jti se revocarían juntas"
|
| 33 |
+
assert a["emitida_en"]
|
| 34 |
|
| 35 |
|
| 36 |
@pytest.mark.parametrize(
|