Jose Salazar Claude Opus 5 commited on
Commit
2f5c422
·
1 Parent(s): 1339cdc

Sesiones revocables: logout real y cierre en todos los dispositivos

Browse files

ARCHITECTURE_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 CHANGED
@@ -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:
backend/app/routers/auth.py CHANGED
@@ -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, _sesion: dict = Depends(usuario_actual)) -> dict:
131
- response.delete_cookie(COOKIE_SESION)
132
- response.delete_cookie(COOKIE_CSRF)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}
backend/app/security/authz.py CHANGED
@@ -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
 
backend/app/security/session.py CHANGED
@@ -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
- return _serializer().dumps(datos)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
backend/tests/test_revocacion_sesiones.py ADDED
@@ -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()
backend/tests/test_session.py CHANGED
@@ -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
- assert ses.leer_sesion(token) == {"email": "vet@example.com"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(