CicloUrbana ya sabe quién es cada ciudadano de Ribalta, pero lo pregunta de una forma que no aguanta el uso real. Con HTTP Basic, la aplicación móvil tiene que guardar la contraseña de Marta y enviarla en cada petición: al abrir el mapa, al consultar una estación, al iniciar un alquiler. La credencial más valiosa del usuario recorre la red decenas de veces al día, el servidor calcula un BCrypt de noventa milisegundos en cada una, y no hay manera de caducar un acceso sin obligar a cambiar la contraseña.
Esta lección lo resuelve con el patrón estándar de las APIs modernas: la contraseña se envía una sola vez, y a cambio el servidor emite un token firmado y caducable que el cliente presenta después en la cabecera Authorization: Bearer. Veremos qué es exactamente un JWT campo a campo, lo implementaremos con JJWT y un filtro propio, añadiremos tokens de refresco con rotación y revocación en base de datos, y afrontaremos con honestidad las dos preguntas incómodas del modelo sin estado: dónde guarda el cliente el token y qué significa realmente cerrar sesión.
Advertencia. Todos los secretos, claves y tokens de esta lección son ficticios y están truncados; ninguno debe reutilizarse. El secreto de firma jamás se escribe en el repositorio: se inyecta por variable de entorno o gestor de secretos. Y una implementación propia de JWT, aunque use una librería sólida, debe ser revisada por un profesional de seguridad antes de exponerse a Internet; el apartado 15 explica la alternativa que evita escribir este código.
Contenido
- Por qué una app móvil necesita autenticación sin estado
- Sesión con cookie frente a token: la comparación honesta
- Anatomía de un JWT
- Los claims: estándar y propios de CicloUrbana
- Algoritmos de firma: HS256 frente a RS256
- Firmado no es cifrado
- JJWT y
JwtProperties ServicioJwt: generar, leer y validarFiltroAutenticacionJwt- Registrar el filtro en la cadena de seguridad
POST /api/v1/auth/login- Tokens de refresco: rotación y revocación
- Cerrar sesión en un mundo sin estado
- Dónde guarda el token el cliente
- Documentar el esquema en OpenAPI y la alternativa para producción
- Probar el flujo completo con curl
- Errores Comunes y Consejos
- Ejercicios
- Por qué una app móvil necesita autenticación sin estado
La alternativa clásica al token es la sesión en el servidor: el usuario entra una vez, el servidor guarda su estado en memoria y le entrega una cookie con un identificador. Funciona muy bien para una web tradicional y muy mal para CicloUrbana:
- El cliente no es un navegador. Una aplicación móvil no gestiona cookies de forma natural, y la política de terceros de los navegadores complica cada vez más el escenario web.
- La sesión vive en una instancia. Con tres réplicas detrás de un balanceador, la sesión de Marta está en la número 2 y las otras dos no la conocen: las salidas son sesión pegajosa (frágil) o replicación (cara). Con un token, cualquier instancia atiende cualquier petición.
- Contradice la restricción REST de 03-01, que exige que cada petición contenga toda la información necesaria, y consume memoria proporcional al número de usuarios conectados.
La solución sin estado invierte el almacenamiento: el estado viaja con el cliente, firmado por el servidor para que no pueda manipularlo. El servidor no recuerda nada; solo verifica una firma.
- Sesión con cookie frente a token: la comparación honesta
| Sesión con cookie | Token JWT | |
|---|---|---|
| Dónde vive el estado | En el servidor | En el cliente |
| Escala horizontal | Requiere sesión pegajosa o almacén compartido | Trivial |
| Revocación inmediata | Sí: se borra la sesión | No: vale hasta que caduca |
| Tamaño por petición | ~50 bytes | 300–1000 bytes |
| Cambio de roles / cliente móvil | Inmediato / incómodo | Hasta la caducidad / natural |
| CSRF | Vulnerable, exige token anti-CSRF | No aplica si va en cabecera |
| XSS | La cookie HttpOnly no es legible por JS |
Grave si se guarda en localStorage |
Los tres inconvenientes del JWT hay que decirlos sin adornos, porque casi nunca se mencionan:
No se puede revocar fácilmente. Un token robado es válido hasta que expira y no hay nada en el servidor que lo invalide: si Marta pierde el móvil, su token sigue funcionando. La defensa principal es la caducidad corta, y por eso el apartado 12 introduce los tokens de refresco. Ocupa espacio: un JWT con roles ronda los 400 bytes que viajan en cada petición, un coste real sobre una conexión móvil. Y es peligroso si se almacena mal: guardarlo en localStorage lo hace legible por cualquier JavaScript de la página, de modo que un XSS pasa de ser un problema molesto a un robo de identidad completo (apartado 14).
Un JWT no es la mejor opción siempre. Para una aplicación web clásica con vistas en el servidor, la sesión con cookie sigue siendo más simple y más segura. CicloUrbana elige el token porque su cliente es móvil y su API es sin estado, no porque sea moderno.
- Anatomía de un JWT
Un JSON Web Token (RFC 7519) son tres bloques codificados en Base64URL y separados por puntos:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJ0YUByaWJhbHRhLmVzIiwia WRVc3VhcmlvIjo0MiwiaXNzIjoiY2ljbG91cmJhbmEifQ.K3Bn1sV7oQ2xJ0dLpR8mFq4tYc9Z └──────── cabecera ────────┘ └──────────── carga útil ────────────┘ └ firma ┘
Al decodificar los dos primeros bloques —que es trivial: echo '<bloque>' | base64 -d— se obtiene JSON:
{ "iss": "ciclourbana",
"sub": "[email protected]",
"idUsuario": 42,
"roles": ["ROLE_CIUDADANO"],
"iat": 1773561600,
"exp": 1773562500,
"jti": "8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c" }Y el tercer bloque, la firma, se calcula así:
Base64URL no es cifrado: es una codificación reversible que existe solo para que el token viaje sin caracteres problemáticos en una URL o una cabecera. Cualquiera puede leer la carga útil; lo que nadie puede hacer sin el secreto es producir una firma válida. La verificación funciona así:
flowchart LR
S["Separar<br/>cabecera . carga . firma"] --> R["Recalcular el HMAC<br/>con el secreto del servidor"]
R --> C{"¿Coincide<br/>la firma?"}
C -- "No" --> X["Manipulado o falso → 401"]
C -- "Sí" --> E{"¿exp en<br/>el futuro?"}
E -- "No" --> Y["Caducado → 401"]
E -- "Sí" --> OK["Autenticar"]
Si un atacante cambia "roles":["ROLE_CIUDADANO"] por "roles":["ROLE_ADMIN"], la carga útil cambia, el HMAC recalculado ya no coincide con la firma que acompaña al token y el servidor lo rechaza. No puede recalcular la firma correcta porque no tiene el secreto.
- Los claims: estándar y propios de CicloUrbana
Cada campo de la carga útil es un claim, una afirmación sobre el sujeto. El RFC define siete registrados:
| Claim | Nombre | Para qué sirve | En CicloUrbana |
|---|---|---|---|
iss |
Issuer | Quién emitió el token | ciclourbana |
sub |
Subject | De quién habla | El correo del usuario |
aud |
Audience | Para quién es válido | ciclourbana-api |
exp |
Expiration | Instante tras el cual no vale | Emisión + 15 minutos |
nbf / iat |
Not before / Issued at | Desde cuándo vale, cuándo se emitió | Solo iat |
jti |
JWT ID | Identificador único del token | UUID, para revocación |
Los tres primeros importan más de lo que parece. Validar iss y aud evita que un token emitido por otro sistema —o para otro servicio de la misma organización— sea aceptado aquí, un fallo real y frecuente en arquitecturas con varios servicios; y jti es lo que hace posible una lista de revocación (apartado 13). A ellos CicloUrbana añade dos claims propios:
| Claim propio | Tipo | Por qué |
|---|---|---|
roles |
Lista de cadenas | Evita consultar la base de datos en cada petición para saber qué puede hacer el usuario |
idUsuario |
Número | El mismo motivo por el que UsuarioAutenticado guarda el id (05-03): permite comprobar la propiedad de un alquiler sin consultar |
Cada claim que añades es un compromiso. Agranda el token y, sobre todo, congela un dato: si un administrador retira el rol OPERARIO a Luis, su token sigue diciendo que lo tiene hasta que caduque. Con quince minutos de vida es asumible; con veinticuatro horas, no. Es la limitación que anticipamos en el ejercicio 3 de 05-03, y la razón de fondo de que el token de acceso deba ser corto.
- Algoritmos de firma: HS256 frente a RS256
| HS256 (HMAC-SHA256) | RS256 (RSA-SHA256) | |
|---|---|---|
| Tipo | Simétrico: un secreto compartido | Asimétrico: par de claves |
| Quién firma / verifica | El mismo secreto para ambas cosas | Privada firma, pública verifica |
| Tamaño de la firma y velocidad | 32 bytes, muy rápido | 256 bytes, firma lenta |
| Riesgo principal | El secreto está en todos los que verifican | Gestión del par de claves |
| Cuándo usarlo | Un solo servicio emite y verifica | Varios servicios verifican; proveedor externo |
CicloUrbana usa HS256, porque hoy la misma aplicación emite y verifica los tokens y un secreto compartido es la solución más simple y perfectamente segura en ese escenario. Hay que cambiar a RS256 en cuanto más de un servicio necesite verificar: con HS256, quien puede verificar también puede emitir, así que compartir el secreto con cinco microservicios significa que cinco equipos pueden fabricar tokens de administrador. Con RS256 los servicios reciben solo la clave pública y verifican sin poder falsificar. Es el escenario del módulo 7, y el motivo de que los proveedores de identidad usen siempre algoritmos asimétricos.
La vulnerabilidad
alg: none. Las primeras implementaciones de JWT aceptaban un token cuya cabecera declarase"alg":"none"y lo daban por válido sin firma. Un atacante solo tenía que quitar la firma y cambiar la cabecera. Otra variante consistía en cambiarRS256porHS256para que el servidor usara la clave pública como secreto HMAC —una clave que el atacante conoce—. La defensa es la misma en ambos casos: el algoritmo esperado lo decide el servidor, nunca el token. JJWT 0.12 lo hace correctamente si se usaverifyWith(clave), que fija el algoritmo a partir del tipo de clave. Nunca escribas código que leaalgde la cabecera para decidir cómo verificar.
- Firmado no es cifrado
Advertencia destacada. La carga útil de un JWT es legible por cualquiera que tenga el token: basta con decodificar Base64URL, sin secretos ni herramientas. La firma garantiza integridad y autenticidad, no confidencialidad.
Lo que nunca debe ir en un JWT:
- Contraseñas o hashes de contraseñas.
- DNI, dirección postal, teléfono, datos de salud o cualquier dato personal sensible.
- Números de tarjeta, datos bancarios, claves de API, secretos internos o rutas de infraestructura.
- Información comercial reservada, como el importe acumulado de los alquileres.
Lo que sí es razonable: el identificador del usuario, su correo si el servicio lo trata como identificador público, sus roles y las marcas de tiempo. La regla práctica: si no lo escribirías en una postal, no lo pongas en un JWT. Cuando de verdad hace falta confidencialidad existe JWE (JSON Web Encryption), que cifra la carga útil; es notablemente más complejo, y la solución habitual es más simple: no meter el dato en el token y consultarlo en el servidor cuando haga falta.
- JJWT y
JwtProperties
JwtProperties<!-- Las tres con <version>0.12.6</version>: JJWT no lo gestiona el starter-parent -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId>
<version>0.12.6</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId>
<version>0.12.6</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version><scope>runtime</scope></dependency>Las tres son necesarias y el reparto es deliberado: jjwt-api contiene las interfaces y es la única con la que compilas; jjwt-impl y jjwt-jackson son implementaciones en ámbito runtime, lo que impide acoplar tu código a detalles internos. Si aparece un ClassNotFoundException sobre DefaultJwtBuilder, es que falta jjwt-impl. Y ojo: JJWT no está gestionado por el spring-boot-starter-parent, así que la versión hay que fijarla a mano y revisarla periódicamente (05-05).
La configuración, con @ConfigurationProperties tipadas como en 02-05:
# application.yml
ciclourbana:
jwt:
emisor: ciclourbana
audiencia: ciclourbana-api
expiracion: 15m # token de acceso: corto a propósito
expiracion-refresco: 30d
secreto: ${JWT_SECRETO} # NUNCA un valor literal aquípackage com.ciclourbana.seguridad;
@ConfigurationProperties(prefix = "ciclourbana.jwt")
@Validated
public record JwtProperties(
@NotBlank @Size(min = 43) String secreto, // 43 caracteres Base64 = 256 bits
@NotBlank String emisor,
@NotBlank String audiencia,
@NotNull Duration expiracion,
@NotNull Duration expiracionRefresco) {
public JwtProperties {
if (expiracion.compareTo(Duration.ofHours(1)) > 0) {
throw new IllegalArgumentException(
"El token de acceso no debe durar más de una hora; usa refresco");
}
}
}Cuatro decisiones importantes:
El secreto viene de una variable de entorno. ${JWT_SECRETO} sin valor por defecto: si la variable no existe, la aplicación no arranca, que es exactamente lo que se quiere. Escribir un secreto por defecto en el YAML es peor que no tener seguridad, porque crea una falsa sensación de tenerla: acaba en Git, en la imagen Docker y en el portátil de todo el equipo, y ese mismo valor termina en producción con una frecuencia que asusta.
@Size(min = 43) no es arbitrario. HS256 exige una clave de al menos 256 bits, que en Base64 son 43 caracteres; JJWT rechaza claves más cortas con WeakKeyException, y validarlo al arrancar convierte ese fallo en tiempo de ejecución en un error de configuración comprensible. Un secreto adecuado se genera, nunca se inventa:
openssl rand -base64 48 # 64 caracteres Base64 = 384 bits
export JWT_SECRETO='...' # el valor real, nunca en un fichero del repositorioEl constructor compacto valida la política, no solo el formato: que alguien configure expiracion: 24h no es un error de tipo pero sí de seguridad, y el arranque lo impide. Y el secreto se rota, como cualquier credencial: en producción vive en un gestor de secretos (Vault, AWS Secrets Manager, los secrets de Kubernetes) y se cambia periódicamente, sabiendo que rotarlo invalida todos los tokens en circulación.
ServicioJwt: generar, leer y validar
ServicioJwt: generar, leer y validarpackage com.ciclourbana.seguridad;
@Service
public class ServicioJwt {
private static final Logger log = LoggerFactory.getLogger(ServicioJwt.class);
private final JwtProperties propiedades;
private final SecretKey clave;
private final Clock reloj; // el bean Clock de 03-03
public ServicioJwt(JwtProperties propiedades, Clock reloj) {
this.propiedades = propiedades;
this.reloj = reloj;
// Decodifica el secreto Base64 y verifica que tenga fuerza suficiente
this.clave = Keys.hmacShaKeyFor(Decoders.BASE64.decode(propiedades.secreto()));
}
/** Emite un token de acceso para un usuario ya autenticado. */
public String generarToken(UsuarioAutenticado usuario) {
Instant ahora = Instant.now(reloj);
return Jwts.builder()
.issuer(propiedades.emisor())
.audience().add(propiedades.audiencia()).and()
.subject(usuario.getUsername())
.id(UUID.randomUUID().toString()).issuedAt(Date.from(ahora)) // jti, iat
.expiration(Date.from(ahora.plus(propiedades.expiracion())))
.claim("idUsuario", usuario.getIdUsuario())
.claim("roles", usuario.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.signWith(clave, Jwts.SIG.HS256)
.compact();
}
/** Verifica firma, emisor, audiencia y expiración. Lanza si algo falla. */
public Claims extraerClaims(String token) {
return Jwts.parser()
.verifyWith(clave) // fija el algoritmo: no lo lee del token
.requireIssuer(propiedades.emisor())
.requireAudience(propiedades.audiencia())
.clockSkewSeconds(30) // tolerancia de reloj entre máquinas
.build().parseSignedClaims(token).getPayload();
}
/** Versión que no lanza: útil en el filtro, donde un token inválido es rutina. */
public Optional<Claims> validar(String token) {
try {
return Optional.of(extraerClaims(token));
} catch (ExpiredJwtException e) {
log.debug("Token caducado"); // frecuente: NO es un error
} catch (SecurityException | MalformedJwtException e) {
log.warn("Token con firma inválida o malformado"); // esto sí es sospechoso
} catch (JwtException | IllegalArgumentException e) {
log.warn("Token no válido: {}", e.getClass().getSimpleName());
}
return Optional.empty(); // nunca se registra el token en el log
}
}Cinco detalles que hacen que este código sea correcto y no solo funcional:
verifyWith(clave)fija el algoritmo a partir del tipo de clave. Es lo que cierra la puerta aalg: noney a la confusión RS256/HS256 del apartado 5.requireIssueryrequireAudiencerechazan tokens ajenos. Se olvidan casi siempre.clockSkewSeconds(30)tolera el desfase de reloj entre máquinas. Sin él, dos servidores con dos segundos de diferencia producen rechazos intermitentes imposibles de reproducir. Y elClockinyectado —el bean de 03-03— hará posible probar la caducidad en el módulo 6 sin esperar quince minutos.- Un token caducado se registra en
DEBUG; una firma inválida, enWARN. Lo primero es el funcionamiento normal; lo segundo puede ser un ataque, y mezclarlos hace inútil el log (09-05). - Nunca se registra el token: un token en un log es una credencial en un log. Y
.audience().add(...).and()es la API fluida de JJWT 0.12, que cambió respecto a 0.11, así que mucho código de internet no compilará contra esta versión.
FiltroAutenticacionJwt
FiltroAutenticacionJwtEl filtro es la pieza que convierte una cabecera HTTP en un usuario autenticado. Hereda de OncePerRequestFilter, igual que el FiltroTraza de 03-06, para garantizar una sola ejecución por petición aunque haya reenvíos internos.
package com.ciclourbana.seguridad;
@Component
public class FiltroAutenticacionJwt extends OncePerRequestFilter {
private static final String CABECERA = "Authorization";
private static final String PREFIJO = "Bearer ";
private final ServicioJwt servicioJwt; // constructor omitido
@Override
protected void doFilterInternal(HttpServletRequest peticion,
HttpServletResponse respuesta,
FilterChain cadena) throws ServletException, IOException {
extraerToken(peticion).flatMap(servicioJwt::validar)
.ifPresent(claims -> autenticar(claims, peticion));
// SIEMPRE se continúa: rechazar es trabajo del AuthorizationFilter
cadena.doFilter(peticion, respuesta);
}
private Optional<String> extraerToken(HttpServletRequest peticion) {
String cabecera = peticion.getHeader(CABECERA);
return (cabecera != null && cabecera.startsWith(PREFIJO))
? Optional.of(cabecera.substring(PREFIJO.length()).trim()) : Optional.empty();
}
@SuppressWarnings("unchecked")
private void autenticar(Claims claims, HttpServletRequest peticion) {
if (SecurityContextHolder.getContext().getAuthentication() != null) return;
List<String> roles = claims.get("roles", List.class);
var autoridades = roles.stream().map(SimpleGrantedAuthority::new).toList();
var usuario = new UsuarioAutenticado(claims.get("idUsuario", Integer.class).longValue(),
claims.getSubject(), autoridades);
var autenticacion = new UsernamePasswordAuthenticationToken(
usuario, null, autoridades); // credenciales: null, ya está autenticado
autenticacion.setDetails(new WebAuthenticationDetailsSource().buildDetails(peticion));
SecurityContextHolder.getContext().setAuthentication(autenticacion);
}
}Cinco decisiones que conviene entender:
El filtro nunca rechaza la petición. Si no hay token, o es inválido, simplemente no autentica y deja continuar la cadena: quien decide si esa petición necesitaba autenticación es el AuthorizationFilter de 05-01, y quien produce el 401 es el AuthenticationEntryPoint de 05-03. Esta separación es la que permite que GET /api/v1/estaciones siga siendo público mientras /api/v1/alquileres no lo es, con el mismo filtro.
No se consulta la base de datos: toda la información —id, correo, roles— viene del token. Es lo que hace el modelo sin estado y rápido, y también lo que congela los roles hasta la caducidad; la alternativa, cargar el UserDetails en cada petición, es más segura y sacrifica la mayor parte de la ventaja.
Se comprueba si ya hay autenticación antes de escribir en el contexto, para no pisar la de otro mecanismo. UsuarioAutenticado necesita un segundo constructor que reciba id, correo y autoridades, sin entidad ni hash: getPassword() devolverá null y no pasa nada, porque en este camino nadie compara contraseñas. Y se guardan los details con la IP: información valiosa para la auditoría de 05-03.
- Registrar el filtro en la cadena de seguridad
@Bean
SecurityFilterChain cadenaApi(HttpSecurity http,
FiltroAutenticacionJwt filtroJwt,
PuntoEntradaNoAutenticado puntoEntrada,
ManejadorAccesoDenegado accesoDenegado) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // justificado en 05-02
.cors(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable) // adiós a la contraseña en cada petición
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable) // no hay sesión que cerrar
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll()
.anyRequest().denyAll()) // ... y el resto del mapa de 05-02
.exceptionHandling(ex -> ex.authenticationEntryPoint(puntoEntrada)
.accessDeniedHandler(accesoDenegado))
.addFilterBefore(filtroJwt, UsernamePasswordAuthenticationFilter.class);
return http.build();
}addFilterBefore(filtroJwt, UsernamePasswordAuthenticationFilter.class) coloca el filtro en una posición concreta de la cadena de 05-01: después de SecurityContextHolderFilter y CorsFilter —así el contexto está limpio y las peticiones de sondeo CORS ya se han resuelto— y antes de ExceptionTranslationFilter y AuthorizationFilter, que es lo imprescindible: cuando la autorización evalúe las reglas, el contexto ya debe contener al usuario. Y aquí desaparecen httpBasic y formLogin, tal como anunciamos en 05-02: a partir de ahora la única forma de autenticarse contra la API es un token.
POST /api/v1/auth/login
POST /api/v1/auth/loginpublic record LoginRequest(@NotBlank @Email String correo, @NotBlank String contrasena) {}
public record TokenResponse(String tokenAcceso, String tokenRefresco,
String tipo, long expiraEnSegundos) {}
@Service
public class ServicioAutenticacion {
private final AuthenticationManager gestorAutenticacion; // bean de 05-03
private final ServicioJwt servicioJwt;
private final ServicioTokenRefresco servicioRefresco;
private final JwtProperties propiedades; // constructor omitido
public TokenResponse autenticar(LoginRequest peticion) {
// Delega TODA la validación en el DaoAuthenticationProvider de 05-03:
// hash, cuenta activa, bloqueo y protección contra temporización
Authentication autenticacion = gestorAutenticacion.authenticate(
new UsernamePasswordAuthenticationToken(
peticion.correo().trim().toLowerCase(Locale.ROOT),
peticion.contrasena()));
var usuario = (UsuarioAutenticado) autenticacion.getPrincipal();
return new TokenResponse(
servicioJwt.generarToken(usuario),
servicioRefresco.emitir(usuario.getIdUsuario()).token(),
"Bearer",
propiedades.expiracion().toSeconds());
}
}El controlador es un @PostMapping("/login") en el AutenticacionController de 05-03 que recibe @Valid @RequestBody LoginRequest y devuelve 200 OK con el TokenResponse.
Tres puntos. La autenticación no se reimplementa: se delega en el AuthenticationManager, que ya sabe comparar hashes en tiempo constante, comprobar si la cuenta está activa y publicar los eventos de fallo; reescribir aquí un if (codificador.matches(...)) perdería todo eso. Si falla lanza BadCredentialsException, que hay que traducir a un 401 uniforme en ManejadorGlobalExcepciones —este sí lo ve, porque ocurre dentro de un controlador—. Y la respuesta no revela nada del usuario: solo tokens.
- Tokens de refresco: rotación y revocación
Hay una tensión evidente. Un token de acceso corto limita el daño de un robo, pero obliga al ciudadano a escribir su contraseña cada quince minutos. Un token largo es cómodo y peligroso.
La solución son dos tokens con papeles distintos:
| Token de acceso | Token de refresco | |
|---|---|---|
| Formato y duración | JWT firmado, 15 minutos | Cadena aleatoria opaca, 30 días |
| Dónde se usa | En cada petición a la API | Solo en /api/v1/auth/refresco |
| ¿El servidor lo guarda? ¿Se revoca? | No y no | Sí, en base de datos; revocable al instante |
El token de refresco no es un JWT, y es deliberado: como el servidor lo guarda de todos modos para poder revocarlo, no hay ventaja en que sea autocontenido, y una cadena aleatoria larga es más corta, más opaca y no revela nada.
-- V6__crear_tokens_refresco.sql
-- (V5 normalizó los correos, en el ejercicio 2 de 05-03)
CREATE SEQUENCE tokens_refresco_id_seq INCREMENT BY 50 START WITH 1;
CREATE TABLE tokens_refresco (
id BIGINT NOT NULL,
usuario_id BIGINT NOT NULL,
token_hash VARCHAR(64) NOT NULL, -- SHA-256 del token, nunca el token
expira_en TIMESTAMPTZ NOT NULL,
revocado_en TIMESTAMPTZ,
creado_en TIMESTAMPTZ NOT NULL,
ip_origen VARCHAR(45),
CONSTRAINT pk_tokens_refresco PRIMARY KEY (id),
CONSTRAINT uk_tokens_refresco_hash UNIQUE (token_hash),
CONSTRAINT fk_tokens_refresco_user FOREIGN KEY (usuario_id)
REFERENCES usuarios (id) ON DELETE CASCADE
);
CREATE INDEX ix_tokens_refresco_usuario ON tokens_refresco (usuario_id);
CREATE INDEX ix_tokens_refresco_expira ON tokens_refresco (expira_en);Se guarda el SHA-256 del token, no el token, por el mismo razonamiento que con las contraseñas: si alguien lee la tabla, no obtiene credenciales utilizables. Aquí basta un hash rápido, porque el token es una cadena aleatoria de 256 bits y no una contraseña adivinable por diccionario. El endpoint de refresco, con rotación:
@Transactional
public TokenResponse refrescar(String tokenPresentado) {
String hash = sha256(tokenPresentado);
TokenRefresco guardado = repositorio.findByTokenHash(hash)
.orElseThrow(() -> new BadCredentialsException("Token de refresco no válido"));
if (guardado.getRevocadoEn() != null) { // reutilización: señal de robo, se corta todo
log.error("REUTILIZACIÓN de token de refresco. Usuario {}", guardado.getUsuarioId());
repositorio.revocarTodosDelUsuario(guardado.getUsuarioId(), Instant.now(reloj));
throw new BadCredentialsException("Token de refresco no válido");
}
if (guardado.getExpiraEn().isBefore(Instant.now(reloj)))
throw new BadCredentialsException("Token de refresco no válido");
guardado.setRevocadoEn(Instant.now(reloj)); // rotación: el viejo muere
var usuario = cargarUsuario(guardado.getUsuarioId()); // roles frescos de la BD
return new TokenResponse(servicioJwt.generarToken(usuario),
emitir(usuario.getIdUsuario()).token(),
"Bearer", propiedades.expiracion().toSeconds());
}Cuatro ideas clave:
Rotación: cada uso del token de refresco lo invalida y emite uno nuevo. Reduce la ventana de un token robado y, sobre todo, hace detectable el robo.
Detección de reutilización: si llega un token ya revocado, o bien es el ladrón usando uno que la víctima ya rotó, o bien al revés; no hay forma de saber cuál, así que se revocan todos los tokens de refresco de ese usuario y se le obliga a autenticarse de nuevo. Es molesto y es lo correcto.
Los roles se recargan de la base de datos, y es el momento en que el cambio de privilegios del ejercicio 3 de 05-03 surte efecto: como máximo quince minutos después. El mensaje de error es siempre el mismo, no exista el token, esté revocado o haya caducado. Y falta una pieza operativa: una tarea programada que borre los caducados, o la tabla crece sin límite (07-03).
- Cerrar sesión en un mundo sin estado
POST /api/v1/auth/logout no puede invalidar el token de acceso. Está firmado, es válido y el servidor no guarda nada sobre él. Las opciones reales:
| Estrategia | Efecto | Coste |
|---|---|---|
| Borrar el token en el cliente | El cliente deja de enviarlo | Nulo. No protege si ya fue robado |
| Revocar el token de refresco | En ≤15 min el acceso muere | Bajo. La opción de CicloUrbana |
Lista de revocación por jti |
Inmediato | Un almacén consultado en cada petición: deja de ser sin estado |
CicloUrbana implementa el cierre de sesión como revocación del token de refresco: el token de acceso sigue valiendo hasta quince minutos y luego no se puede renovar. Es una decisión consciente, y hay que ser honestos sobre lo que significa: durante esos quince minutos, un token robado sigue funcionando.
Cuando eso no es tolerable —una operación bancaria, un cierre de sesión por sospecha de compromiso— la solución es una lista de revocación de los jti en una caché rápida como Redis, con expiración automática al caducar el token; consultarla en cada petición reintroduce estado, pero acotado, y la caché distribuida se trata en 09-02. En cualquier caso, la defensa principal sigue siendo la expiración corta: todo lo demás son mitigaciones.
- Dónde guarda el token el cliente
Es la parte del sistema que el backend no controla y donde más incidentes reales se producen.
| Almacenamiento | Vulnerable a XSS | Vulnerable a CSRF | Notas |
|---|---|---|---|
localStorage / sessionStorage |
Sí, totalmente | No | Legible por cualquier JS de la página |
Cookie HttpOnly + Secure + SameSite=Strict |
No | Sí, requiere CSRF | El JS no puede leerla |
| Memoria del JS (una variable) | Parcialmente | No | Se pierde al recargar; el refresco en cookie lo mitiga |
| Llavero del sistema (móvil) | No aplica | No aplica | La mejor opción en una app nativa |
Advertencia.
localStoragees el consejo más repetido de internet y el peor. Cualquier XSS —una dependencia de JavaScript comprometida, un campo mal escapado— permite leer el token y suplantar al usuario desde otra máquina. Una cookieHttpOnlyno es legible por JavaScript ni siquiera con XSS.
La app móvil de CicloUrbana, que es el cliente principal, usa el llavero del sistema: Keychain en iOS, EncryptedSharedPreferences o Keystore en Android. Para el panel web del ayuntamiento la recomendación es la cookie HttpOnly; Secure; SameSite=Strict con protección CSRF reactivada —recuerda la advertencia de 05-02: al volver la credencial a una cookie, la condición que permitía desactivar CSRF deja de cumplirse—, o bien el patrón mixto de token de acceso en memoria y refresco en cookie HttpOnly. Y en cualquier caso, HTTPS obligatorio: sin TLS todo lo anterior es irrelevante, porque el token viaja legible por la red (05-05).
- Documentar el esquema en OpenAPI y la alternativa para producción
El Swagger UI de 03-07 dejó de funcionar contra los endpoints protegidos: no tiene dónde escribir el token. Se arregla declarando el esquema de seguridad en ConfiguracionOpenApi:
@Bean
OpenAPI apiCicloUrbana(...) {
return new OpenAPI()
.info(...) // igual que en 03-07
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
.components(new Components().addSecuritySchemes("bearerAuth",
new SecurityScheme().type(SecurityScheme.Type.HTTP)
.scheme("bearer").bearerFormat("JWT")
.description("Token obtenido en POST /api/v1/auth/login")));
}Aparece entonces el botón Authorize en la interfaz y springdoc añade la cabecera a todas las llamadas; los endpoints públicos se marcan con @SecurityRequirements vacío para que la documentación no mienta.
La alternativa recomendada para producción
Todo el código de esta lección es una implementación propia: excelente para entender el mecanismo, y en un sistema real conviene no escribirlo. Spring ofrece un módulo mantenido y auditado:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>spring:
security:
oauth2:
resourceserver:
jwt:
# El proveedor publica sus claves públicas; Spring las descarga y las rota solo
issuer-uri: https://identidad.ribalta.es/realms/ciclourbanaCon esas líneas desaparecen ServicioJwt, FiltroAutenticacionJwt y la gestión de secretos: el BearerTokenAuthenticationFilter de 05-01 se encarga usando Nimbus por debajo, y las claves públicas se obtienen del JWKS del proveedor y se rotan solas.
| Implementación propia (esta lección) | Resource Server + proveedor | |
|---|---|---|
| Código que mantienes | ~200 líneas de seguridad | Prácticamente ninguna |
| Emisión de tokens y rotación de claves | Tuya, manual | Del proveedor (Keycloak, Auth0, Cognito), automática vía JWKS |
| Segundo factor, OIDC, SSO | Habría que implementarlo | Incluido |
| Auditoría del código | Tu responsabilidad | Del proveedor |
| Infraestructura extra | Ninguna | Un servicio más |
El criterio: si el sistema tiene un solo servicio, un cliente y requisitos sencillos, la implementación propia con una librería sólida es aceptable. En cuanto aparezcan varios servicios, inicio de sesión con proveedores externos, segundo factor o requisitos de cumplimiento normativo, usa un proveedor de identidad. El coste de mantener seguridad propia crece mucho más rápido de lo que parece.
- Probar el flujo completo con curl
# 1. Login (tras el registro de 05-03): la contraseña viaja UNA sola vez
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"correo":"[email protected]","contrasena":"clave-larga-ej"}' | jq -r .tokenAcceso)
# 3 y 4. Sin token y con token
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/v1/alquileres # 401
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/alquileres # 200
# 5. Inspeccionar la carga útil (sin secreto: demuestra que NO va cifrada)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
# {"iss":"ciclourbana","sub":"[email protected]","idUsuario":42,"roles":[...],...}
# 6. Marta es CIUDADANO: la gestión de estaciones le está vedada
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"nombre":"Mercado Viejo","capacidad":20}' \
http://localhost:8080/api/v1/estaciones # 403
# 7. Token manipulado: cambiar un solo carácter rompe la firma
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer ${TOKEN}x" http://localhost:8080/api/v1/alquileres # 401Los pasos 5 y 7 son los importantes: el 5 demuestra que el token es legible por cualquiera —de ahí la advertencia del apartado 6— y el 7, que no es modificable.
Errores Comunes y Consejos
Poner datos sensibles en la carga útil. Va firmada, no cifrada: un DNI en un JWT es un DNI público.
Escribir el secreto en application.yml, o usar uno corto e inventado. Un secreto en el YAML acaba en Git, en la imagen y en el portátil de todo el equipo; y misecreto123 no llega a 256 bits. Variable de entorno sin valor por defecto, generado con openssl rand -base64 48. Y nada de tokens de acceso de 24 horas «para que no moleste»: multiplican por 96 la ventana de un robo y congelan los roles todo ese tiempo.
Que el filtro devuelva 401 directamente, o registrarlo en la posición equivocada de la cadena. Lo primero rompe los endpoints públicos —el filtro autentica si puede, y la decisión es del AuthorizationFilter—; lo segundo, si queda tras AuthorizationFilter, hace que todo devuelva 401.
No validar iss ni aud, con lo que un token emitido por otro sistema con el mismo secreto sería aceptado; o leer alg del token para decidir cómo verificar, que es la vulnerabilidad alg: none. El algoritmo lo fija el servidor.
Guardar el token de refresco en claro en la base de datos, o registrar cualquier token en un log aunque sea truncado. Un token es una credencial; guarda su SHA-256 y no lo escribas nunca.
Consejo: expiración corta más refresco con rotación —15 minutos de acceso, 30 días de refresco rotatorio con detección de reutilización— es el equilibrio que funciona. Y usa el Clock inyectado en ServicioJwt: en el módulo 6 podrás probar la caducidad adelantando el reloj en lugar de esperar.
Ejercicios
Ejercicio 1
Un token de CicloUrbana tiene esta carga útil. Detecta cuatro problemas de seguridad o diseño y propón la versión corregida.
{ "sub": "[email protected]", "nombre": "Marta Aguiló", "dni": "12345678Z",
"contrasenaHash": "$2a$10$N9qo8uLOickgx2ZMRZoMye...", "idUsuario": 42,
"roles": ["ROLE_CIUDADANO"], "importeAcumulado": 127.50,
"iat": 1773561600, "exp": 1774166400 }Ejercicio 2
Implementa ServicioJwt.validar(String) de forma que distinga tres resultados —token válido, token caducado y token inválido— mediante un tipo propio en lugar de un Optional, y explica qué debe hacer el filtro con cada uno y a qué nivel de log corresponde.
Ejercicio 3
Diseña el flujo completo para este requisito del ayuntamiento: «cuando un ciudadano cambia su contraseña, todas sus sesiones abiertas en otros dispositivos deben cerrarse inmediatamente». Ten en cuenta que el token de acceso no se puede revocar.
Soluciones
Solución 1
Problema 1 — dni. Dato personal identificativo, legible por cualquiera con el token y sujeto al RGPD. Fuera. Problema 2 — contrasenaHash, el fallo más grave: entrega el hash a cualquiera que capture el token, permitiendo un ataque de diccionario sin límite de intentos y sin dejar rastro en el servidor. Fuera, siempre.
Problema 3 — importeAcumulado. Dato comercial reservado y, además, un dato que cambia: quedaría congelado hasta la caducidad y mostraría cifras obsoletas si el cliente se fiara de él. Se consulta por API. Problema 4 — expiración de siete días (exp - iat = 604 800): un token robado vale una semana, y los cambios de rol tardan una semana en aplicarse. Faltan además iss, aud y jti, sin los cuales no se puede validar la procedencia ni revocar por identificador.
{ "iss": "ciclourbana", "aud": "ciclourbana-api", "sub": "[email protected]",
"idUsuario": 42, "roles": ["ROLE_CIUDADANO"],
"iat": 1773561600, "exp": 1773562500,
"jti": "8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c" }Regla para decidir qué entra: solo lo que se necesita en cada petición para autorizar, no cambia durante la vida del token y no sería un problema en una postal. El nombre podría quedarse si la app lo muestra —no es sensible—, pero engorda el token: es preferible pedirlo una vez a /api/v1/usuarios/yo.
Solución 2
public sealed interface ResultadoValidacion {
record Valido(Claims claims) implements ResultadoValidacion {}
record Caducado(String correo) implements ResultadoValidacion {}
record Invalido(String motivo) implements ResultadoValidacion {}
}
public ResultadoValidacion validar(String token) {
try {
return new ResultadoValidacion.Valido(extraerClaims(token));
} catch (ExpiredJwtException e) {
// La excepción de JJWT conserva los claims: útil para el log y para sugerir refresco
return new ResultadoValidacion.Caducado(e.getClaims().getSubject());
} catch (SecurityException e) {
return new ResultadoValidacion.Invalido("firma");
} catch (MalformedJwtException e) {
return new ResultadoValidacion.Invalido("formato");
} catch (JwtException | IllegalArgumentException e) {
return new ResultadoValidacion.Invalido(e.getClass().getSimpleName());
}
}
// En el filtro, con switch de patrones de Java 21:
switch (servicioJwt.validar(token)) {
case ResultadoValidacion.Valido v -> autenticar(v.claims(), peticion);
case ResultadoValidacion.Caducado c -> { // pista para el cliente
log.debug("Token caducado de {}", c.correo());
respuesta.setHeader("X-Motivo-Auth", "token-caducado");
}
case ResultadoValidacion.Invalido i -> log.warn("Token rechazado ({})", i.motivo());
}Qué hace el filtro con cada caso. Válido: autentica. Caducado: no autentica, pero tampoco es un error; es el funcionamiento normal cada quince minutos, va a DEBUG, y la cabecera de pista permite a la app móvil distinguir «renueva el token» de «vuelve a iniciar sesión» sin exponer nada. Inválido: no autentica y va a WARN, porque una firma incorrecta es un intento de manipulación o un cliente roto, y un pico de estos eventos debe disparar una alerta (09-05). La ventaja del tipo sellado sobre el Optional es que es imposible olvidar un caso: el switch sobre una interfaz sealed debe ser exhaustivo o no compila, y en las pruebas del módulo 6 se puede afirmar sobre el motivo exacto.
Solución 3
El problema: el token de acceso de los otros dispositivos es válido y no hay nada en el servidor que lo invalide; revocar los tokens de refresco cierra la puerta futura, pero deja hasta quince minutos de acceso vigente. Solución completa, en cuatro piezas. 1. Marca de invalidación en el usuario, con una migración V7__anadir_invalidacion_tokens.sql que ejecuta ALTER TABLE usuarios ADD COLUMN tokens_invalidos_antes_de TIMESTAMPTZ;. 2. Al cambiar la contraseña, en la misma transacción: guardar el hash nuevo, poner tokensInvalidosAntesDe = ahora y revocar todos los tokens de refresco del usuario. 3. Comprobación en el filtro, aprovechando que el iat del token dice cuándo se emitió: si es anterior a la marca, el token muere.
Instant emitido = claims.getIssuedAt().toInstant();
Instant corte = servicioUsuarios.invalidacionDe(claims.get("idUsuario", Integer.class));
if (corte != null && emitido.isBefore(corte)) return; // no autenticar4. El coste, y cómo pagarlo. Esto reintroduce una consulta por petición, justo lo que el modelo sin estado evitaba. Tres formas de amortiguarlo, de menor a mayor esfuerzo: cachear la marca por usuario con expiración corta (09-02); mantener en caché solo los usuarios con invalidación reciente, que son poquísimos, y tratar la ausencia como «sin invalidación»; o incluir la marca como claim en el token, aunque entonces solo protege a partir del siguiente refresco y no resuelve el requisito.
La lección de fondo: el requisito «cerrar sesión inmediatamente en todas partes» es incompatible con la autenticación puramente sin estado. Toda solución paga con estado en el servidor. Lo honesto es reconocerlo, elegir dónde se paga y dejarlo documentado, no fingir que el JWT lo resuelve todo. Y conviene preguntar antes si «inmediatamente» significa de verdad cero segundos o si quince minutos son aceptables: la respuesta cambia por completo la arquitectura.
Conclusión
CicloUrbana autentica ya como una API moderna. Sabes por qué una aplicación móvil necesita autenticación sin estado —el cliente no es un navegador, la sesión no sobrevive al balanceador y la restricción REST de 03-01 lo exige— y has comparado sesión y token con honestidad, sin ocultar los tres inconvenientes reales del JWT: no se revoca con facilidad, ocupa espacio en cada petición y es peligroso si el cliente lo almacena mal. Sabes también que para una web clásica con vistas en el servidor la sesión con cookie sigue siendo la opción más simple y más segura: CicloUrbana elige el token por sus requisitos, no por moda.
Conoces un JWT por dentro: cabecera, carga útil y firma en Base64URL, el HMAC que ata las dos primeras partes al secreto del servidor, y el hecho central de que Base64URL no es cifrado. Manejas los claims registrados —iss, sub, aud, exp, iat, jti— y los dos propios del proyecto, roles e idUsuario, sabiendo que cada uno agranda el token y congela un dato hasta la caducidad. Has comparado HS256 con RS256 y sabes cuándo obliga a cambiar la arquitectura: en cuanto un segundo servicio tenga que verificar, porque con un secreto compartido quien verifica también puede emitir. Y conoces la vulnerabilidad alg: none y su defensa: el algoritmo lo decide el servidor, nunca el token.
Has implementado el sistema completo con JJWT 0.12: las tres dependencias con sus ámbitos, JwtProperties validadas con un secreto que llega por variable de entorno y sin valor por defecto —de modo que la aplicación no arranca si falta—, con longitud mínima de 43 caracteres Base64 y una comprobación de política que impide configurar tokens de más de una hora. ServicioJwt genera, extrae y valida con verifyWith, requireIssuer, requireAudience, tolerancia de reloj y un Clock inyectado que hará posible probar la caducidad en el módulo 6. FiltroAutenticacionJwt, un OncePerRequestFilter registrado con addFilterBefore delante de UsernamePasswordAuthenticationFilter, nunca rechaza: autentica si puede y deja la decisión al AuthorizationFilter, que es justo lo que permite convivir endpoints públicos y protegidos en la misma cadena. Y con POST /api/v1/auth/login delegando en el AuthenticationManager de 05-03, HTTP Basic y el formulario han desaparecido de la configuración.
Has añadido tokens de refresco con la tabla tokens_refresco de la migración V6, que guarda el SHA-256 del token y no el token, con rotación en cada uso, detección de reutilización que revoca todas las sesiones del usuario ante la señal de robo y recarga de roles desde la base de datos en cada refresco. Y has mirado de frente los dos problemas que el marketing del JWT suele esconder: cerrar sesión en un mundo sin estado es revocar el refresco y aceptar hasta quince minutos de acceso vigente, o pagar con estado mediante una lista de revocación por jti; y el almacenamiento en el cliente, donde localStorage es el consejo más repetido y el peor, frente al llavero del sistema en la app móvil y la cookie HttpOnly; Secure; SameSite —con CSRF reactivado— en el panel web. El Swagger UI de 03-07 vuelve a funcionar con su botón Authorize, y conoces la alternativa madura para producción: spring-boot-starter-oauth2-resource-server con un proveedor de identidad que rota sus claves por JWKS y trae SSO y segundo factor de serie.
Queda un agujero, y es el mismo que dejamos anotado en 05-01 y en 05-03. Marta tiene un token válido con el rol ROLE_CIUDADANO, así que POST /api/v1/alquileres/9/finalizar pasa todas las reglas de la cadena: la ruta es legítima y el rol es correcto. Pero el alquiler número 9 es de otro ciudadano. Ninguna regla basada en URL puede expresar «solo tus propios alquileres», porque la respuesta no depende de la ruta ni del rol, sino del dato. Es el riesgo A01/BOLA del OWASP Top 10, el más explotado de las APIs REST. En 05-05, Seguridad a Nivel de Método y Endurecimiento de la API, bajaremos la seguridad hasta la capa de servicio con @EnableMethodSecurity, @PreAuthorize y expresiones SpEL, escribiremos un evaluador de permisos propio para la regla de los alquileres, y cerraremos el módulo endureciendo la API entera: CORS por entorno, limitación de tasa, HTTPS obligatorio, ocultación de la documentación en producción, auditoría de eventos de seguridad, revisión de dependencias vulnerables y una lista de comprobación antes de producción.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
