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()]