File size: 2,518 Bytes
70e641d
 
 
 
 
 
 
 
 
 
2f5c422
70e641d
5af9605
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f5c422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
5af9605
 
 
 
 
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
"""Sesiones firmadas por cookie (itsdangerous) con flags seguros y protección CSRF.

Reemplaza las sesiones PHP sin flags. La cookie es HttpOnly + SameSite=Strict + Secure
(en prod). El token CSRF se entrega en una cookie legible por JS y debe reenviarse en la
cabecera X-CSRF-Token en peticiones mutantes.
"""

from __future__ import annotations

import secrets
from datetime import UTC, datetime, timedelta

from itsdangerous import BadData, URLSafeTimedSerializer

from ..config import obtener_config

COOKIE_SESION = "morphos_sesion"
COOKIE_CSRF = "morphos_csrf"
CABECERA_CSRF = "x-csrf-token"


def _serializer() -> URLSafeTimedSerializer:
    cfg = obtener_config()
    secreto = cfg.session_secret or "dev-inseguro-cambiar"  # sólo válido en entorno dev
    return URLSafeTimedSerializer(secreto, salt="morphos.sesion")


def firmar_sesion(datos: dict) -> str:
    """Firma la sesión añadiéndole identidad (`jti`) e instante de emisión (`emitida_en`).

    Sin `jti` no se puede revocar UNA sesión —no hay nada que nombrar—, y sin `emitida_en` no se
    puede cortar un conjunto de ellas por fecha. Se generan aquí, y no en quien llama, para que
    ninguna ruta pueda emitir por descuido una sesión irrevocable.
    """
    completos = {
        "jti": secrets.token_urlsafe(16),
        "emitida_en": datetime.now(UTC).isoformat(),
        **datos,
    }
    return _serializer().dumps(completos)


def caducidad_de(sesion: dict) -> str:
    """Cuándo deja de valer la firma por sí sola: hasta ahí hay que recordar una revocación."""
    cfg = obtener_config()
    emitida = sesion.get("emitida_en")
    try:
        base = datetime.fromisoformat(emitida) if emitida else datetime.now(UTC)
    except ValueError:
        base = datetime.now(UTC)
    return (base + timedelta(seconds=cfg.session_max_age_s)).isoformat()


def leer_sesion(token: str | None) -> dict | None:
    if not token:
        return None
    cfg = obtener_config()
    try:
        return _serializer().loads(token, max_age=cfg.session_max_age_s)
    except BadData:
        # BadData es el padre de BadSignature (firma inválida o falsificada), de
        # SignatureExpired (sesión caducada) y de BadPayload (token corrupto): las tres son
        # "cookie no válida" → sin sesión. NO se captura Exception: un TypeError o un fallo de
        # configuración deben propagarse, no disfrazarse de 401 y ocultar el bug real.
        return None


def nuevo_token_csrf() -> str:
    return secrets.token_urlsafe(32)