🛠 Unidad ISS-18 · Refresh con rotación — capa 🛠 CONSTRUIR
Capa Página Para qué 🧠 Aprender — no está en la fuente comprender, explicar y relacionar 🛠 Construir esta página ejecutar, programar y verificar ✅ GATE Condiciones de cierre condición para pasar al bloque siguiente Esta es la guía ejecutable. El cuerpo de abajo es el ISS técnico verbatim: comandos, rutas, versiones, verificaciones y criterios, sin simplificar.
Guía de estudio. Esta unidad no tiene cuaderno en
material/django/iss/ISS-18/aprendizaje/. La página publica solo el ISS técnico.
ISS-18 — Refresh con rotación
Objetivo
Cambiar el refresh en cada uso y tratar la reutilización como revocación de la familia.
Requisitos
- ISS-17 superado.
Construcción
RefreshAPIView también es OPEN y APIView. Busca la fila por hash dentro de select_for_update().
ARCHIVO: apps/security/urls.py
UBICAR:
from apps.security.auth_views import LoginAPIView
REEMPLAZAR POR:
from apps.security.auth_views import LoginAPIView, RefreshAPIView
DEBAJO DE:
path("auth/login/", LoginAPIView.as_view(), name="auth-login"),
AGREGAR:
path("auth/refresh/", RefreshAPIView.as_view(), name="auth-refresh"),
config/urls.py no se toca. El include del ISS-17 ya entrega api/ a esta lista.
La vista también se agrega al archivo que ya existe, encima de _find_user. Hace falta importar transaction y RefreshSerializer.
ARCHIVO: apps/security/auth_views.py
UBICAR:
from django.utils import timezone
REEMPLAZAR POR:
from django.db import transaction
from django.utils import timezone
UBICAR:
from apps.security.serializers import LoginSerializer, TokenPairSerializer
REEMPLAZAR POR:
from apps.security.serializers import LoginSerializer, RefreshSerializer, TokenPairSerializer
ENCIMA DE:
def _find_user(username, email):
AGREGAR la clase RefreshAPIView: OPEN, APIView, busca por hash con select_for_update,
revoca la familia si el token ya fue rotado, y si está vigente emite otro par
de la misma familia marcando el anterior con replaced_by.
no existe → 401
ya rotado, revocado o inactivo → revoca la familia → 401
vencido → inactiva ese token → 401
usuario inactive → inactiva ese token → 401
vigente → emite otro par de la misma familia
marca el anterior replaced_by
La prueba sigue en OPEN y entra en la clase AuthFlowTests del ISS-17.
ARCHIVO: apps/security/tests.py
AL FINAL DE class AuthFlowTests, AGREGAR:
def test_refresh_rotates_and_reuse_revokes_family(self):
login = self.client.post(
"/api/auth/login/",
{"username": "ana", "password": PASSWORD},
format="json",
)
first = login.data["refresh"]
rotated = self.client.post("/api/auth/refresh/", {"refresh": first}, format="json")
self.assertEqual(rotated.status_code, status.HTTP_200_OK)
second = rotated.data["refresh"]
self.assertNotEqual(first, second)
reused = self.client.post("/api/auth/refresh/", {"refresh": first}, format="json")
self.assertEqual(reused.status_code, status.HTTP_401_UNAUTHORIZED)
after_reuse = self.client.post("/api/auth/refresh/", {"refresh": second}, format="json")
self.assertEqual(after_reuse.status_code, status.HTTP_401_UNAUTHORIZED)
python manage.py test apps.security.tests.AuthFlowTests.test_refresh_rotates_and_reuse_revokes_family
Explicación
Rotar sin detectar reuso deja vivo el token robado. Si el token viejo vuelve a presentarse, se asume que alguien más lo tiene y se apagan todos los de esa family_id, incluido el que acaba de emitirse al cliente legítimo. Ese cliente tendrá que volver a login. Es la política elegida; no es la blacklist de SimpleJWT.
El vencimiento no revoca la familia: un token expirado no prueba que se haya copiado.
Cómo probarlo
Capa HTTP, acceso OPEN. El test hace login para obtener un refresh y no guarda el access. Tres POST siguen a la misma ruta.
python manage.py test apps.security.tests.AuthFlowTests.test_refresh_rotates_and_reuse_revokes_family
│
▼
login → refresh A
│
▼
POST /api/auth/refresh/ {refresh: A} 200 devuelve B, A queda replaced_by
│
▼
POST /api/auth/refresh/ {refresh: A} 401 reuso: se apaga la familia
│
▼
POST /api/auth/refresh/ {refresh: B} 401 B también murió con la familia
En Swagger, en esta misma capa y sin Authorize: haga login, copie refresh y llame tres veces a POST /api/auth/refresh/ con A, otra vez con A y luego con B. El resultado visual es 200, 401 y 401.
swagger-ui OPEN
login → refresh A
POST /api/auth/refresh/ A 200 → B
POST /api/auth/refresh/ A 401
POST /api/auth/refresh/ B 401
Criterios de aceptación
- AC-18-01: el segundo refresh es distinto del primero y el primero responde 200 la primera vez.
- AC-18-02: reusar el primero responde 401.
- AC-18-03: el segundo, ya comprometida la familia, también responde 401.
Verificación
test_refresh_rotates_and_reuse_revokes_family.
Evidencias
- EVI-18-01: test PASS.
GATE
| AC | Verificación | Evidencia | Resultado |
|---|---|---|---|
| AC-18-01 | rotación | EVI-18-01 | PASS |
| AC-18-02 | reuso | EVI-18-01 | PASS |
| AC-18-03 | familia revocada | EVI-18-01 | PASS |
✅ GATE de la unidad ISS-18 — este bloque no añade ningún criterio nuevo.
Las condiciones de cierre son las de esta misma página:
- 📋 Criterios de aceptación → Criterios de aceptación
- ✅ GATE → GATE
- 🔎 Verificación → Verificación
Con el GATE en verde queda habilitado el bloque siguiente de la ruta.
Navegación de la ruta: ← ISS-17 · 🛠 Construir · ↑ Ruta Django · → ISS-19 · 🛠 Construir