Spaces:
Sleeping
Sleeping
File size: 10,548 Bytes
70e641d 1339cdc 70e641d b90d0d2 70e641d 2f5c422 70e641d 1339cdc 70e641d b90d0d2 1339cdc b90d0d2 70e641d b90d0d2 1339cdc 2f5c422 b90d0d2 70e641d b90d0d2 70e641d 1339cdc 70e641d 1339cdc 70e641d 1339cdc 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 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | """Capa de datos de usuarios.
Diferencias de seguridad frente a la versión PHP:
- La BD SQLite vive en instance/ FUERA del directorio servido (no es descargable).
- Esquema versionado con `PRAGMA user_version` (ver `_MIGRACIONES`).
- Hash de contraseña con scrypt (stdlib), sal aleatoria por usuario.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import secrets
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import UTC, datetime
from .config import TENANT_POR_DEFECTO, VOLUMEN_PERSISTENTE, obtener_config
log = logging.getLogger("morphos.db")
# Migraciones versionadas con `PRAGMA user_version`. Antes esto era un único script de
# `CREATE TABLE IF NOT EXISTS`: creaba el esquema en una BD vacía y no hacía NADA sobre una
# existente, así que añadir una columna era una operación manual sobre un fichero al que, en
# Spaces, nadie puede llegar. Cada entrada de la lista es un paso; el índice+1 es la versión
# resultante, y sólo se aplican los pasos por encima de la versión actual.
#
# Reglas: nunca se edita un paso ya publicado (una BD que lo aplicó no volvería a ejecutarlo) y
# los pasos se añaden al final. La versión garantiza que cada paso corre UNA vez, así que no
# tienen por qué ser idempotentes —el 3 es un ALTER TABLE, que no lo es—. Los `IF NOT EXISTS`
# del paso 1 son por otro motivo: las BD creadas antes de este mecanismo están en la versión 0
# con esas tablas ya presentes, y hay que poder ponerlas al día sin borrarlas.
_MIGRACIONES: list[str] = [
# 1 — esquema inicial (el que ya existía).
"""
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
apellido TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
creado_en DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS intentos_login (
email TEXT NOT NULL,
ip TEXT NOT NULL,
momento DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Persistencia OPCIONAL de resultados de analizador (sólo con lab_persistir=true; útil sólo
-- con volumen persistente). Clave = muestra_id normalizada; último gana (INSERT OR REPLACE).
CREATE TABLE IF NOT EXISTS resultados_lab (
muestra_id TEXT PRIMARY KEY,
momento DATETIME,
recibido_en DATETIME DEFAULT CURRENT_TIMESTAMP,
payload_json TEXT NOT NULL
);
""",
# 2 — índice para el throttle de login: `intentos_recientes` filtra por email+ip+momento en
# cada intento y hacía scan completo de la tabla.
"""
CREATE INDEX IF NOT EXISTS idx_intentos_email_ip_momento
ON intentos_login (email, ip, momento);
""",
# 3 — clínica dueña de cada cuenta. Los usuarios que ya existan quedan en el tenant por
# defecto, que es donde también caen los dispositivos sin clínica declarada: un despliegue
# de una sola clínica no nota el cambio.
f"""
ALTER TABLE usuarios ADD COLUMN tenant TEXT NOT NULL DEFAULT '{TENANT_POR_DEFECTO}';
""",
# 4 — revocación de sesiones. Las cookies firmadas son válidas hasta que caducan mirándolas
# sólo a ellas, así que no había forma de invalidar una copiada ni de echar a nadie tras un
# incidente. Dos mecanismos, porque resuelven cosas distintas:
# - `sesiones_revocadas`: una sesión concreta (logout). Se guarda hasta su caducidad; a
# partir de ahí la firma ya no vale por sí sola y la fila sobra.
# - `usuarios.sesiones_validas_desde`: TODAS las de una cuenta a la vez (cambio de
# contraseña, robo). Un sello temporal en vez de un contador de versión porque la
# pregunta que hay que responder es «¿se emitió antes del corte?».
"""
CREATE TABLE IF NOT EXISTS sesiones_revocadas (
jti TEXT PRIMARY KEY,
expira_en DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sesiones_revocadas_expira ON sesiones_revocadas (expira_en);
ALTER TABLE usuarios ADD COLUMN sesiones_validas_desde DATETIME;
""",
]
def _migrar(con: sqlite3.Connection) -> int:
"""Aplica los pasos pendientes y devuelve la versión resultante."""
version = con.execute("PRAGMA user_version").fetchone()[0]
for indice in range(version, len(_MIGRACIONES)):
con.executescript(_MIGRACIONES[indice])
# `PRAGMA` no admite parámetros; el valor es un índice entero nuestro, no entrada.
con.execute(f"PRAGMA user_version = {indice + 1}")
return len(_MIGRACIONES)
def inicializar_db() -> None:
cfg = obtener_config()
cfg.db_path.parent.mkdir(parents=True, exist_ok=True)
with _conexion() as con:
version = _migrar(con)
persistente = cfg.db_path.is_relative_to(VOLUMEN_PERSISTENTE)
log.info("BD en %s (esquema v%d, %s).", cfg.db_path, version,
"persistente" if persistente else "EFÍMERA: las cuentas no sobreviven al reinicio")
if not persistente:
log.warning(
"La base de usuarios está en almacenamiento EFÍMERO (%s): cada reinicio borra "
"cuentas, contraseñas e historial de intentos. Monta un volumen persistente o "
"apunta MORPHOS_DB_PATH a uno.", cfg.db_path,
)
@contextmanager
def _conexion() -> Iterator[sqlite3.Connection]:
cfg = obtener_config()
con = sqlite3.connect(cfg.db_path)
con.row_factory = sqlite3.Row
try:
yield con
con.commit()
finally:
con.close()
# --- Hash de contraseñas (scrypt, stdlib) ---
def hash_password(password: str) -> str:
sal = secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode(), salt=sal, n=2**14, r=8, p=1, dklen=32)
return f"scrypt${sal.hex()}${dk.hex()}"
def verificar_password(password: str, almacenado: str) -> bool:
try:
algo, sal_hex, hash_hex = almacenado.split("$")
if algo != "scrypt":
return False
sal = bytes.fromhex(sal_hex)
dk = hashlib.scrypt(password.encode(), salt=sal, n=2**14, r=8, p=1, dklen=32)
return hmac.compare_digest(dk.hex(), hash_hex)
except (ValueError, AttributeError):
return False
# --- Operaciones de usuario ---
def buscar_usuario(email: str) -> sqlite3.Row | None:
with _conexion() as con:
cur = con.execute(
"SELECT id, nombre, apellido, email, password, tenant "
"FROM usuarios WHERE email = ? LIMIT 1",
(email,),
)
return cur.fetchone()
def crear_usuario(nombre: str, apellido: str, email: str, password: str, tenant: str) -> None:
with _conexion() as con:
con.execute(
"INSERT INTO usuarios (nombre, apellido, email, password, tenant) "
"VALUES (?, ?, ?, ?, ?)",
(nombre, apellido, email, hash_password(password), tenant),
)
# --- Revocación de sesiones ---
def _ahora_iso() -> str:
"""Instante actual en ISO-8601 UTC con microsegundos.
Se genera en Python y NO con `datetime('now')` de SQLite por dos motivos: SQLite tiene
resolución de SEGUNDO —una sesión emitida en el mismo segundo que un corte de revocación
sobrevivía— y usa un espacio en vez de 'T', así que comparar sus cadenas con las ISO de las
sesiones daba órdenes incorrectos.
"""
return datetime.now(UTC).isoformat()
def revocar_sesion(jti: str, expira_en: str) -> None:
"""Invalida UNA sesión (logout) hasta que su firma caduque por sí sola."""
with _conexion() as con:
con.execute(
"INSERT OR REPLACE INTO sesiones_revocadas (jti, expira_en) VALUES (?, ?)",
(jti, expira_en),
)
# Poda oportunista: pasada su caducidad la firma ya no vale, así que la fila no aporta.
# El corte va como parámetro, en el MISMO formato que lo guardado.
con.execute("DELETE FROM sesiones_revocadas WHERE expira_en < ?", (_ahora_iso(),))
def sesion_revocada(jti: str) -> bool:
with _conexion() as con:
cur = con.execute("SELECT 1 FROM sesiones_revocadas WHERE jti = ? LIMIT 1", (jti,))
return cur.fetchone() is not None
def revocar_todas_las_sesiones(email: str) -> None:
"""Corta TODAS las sesiones de una cuenta: las emitidas antes de ahora dejan de valer."""
with _conexion() as con:
con.execute(
"UPDATE usuarios SET sesiones_validas_desde = ? WHERE email = ?",
(_ahora_iso(), email),
)
def sesiones_validas_desde(email: str) -> str | None:
with _conexion() as con:
cur = con.execute(
"SELECT sesiones_validas_desde FROM usuarios WHERE email = ? LIMIT 1", (email,)
)
fila = cur.fetchone()
return fila["sesiones_validas_desde"] if fila else None
# --- Registro de intentos de login (para throttling) ---
def registrar_intento(email: str, ip: str) -> None:
with _conexion() as con:
con.execute("INSERT INTO intentos_login (email, ip) VALUES (?, ?)", (email, ip))
# Poda oportunista: `limpiar_intentos` sólo corre tras un login correcto, así que los
# intentos fallidos contra emails que nunca aciertan crecerían sin límite. Una hora cubre
# de sobra cualquier ventana de throttling configurada.
con.execute("DELETE FROM intentos_login WHERE momento < datetime('now', '-1 hour')")
def intentos_recientes(email: str, ip: str, ventana_s: int) -> int:
with _conexion() as con:
cur = con.execute(
"SELECT COUNT(*) AS n FROM intentos_login "
"WHERE (email = ? OR ip = ?) AND momento > datetime('now', ?)",
(email, ip, f"-{ventana_s} seconds"),
)
return int(cur.fetchone()["n"])
def limpiar_intentos(email: str) -> None:
with _conexion() as con:
con.execute("DELETE FROM intentos_login WHERE email = ?", (email,))
# --- Persistencia opcional de resultados de laboratorio ---
def guardar_resultado_lab(muestra_id: str, momento: str, payload_json: str) -> None:
with _conexion() as con:
con.execute(
"INSERT OR REPLACE INTO resultados_lab (muestra_id, momento, payload_json) VALUES (?, ?, ?)",
(muestra_id, momento, payload_json),
)
def cargar_resultados_lab(limite: int = 500) -> list[str]:
"""Devuelve los payloads JSON más recientes, para recargar el almacén en proceso al arrancar."""
with _conexion() as con:
cur = con.execute(
"SELECT payload_json FROM resultados_lab ORDER BY recibido_en DESC LIMIT ?",
(limite,),
)
return [row["payload_json"] for row in cur.fetchall()]
|