🛠 Unidad ISS-15 · RefreshToken — 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-15/aprendizaje/. La página publica solo el ISS técnico.
ISS-15 — RefreshToken
Objetivo
Persistir la sesión de refresh sin guardar el token en claro y sin usar la blacklist de SimpleJWT como almacén.
Requisitos
- Cierre de negocio, al final del ISS-14, superado en el motor que se esté usando.
Userya existe desde el ISS-03.
Construcción
apps/security/tokens.py es nuevo:
cat > apps/security/tokens.py <<'EOF'
import hashlib
import secrets
def generate_refresh_value():
return secrets.token_urlsafe(48)
def hash_token(raw_token):
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
EOF
apps/security/models.py ya tiene User. La clase RefreshToken se pega al final. No se reescribe el usuario.
class RefreshToken(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.RESTRICT,
related_name="refresh_tokens",
db_column="user_id",
)
token_hash = models.CharField(max_length=64, unique=True)
family_id = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True)
expires_at = models.DateTimeField()
revoked_at = models.DateTimeField(null=True, blank=True)
replaced_by = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="replacements",
)
status = models.CharField(
max_length=8,
choices=RecordStatus.choices,
default=RecordStatus.ACTIVE,
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "refresh_tokens"
ordering = ["-created_at"]
constraints = [
models.CheckConstraint(
condition=models.Q(status__in=RecordStatus.values),
name="refresh_tokens_status_valid",
)
]
def __str__(self):
return f"{self.user} {self.family_id}"
@property
def is_usable(self):
return (
self.status == RecordStatus.ACTIVE
and self.revoked_at is None
and self.replaced_by_id is None
and self.expires_at > timezone.now()
)
@classmethod
def lifetime(cls):
return settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"]
@classmethod
def default_expiry(cls):
return timezone.now() + cls.lifetime()
ARCHIVO: apps/security/models.py
ENCIMA DE:
from django.contrib.auth.models import AbstractUser, UserManager
AGREGAR:
import uuid
from django.conf import settings
from django.utils import timezone
AL FINAL DEL ARCHIVO, DESPUÉS DE LA CLASE User, AGREGAR la clase RefreshToken.
SimpleJWT entra aquí, antes del login. Solo emite y verifica el access token. No se instala rest_framework_simplejwt.token_blacklist: el refresh de StoreLab vive en la tabla refresh_tokens, no en esa app.
source .venv/bin/activate
python -m pip install "djangorestframework-simplejwt==5.5.1"
python -m pip freeze > requirements.txt
El paquete pip es djangorestframework-simplejwt. En INSTALLED_APPS el nombre es rest_framework_simplejwt. PyJWT llega como dependencia; no se instala aparte. Settings ya existe.
ARCHIVO: config/settings/__init__.py
ENCIMA DE:
import os
AGREGAR:
from datetime import timedelta
DEBAJO DE:
"rest_framework",
AGREGAR:
"rest_framework_simplejwt",
AL FINAL DEL ARCHIVO, AGREGAR:
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
"UPDATE_LAST_LOGIN": True,
"ALGORITHM": "HS256",
"SIGNING_KEY": SECRET_KEY,
"AUTH_HEADER_TYPES": ("Bearer",),
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
}
No instale token_blacklist. La rotación es la de StoreLab.
Se agrega RefreshToken en apps/security/models.py y se migra de nuevo:
Campos: user, token_hash (64, único), family_id (UUID), expires_at, revoked_at, replaced_by, status, created_at, updated_at. Tabla refresh_tokens.
apps/security/tokens.py genera el valor con secrets.token_urlsafe y guarda sha256 en hexadecimal.
El default de status aquí es active. Es la excepción del diccionario: el token acaba de nacer de un login válido. El resto de entidades sigue naciendo inactive.
No se instala rest_framework_simplejwt.token_blacklist. ROTATE_REFRESH_TOKENS y BLACKLIST_AFTER_ROTATION quedan en False porque la rotación es nuestra.
Explicación
El access token es un JWT. Se verifica con la firma y la expiración; no hace falta una fila. El refresh tiene que poder revocarse, así que necesita estado en la base. Guardar el refresh en claro permitiría usar la tabla como si fuera la sesión. El hash no se puede invertir: hace falta el valor que solo vio el cliente.
Criterios de aceptación
- AC-15-01: después del login hay una fila y su
token_hashno es el refresh devuelto. - AC-15-02: el hash coincide con SHA-256 del valor devuelto.
- AC-15-03: la respuesta no contiene
password.
Verificación
AuthFlowTests.test_login_with_username_and_email.
Evidencias
- EVI-15-01: test PASS.
GATE
| AC | Verificación | Evidencia | Resultado |
|---|---|---|---|
| AC-15-01 | fila distinta del claro | EVI-15-01 | PASS |
| AC-15-02 | SHA-256 | EVI-15-01 | PASS |
| AC-15-03 | sin password en JSON | EVI-15-01 | PASS |
✅ GATE de la unidad ISS-15 — 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-14 · 🛠 Construir · ↑ Ruta Django · → ISS-16 · 🛠 Construir