Saltar al contenido
MaytokVerso
Volver

Sesiones JWT enterprise en Django y Vue

Introducción

Las sesiones JWT tienen una tensión de base: el access token debe durar poco para limitar el daño si se filtra, pero si dura poco, el usuario vive desconectándose. La solución estándar es separar los tokens: uno de corta vida que viaja en memoria y otro de larga vida (refresh) que renueva al primero.

En este artículo te guío por una implementación completa de ese flujo en una app con backend Django y frontend Vue 3, con lo que considero el nivel “enterprise”: rotación del refresh token, detección de reuso con revocación de familia, tiempo máximo de inactividad, tiempo absoluto de sesión y renovación proactiva desde el frontend que respeta la inactividad.

Arquitectura del flujo

SPA (Vue)                    API (Django)
   │                              │
   │ 1. login (email + password)  │
   ├─────────────────────────────►│  crea UserSession (familia)
   │ ◄────────────────────────────┤  access (memoria) + refresh (cookie HttpOnly)
   │                              │
   │ 2. peticiones con Bearer     │
   ├─────────────────────────────►│
   │                              │
   │ 3. renovación proactiva      │
   ├─────────────────────────────►│  valida sesión, rota refresh,
   │ ◄────────────────────────────┤  blacklist del anterior
   │                              │
   │ 4. reuso detectado           │
   ├─────────────────────────────►│  revoca toda la familia

Las reglas que seguimos:

Backend: el modelo de sesión

Una sesión representa una familia de refresh tokens. Guardamos el jti vigente (el identificador único del token), el session_id que viajará dentro del token y las fechas para aplicar las políticas:

class UserSession(BaseModel):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="sessions")
    family_id = models.CharField(max_length=64, db_index=True)
    session_id = models.UUIDField(unique=True, editable=False)
    current_jti = models.CharField(max_length=64, blank=True, default="")
    previous_jti = models.CharField(max_length=64, blank=True, default="")
    device = models.CharField(max_length=255, blank=True)
    ip_address = models.GenericIPAddressField(null=True, blank=True)
    last_activity_at = models.DateTimeField(null=True, blank=True)
    expires_at = models.DateTimeField(null=True, blank=True)
    revoked_at = models.DateTimeField(null=True, blank=True, db_index=True)

Backend: emisión de tokens (login)

Al iniciar sesión creamos la sesión y escribimos fam y sid como claims del refresh token. De esa forma, cualquier token de la familia sabe a qué sesión pertenece:

class AguajeTokenObtainPairSerializer(TokenObtainPairSerializer):
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        session = UserSession.objects.create(
            user=user,
            family_id=str(uuid.uuid4()),
            session_id=uuid.uuid4(),
            current_jti=token.payload["jti"],
        )
        token["fam"] = session.family_id
        token["sid"] = str(session.session_id)
        return token

La cookie de refresh se fija en la respuesta con HttpOnly, Secure y SameSite configurable (Lax para mismo origen, None para orígenes separados). Si el frontend y la API viven en orígenes distintos y REFRESH_COOKIE_SAMESITE no se define, el sistema la selecciona automáticamente: un despliegue cross-origin con SameSite=Lax rompe la restauración de sesión al recargar la página, porque el navegador no envía cookies Lax en peticiones cross-site.

Backend: renovación con reuso y timeouts

El corazón de la implementación es el serializer de refresh. Antes de rotar el token, verificamos cuatro cosas:

  1. Que la sesión exista y no esté revocada.
  2. Que el jti presentado sea el vigente. Si no lo es, verificamos si es el token inmediatamente anterior dentro de una ventana de gracia corta (carrera entre pestañas); si no, es reuso: revocamos toda la familia.
  3. Que no haya pasado el tiempo absoluto.
  4. Que no haya pasado el tiempo de inactividad.
class AguajeTokenRefreshSerializer(TokenRefreshSerializer):
    def validate(self, attrs):
        # verify=False: leemos claims para detectar reuso antes de que el
        # blacklist del token intercepte; la verificación real (firma,
        # expiración y blacklist) ocurre en super().validate().
        refresh = RefreshToken(attrs["refresh"], verify=False)
        session = UserSession.objects.filter(
            session_id=refresh.payload.get("sid")
        ).first()

        if session is None or session.revoked_at is not None:
            raise TokenError("La sesión no es válida.")

        if session.current_jti != refresh.payload.get("jti"):
            is_concurrent_tab = (
                session.previous_jti
                and refresh.payload.get("jti") == session.previous_jti
                and session.last_activity_at is not None
                and timezone.now() - session.last_activity_at <= RACE_GRACE
            )
            if not is_concurrent_tab:
                session.revoked_at = timezone.now()
                session.save(update_fields=["revoked_at", "updated_at"])
                raise TokenError("Reuso de refresh token detectado.")

            # Carrera entre pestañas: se rota de nuevo sin revocar la familia.
            refresh.set_jti()
            refresh.set_exp()
            refresh.set_iat()
            refresh.outstand()
            data = {
                "access": str(refresh.access_token),
                "refresh": str(refresh),
            }
            session.previous_jti = session.current_jti
            session.current_jti = refresh.payload["jti"]
            session.last_activity_at = timezone.now()
            session.save(
                update_fields=[
                    "previous_jti",
                    "current_jti",
                    "last_activity_at",
                    "updated_at",
                ]
            )
            return data

        now = timezone.now()
        if session.expires_at is not None and session.expires_at < now:
            session.revoked_at = now
            session.save(update_fields=["revoked_at", "updated_at"])
            raise TokenError("La sesión expiró.")

        if (
            session.last_activity_at is not None
            and session.last_activity_at + settings.SESSION_IDLE_TIMEOUT < now
        ):
            raise TokenError("La sesión expiró por inactividad.")

        data = super().validate(attrs)
        session.previous_jti = session.current_jti
        session.current_jti = RefreshToken(data["refresh"]).payload["jti"]
        session.last_activity_at = timezone.now()
        session.save(
            update_fields=[
                "previous_jti",
                "current_jti",
                "last_activity_at",
                "updated_at",
            ]
        )
        return data

Un detalle que cuesta encontrar: RefreshToken() con verify=True lanza “Token is blacklisted” al instanciar un token ya revocado, lo que impediría llegar a nuestra lógica de reuso. Por eso leemos los claims con verify=False y dejamos que super().validate() haga la verificación criptográfica real (firma, expiración y blacklist).

Múltiples pestañas: la carrera de refresco

El access token vive en memoria y es por pestaña: una pestaña nueva arranca sin token y restaura la sesión con el refresh de la cookie (compartida). Eso funciona, salvo cuando la pestaña nueva renueva en el mismo instante que la original (su timer o un 401): ambas envían el mismo refresh token. Una rota y la otra presenta el token ya rotado. Sin matices, la detección de reuso lo trataría como robo y revocaría toda la familia, mandando al login las dos pestañas.

La solución tiene dos capas. En el backend, una ventana de gracia para el token inmediatamente anterior:

RACE_GRACE = timedelta(seconds=60)

Si llega el token anterior dentro de la gracia, se rota de nuevo sin revocar la familia (el fragmento de arriba). Cualquier token más antiguo, o el anterior fuera de la ventana, sigue revocando la familia.

En el frontend, Web Locks serializa el refresh entre pestañas del mismo origen, para que la carrera ni siquiera llegue al backend:

async function refreshAccessToken(): Promise<string | null> {
  if (!refreshPromise) {
    const run = () => requestRefreshAccessToken()
    const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined
    const attempt = locks ? locks.request('aguaje:refresh', run) : run()
    refreshPromise = attempt
    void attempt.finally(() => {
      refreshPromise = null
    })
  }
  return refreshPromise
}

Solo una pestaña renueva a la vez; la segunda espera el lock y luego usa la cookie ya rotada.

Las políticas son configurables por entorno:

SESSION_IDLE_TIMEOUT = timedelta(
    minutes=config("SESSION_IDLE_MINUTES", default=30, cast=int)
)
SESSION_ABSOLUTE_TIMEOUT = timedelta(
    hours=config("SESSION_ABSOLUTE_HOURS", default=24, cast=int)
)

Backend: logout y limpieza

Al cerrar sesión revocamos la familia completa (no solo el token) y blacklisteamos el refresh presentado:

token = RefreshToken(refresh)
sid = token.payload.get("sid")
if sid:
    UserSession.objects.filter(session_id=sid).update(
        revoked_at=timezone.now(),
        updated_at=timezone.now(),
    )
token.blacklist()

Una tarea periódica (Celery beat) elimina los tokens expirados y las sesiones revocadas o vencidas, para que la tabla no crezca sin límite.

Frontend: renovación proactiva que respeta la inactividad

El frontend no espera a recibir un 401: renueva el access token antes de que expire. Pero si renovara con un timer fijo, la sesión sería infinita aunque el usuario no toque la computadora. La clave es renovar solo si hay actividad real.

Registramos actividad con eventos del navegador y un touch() que además renueva si el token está por expirar:

function touch() {
  lastActivityAt = Date.now()
  if (isAuthenticated.value && isExpiringSoon()) {
    void runRenewal()
  }
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') touch()
})
for (const event of ['pointerdown', 'keydown', 'touchstart']) {
  document.addEventListener(event, touch, { passive: true })
}

La renovación agenda el siguiente refresco leyendo el exp del JWT (60 segundos antes de expirar), y se detiene si el usuario está inactivo:

async function runRenewal() {
  if (Date.now() - lastActivityAt >= SESSION_IDLE_MS) {
    stopKeepAlive()
    return
  }
  const ok = await refreshSession()
  if (ok) {
    lastActivityAt = Date.now()
    startKeepAlive()
  }
}

function startKeepAlive() {
  stopKeepAlive()
  refreshTimer = setTimeout(() => void runRenewal(), nextRefreshDelay())
}

Si la renovación falla (sesión revocada, inactividad superada o tiempo absoluto cumplido), un handler global limpia la sesión y redirige a login:

export function setSessionExpiredHandler(handler: (() => void) | null) {
  sessionExpiredHandler = handler
}

// en refreshAccessToken, al fallar:
setAccessToken(null)
sessionExpiredHandler?.()

Variables de entorno

VariableDefaultDescripción
SESSION_IDLE_MINUTES30Inactividad máxima antes de pedir un nuevo login.
SESSION_ABSOLUTE_HOURS24Vida máxima de la sesión desde el login.
VITE_SESSION_IDLE_MINUTES30Misma política en el frontend; debe coincidir con el backend.
REFRESH_COOKIE_SAMESITELax (auto None con CORS cross-origin)None + Secure si frontend y API están en orígenes distintos.

Pruebas

El flujo se prueba con el test client de Django. El caso más interesante es el de reuso: tras rotar, reutilizar el token viejo debe revocar la familia, incluido el token rotado:

def test_refresh_reuse_revokes_whole_family(self):
    login = self.login()
    rotated = self.client.post(
        reverse("auth_refresh"), {"refresh": login.data["refresh"]}, format="json"
    )
    assert rotated.status_code == 200

    # Reuso tardío (fuera de la ventana de gracia de 60 s)
    session = UserSession.objects.get(user=self.user)
    session.last_activity_at = timezone.now() - timedelta(minutes=5)
    session.save(update_fields=["last_activity_at", "updated_at"])

    fresh_client = APIClient()  # sin la cookie rotada
    replay = fresh_client.post(
        reverse("auth_refresh"), {"refresh": login.data["refresh"]}, format="json"
    )
    assert replay.status_code == 401

    invalidated = fresh_client.post(
        reverse("auth_refresh"),
        {"refresh": rotated.data["refresh"]},
        format="json",
    )
    assert invalidated.status_code == 401


def test_previous_refresh_within_grace_is_a_concurrent_tab(self):
    login = self.login()
    rotated = self.client.post(
        reverse("auth_refresh"), {"refresh": login.data["refresh"]}, format="json"
    )
    assert rotated.status_code == 200

    fresh_client = APIClient()  # segunda pestaña, sin cookie rotada
    raced = fresh_client.post(
        reverse("auth_refresh"), {"refresh": login.data["refresh"]}, format="json"
    )
    assert raced.status_code == 200
    assert UserSession.objects.get(user=self.user).revoked_at is None

También conviene probar el rechazo por inactividad y por tiempo absoluto manipulando last_activity_at y expires_at, y verificar que el logout revoque la sesión.

Consideraciones de seguridad

Conclusión

Con esta implementación la sesión se mantiene abierta mientras el usuario trabaja, se cierra sola tras el tiempo de inactividad configurado y se protege contra la reutilización de tokens robados. No es magia: es la combinación de rotación, trazabilidad de la familia de tokens y una política de expiración explícita, aplicada en los dos lados de la aplicación.


Comparte este post:

Post siguiente
Compilar QZTray de forma correcta en Windows