====== 06 - Login con JWT ======
El login con **JWT** mantiene la idea del par de tokens: un token de acceso de vida corta y un refresh token de vida larga. La diferencia está en el token de acceso: es autocontenido y verificable sin conservar estado en el servidor.
Se asume conocido el flujo con tokens opacos: par acceso/refresh, rotación, ''Principal'' y filtro Servlet que deja la identidad en el contexto de la petición.
===== 1. Token opaco frente a JWT =====
Un **token opaco** es una cadena aleatoria sin significado para el cliente; el servidor lo valida consultando su estado. Un **JWT** es un token con contenido firmado: el servidor lo valida comprobando la firma y la caducidad, sin consultar la base de datos.
^ Aspecto ^ **Token opaco** ^ **JWT de acceso** ^
| Contenido | Aleatorio, sin datos legibles | Identidad y claims firmados |
| Validación | Búsqueda en el almacén de tokens | Verificación de firma, emisor y caducidad |
| Estado en servidor | Necesario para cada petición | No necesario para autenticar |
| Revocación | Revocable marcando su estado | No revocable sin estado adicional |
| Tamaño | Corto | Más largo, viaja en cada petición |
El carácter **stateless** se refiere solo a la autenticación con el JWT de acceso. El refresh token sigue siendo opaco y stateful: se almacena su hash, su caducidad y su estado de revocación o rotación.
===== 2. Estructura y claims =====
Un JWT tiene tres partes unidas por puntos: cabecera, carga útil y firma. Cada parte se codifica en Base64 apto para URL:
@startuml
left to right direction
rectangle "Cabecera\ntipo + algoritmo" as header #ADD8E6
rectangle "Carga útil\nclaims" as payload #90EE90
rectangle "Firma\nverificación" as signature #F08080
header --> payload : "."
payload --> signature : "."
note bottom : El token viaja como una sola línea: cabecera.carga.firma
@enduml
. // cabecera
. // carga útil (claims)
// firma
Los **claims** son las afirmaciones contenidas en la carga útil:
^ Claim ^ Significado ^
| ''iss'' | Emisor esperado del token |
| ''sub'' | Sujeto, normalmente el id del usuario |
| ''roles'' | Roles necesarios para autorizar, por ejemplo ''ROLE_USER'' |
| ''iat'' | Momento de emisión |
| ''exp'' | Momento de caducidad |
Ejemplo de carga útil decodificada:
{
"iss": "api-ejemplo",
"sub": "42",
"roles": ["ROLE_USER"],
"iat": 1760515200,
"exp": 1760516100
}
La firma se calcula sobre cabecera y carga con una clave conocida solo por el servidor. En los ejemplos se usa **JJWT** con **HS256**, firma simétrica: el mismo secreto firma y verifica.
La carga útil va solo firmada, no cifrada. Cualquiera puede decodificar el Base64 y leerla. No incluir contraseñas, datos personales sensibles ni información interna en los claims.
===== 3. Emisión: JWT corto y refresh opaco =====
Al iniciar sesión, el servidor valida las credenciales y emite **un par con naturalezas distintas**:
^ Token ^ Naturaleza ^ Vida típica ^
| **Acceso JWT** | Autocontenido y firmado | Corta, de 5 a 15 minutos |
| **Refresh token** | Opaco, aleatorio, almacenado como hash | Larga, días o semanas |
@startuml
actor Cliente
participant Servidor
database "Almacén de refresh" as Store
Cliente -> Servidor : credenciales
Servidor -> Servidor : autentica y firma JWT
Servidor -> Store : registra hash del refresh
Servidor --> Cliente : JWT de acceso + refresh opaco
@enduml
La generación firma los claims con el emisor, el sujeto, los roles y las fechas de emisión y caducidad:
String generateAccessToken(User user, Instant issuedAt, Instant expiresAt) {
return Jwts.builder()
.issuer(issuer)
.subject(user.id().toString())
.claim("roles", user.roles().stream().sorted().toList())
.issuedAt(Date.from(issuedAt))
.expiration(Date.from(expiresAt))
.signWith(secretKey)
.compact();
}
El refresh token se genera con un generador criptográficamente seguro y se almacena su hash, nunca el valor reutilizable ni en logs. Su rotación y la detección de reuso siguen las mismas reglas que con tokens opacos.
===== 4. Validación stateless en el filtro =====
En cada petición protegida, el filtro extrae el JWT de la cabecera ''Authorization'' con el esquema ''Bearer'', verifica firma, emisor y caducidad, y construye el ''Principal'' solo con los claims. No consulta la base de datos:
@startuml
actor Cliente
participant Filtro
participant Servidor
Cliente -> Filtro : petición + Bearer JWT
Filtro -> Filtro : verifica firma, emisor y caducidad
Filtro -> Servidor : continúa con Principal de los claims
Servidor -> Servidor : autoriza la operación
Servidor --> Cliente : resultado o acceso denegado
@enduml
Optional parseAccessToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.requireIssuer(issuer)
.build()
.parseSignedClaims(token)
.getPayload();
return Optional.of(new AuthenticatedPrincipal(
Long.valueOf(claims.getSubject()), extractRoles(claims)));
} catch (JwtException | IllegalArgumentException exception) {
return Optional.empty();
}
}
La autenticación delega en ese parseado. Si el token falta, está mal formado, tiene otra firma, otro emisor o ha caducado, no hay identidad autenticada:
Optional authenticate(String accessToken) {
if (accessToken == null || accessToken.isBlank()) {
return Optional.empty();
}
return parseAccessToken(accessToken);
}
El filtro deja el resultado en el contexto de la petición y lo limpia al finalizar, como en el filtro de tokens opacos. Las rutas públicas continúan sin ''Principal''. Los roles usados para autorizar son los congelados en el claim ''roles''. Si una operación necesita datos actuales —perfil completo, habilitación o permisos—, esa lectura es una consulta aparte a la BBDD.
Validar siempre firma, ''iss'' y ''exp''. La firma demuestra que el contenido no se manipuló y que lo generó quien conoce el secreto, usando la clave y el algoritmo esperados. Un parser que acepte ''alg'' ''none'' admite cargas forjadas sin firma: el atacante escribe el ''sub'' y los ''roles'' que quiera y el servidor los creería. Comprobar ''iss'' delimita el dominio de confianza: sin esa comprobación, el servidor acepta tokens emitidos por otro entorno o servicio. Exigir ''exp'' limita la ventana de exposición: sin caducidad, un token robado vale indefinidamente.
===== 5. Límites del JWT puro =====
Como el filtro no consulta la base de datos, todo cambio posterior a la emisión queda invisible hasta que el token caduca:
^ Problema ^ Por qué ocurre ^ Mitigación ^
| Roles modificados | Los roles quedan congelados en el token | - TTL corto (5-15 min).\\ - Consultar la BBDD en operaciones críticas |
| Usuario deshabilitado o eliminado | El JWT anterior sigue aceptado hasta ''exp'' | - TTL corto.\\ - El refresh sí es revocable y cierra la sesión futura |
| Token robado | No hay revocación sin guardar estado | - TTL corto.\\ - Rotar el refresh ante sospecha.\\ - Denylist solo si se acepta perder stateless |
| Datos sensibles en claims | La carga es legible por cualquiera | - Incluir solo id y roles.\\ - Nunca secretos ni datos personales |
| Secreto comprometido o rotado | La misma clave verifica todos los tokens | - Clave de al menos 256 bits externalizada.\\ - Rotación planificada |
| Relojes desincronizados | ''iat'' y ''exp'' dependen de la hora | - Margen de tolerancia pequeño.\\ - Sincronización entre servidores |
Un JWT puro no permite expulsar de inmediato ni degradar permisos: solo cabe esperar a que caduque. Por eso el acceso debe durar minutos y la revocación real vive en el refresh token opaco.
Una **denylist** es un registro de tokens revocados —normalmente sus identificadores— que el servidor consulta en cada petición hasta que caducan (''exp'') y después puede purgar. Restaura la revocación inmediata a cambio de volver a guardar y consultar estado en la ruta crítica. Solo compensa en incidentes puntuales, no como diseño permanente de un flujo stateless.
===== 6. Pruebas =====
* Probar el proveedor: firma válida aceptada; firma manipulada, emisor distinto, token caducado o algoritmo ''none'' rechazados.
* Probar que los roles del ''Principal'' son los del claim, sin consultar la base de datos.
* Probar el filtro: cabecera ''Bearer'' válida y ausente o mal formada; rutas públicas sin identidad; limpieza del contexto tras la petición.
* Probar la autorización: acceso permitido y denegado según los roles del token.
* Probar los límites como comportamiento documentado: un cambio de roles o de habilitación posterior a la emisión no altera un JWT vigente; la revocación se prueba sobre el refresh, no sobre el acceso.
* Probar el refresh con base de datos real: hash almacenado, caducidad, rotación atómica y reuso detectado.
Las pruebas del JWT aíslan el parseado y el filtro; las del refresh integran el almacén. No probar la persistencia a través del filtro stateless ni la firma a través de la base de datos.