Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado comprobar roles y propiedad en el proceso de revisión. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Los permisos ya funcionan con usuarios conocidos. Hoy compararás la sesión con un token firmado. JWT es un formato de token con datos y firma; la firma permite detectar alteraciones, pero no oculta su contenido. Integrarás emisión y validación manteniendo las mismas reglas de autorización.
El gran debate arquitectónico: ¿Sesión o Token?
Una de las decisiones técnicas más discutidas en arquitectura web es elegir la estrategia de autenticación:
- Sesión: El cliente guarda un ID opaco; el servidor guarda los datos en RAM/Redis.
- JWT: El cliente guarda los datos firmados; el servidor no guarda nada en RAM.
| Característica | Sesión basada en Cookies (JSESSIONID) |
Token firmado (JWT) |
|---|---|---|
| Estado en el servidor | Con estado (Stateful): El servidor debe recordar la sesión en su memoria RAM o en un almacén Redis compartido. | Sin estado (Stateless): El servidor no guarda nada en memoria; valida la firma matemática del token en cada petición. |
| Escalabilidad horizontal | Requiere balanceo con sesiones pegajosas (Sticky Sessions) o clúster de Redis centralizado. | Excelente de forma nativa: Cualquier instancia de Spring Boot con la misma clave secreta puede validar el token sin consultar bases de datos. |
| Clientes heterogéneos | Ideal para aplicaciones web tradicionales en el navegador. | Ideal para aplicaciones móviles (iOS/Android), microservicios y APIs consumidas por terceros. |
| Revocación inmediata | Trivial: Basta con ejecutar session.invalidate() o borrar la clave en Redis. |
Compleja: Una vez emitido, el token es válido hasta que expire su fecha exp, a menos que mantengas una lista negra en base de datos (reintroduciendo estado). |
Anatomía de un JSON Web Token (JWT)
Un JWT es una cadena de texto compacta dividida en tres partes separadas por puntos:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInJvbCI6IlJPTEVfQURNSU5JU1RSQURPUiIsImV4cCI6MTc3MDk4NzYwMH0.D3f8A9...
\___________________/ \___________________________________________________________________/ \_______/
Header Payload Signature
- Header (Cabecera): Especifica el tipo de token (
JWT) y el algoritmo de firma criptográfica (ej:HS256para HMAC con clave secreta compartida, oRS256para clave pública/privada). - Payload (Cuerpo / Reclamaciones - Claims): Contiene las afirmaciones sobre el usuario en formato JSON:
- Reclamaciones estándar:
sub(subject, nombre de usuario),iat(issued at, fecha de emisión),exp(expiration, fecha de caducidad). - Reclamaciones personalizadas:
roles,email,tenantId. - ¡Atención! El payload no está cifrado; solo está codificado en Base64Url. Cualquiera puede leerlo. Nunca guardes contraseñas ni datos confidenciales dentro de un JWT.
- Reclamaciones estándar:
- Signature (Firma criptográfica): Se calcula combinando el Header y el Payload codificados con una clave secreta conocida solo por el servidor:
Si un atacante modifica un solo carácter del payload (por ejemplo, cambia su rol defirma = HMAC-SHA256(cabecera + "." + payload, CLAVE_SECRETA)ROLE_DESARROLLADORaROLE_ADMINISTRADOR), la firma deja de coincidir y el backend rechaza el token de inmediato.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre la configuración de seguridad, el acceso del cliente y los tests de permisos. Anota cómo se transporta actualmente la identidad.
- Localiza
pom.xmly la configuración externa para la clave y la caducidad del token. Utiliza valores de desarrollo fuera del repositorio y una cuenta ficticia. - Guarda el recorrido de autenticación actual para compararlo con el del token. Señala qué rutas emitirán tokens y cuáles exigirán validarlos.
Paso 2 · Generación y validación de JWT en Spring Boot
Trabaja en este orden: dependencias JJWT en pom.xml, configuración de la clave externa, security/JwtService.java, security/JwtAuthenticationFilter.java y modificación de la cadena existente. No pegues varias SecurityConfig de sesiones distintas: al final debe haber una sola configuración efectiva para estas rutas. El filtro obtiene la identidad del token y carga el usuario persistente; conserva las reglas de rol y propiedad. Antes de probarlo necesitas el endpoint de login del paso 3, que produce el token con una autenticación real.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
Este servicio encapsula la firma y lectura de tokens mediante una clave secreta segura:
package com.ejemplo.gestor.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Service
public class JwtService {
// Clave secreta de al menos 256 bits (32 caracteres) externalizada
@Value("${app.security.jwt.secret}")
private String jwtSecret;
@Value("${app.security.jwt.expiration-minutes:60}")
private long expirationMinutes;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
}
public String generarToken(UserDetails userDetails, Map<String, Object> claimsExtra) {
long ahora = System.currentTimeMillis();
long expiracion = ahora + (expirationMinutes * 60 * 1000);
return Jwts.builder()
.claims(claimsExtra)
.subject(userDetails.getUsername())
.issuedAt(new Date(ahora))
.expiration(new Date(expiracion))
.signWith(getSigningKey())
.compact();
}
public String extraerUsername(String token) {
return extraerClaims(token).getSubject();
}
public boolean esTokenValido(String token, UserDetails userDetails) {
final String username = extraerUsername(token);
return username.equals(userDetails.getUsername())
&& userDetails.isEnabled() && userDetails.isAccountNonLocked()
&& userDetails.isAccountNonExpired() && userDetails.isCredentialsNonExpired()
&& !estaExpirado(token);
}
private boolean estaExpirado(String token) {
return extraerClaims(token).getExpiration().before(new Date());
}
private Claims extraerClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}
Creamos un filtro que intercepta cada petición, extrae la cabecera Authorization: Bearer <token> y puebla el contexto de Spring Security:
package com.ejemplo.gestor.security;
import jakarta.servlet.FilterChain;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
// 1. Si no hay cabecera Bearer, dejamos pasar la petición a los siguientes filtros
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7).trim(); // Extraemos la cadena tras "Bearer "
// 2. Un token caducado o manipulado hace que jjwt lance una excepción.
// Si la dejásemos salir del filtro, el cliente recibiría un 500:
// la capturamos, no autenticamos a nadie y dejamos que la cadena
// siga hasta el AuthenticationEntryPoint, que responderá 401.
final String username;
try {
username = jwtService.extraerUsername(jwt);
} catch (JwtException | IllegalArgumentException ex) {
filterChain.doFilter(request, response);
return;
}
// 3. Si el token tiene usuario y no está autenticado previamente en el contexto
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
try {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtService.esTokenValido(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 4. Establecemos la identidad verificada en el contexto de la petición
SecurityContextHolder.getContext().setAuthentication(authToken);
}
} catch (org.springframework.security.core.userdetails.UsernameNotFoundException
| JwtException | IllegalArgumentException ex) {
SecurityContextHolder.clearContext();
}
}
filterChain.doFilter(request, response);
}
}
En SecurityConfig, registramos el filtro antes del filtro de usuario/contraseña y declaramos la API como estrictamente sin estado:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtAuthFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
Paso 3 · Implementar el login y comprobar el token
Configuración al ejecutar pruebas. La clave también es necesaria para los tests que arrancan el contexto completo. Ejecuta Maven desde una terminal con JWT_SECRET configurada, o proporciona una clave ficticia exclusiva del perfil test. Mantén las credenciales reales fuera del repositorio. Si aparece «Could not resolve placeholder», revisa primero qué entorno está usando ese proceso.
Primero construiremos el acceso que falta entre la contraseña y el token. Un AuthenticationManager comprueba las credenciales usando el UserDetailsService y el PasswordEncoder de las sesiones anteriores. JwtService firma el token solo después de esa comprobación.
- Añade a
application.propertieslas dos propiedades siguientes. La primera lee una variable del entorno; la clave no se escribe en el fichero ni se sube a GitHub.
app.security.jwt.secret=${JWT_SECRET}
app.security.jwt.expiration-minutes=60
En una terminal PowerShell genera una clave aleatoria para desarrollo y arranca el backend desde esa misma terminal. El IDE no hereda variables de una terminal que ya estaba abierta: si arrancas desde el IDE, configura allí JWT_SECRET con un valor de desarrollo propio.
$jwtBytes = New-Object byte[] 32
$jwtRandom = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$jwtRandom.GetBytes($jwtBytes)
$env:JWT_SECRET = [Convert]::ToBase64String($jwtBytes)
$jwtRandom.Dispose()
.\mvnw.cmd spring-boot:run
El JwtService del paso anterior usa los bytes UTF-8 de esta cadena aleatoria como clave. Mantén el mismo valor durante las pruebas; cambiarlo invalida las firmas de los tokens anteriores.
- Crea
config/AuthenticationConfig.java. Este bean permite que el controlador de acceso utilice la configuración de autenticación ya existente. El segundo bean evita registrar el filtro JWT dos veces: debe ejecutarse dentro de la cadena de seguridad, en la posición del paso anterior.
package com.ejemplo.gestor.config;
import com.ejemplo.gestor.security.JwtAuthenticationFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
@Configuration
public class AuthenticationConfig {
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
@Bean
public FilterRegistrationBean<JwtAuthenticationFilter> jwtSoloEnSecurity(
JwtAuthenticationFilter filtro) {
var registro = new FilterRegistrationBean<>(filtro);
registro.setEnabled(false);
return registro;
}
}
- Crea
controller/AuthController.javacon este contenido. Los dos records pequeños se declaran dentro del controlador para que puedas copiar el archivo completo; si ya tienes DTO de acceso, reutilízalos en lugar de mantener dos contratos.
package com.ejemplo.gestor.controller;
import com.ejemplo.gestor.security.JwtService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
private final long minutos;
public AuthController(AuthenticationManager authenticationManager, JwtService jwtService,
@Value("${app.security.jwt.expiration-minutes:60}") long minutos) {
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
this.minutos = minutos;
}
public record LoginRequest(@NotBlank String username, @NotBlank String password) {}
public record LoginResponse(String token, String tipo, long expiraEnMinutos) {}
@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginRequest entrada) {
var autenticacion = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
entrada.username(), entrada.password()));
var usuario = (UserDetails) autenticacion.getPrincipal();
return new LoginResponse(jwtService.generarToken(usuario, Map.of()), "Bearer", minutos);
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ProblemDetail> accesoRechazado(AuthenticationException ex) {
var problema = ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED, "No se ha podido iniciar sesión con esas credenciales");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(problema);
}
}
- En la misma cadena SecurityFilterChain del paso 2, conserva los permisos de la sesión 38 y configura la respuesta de acceso rechazado antes de
http.build(). La ruta/api/v1/auth/logindebe estar enpermitAll(). Importaorg.springframework.http.HttpStatus.
http.exceptionHandling(errores -> errores
.authenticationEntryPoint((peticion, respuesta, error) ->
respuesta.sendError(HttpStatus.UNAUTHORIZED.value()))
.accessDeniedHandler((peticion, respuesta, error) ->
respuesta.sendError(HttpStatus.FORBIDDEN.value())));
Esta configuración distingue la falta de identidad (401) de una identidad sin permisos (403). Consérvala al integrar CORS en la sesión 40.
- En Bruno prepara
POST /api/v1/auth/login, sin autenticación, con body JSON{"username":"tu-usuario-de-prueba","password":"su-contraseña-ficticia"}. Utiliza una cuenta persistida y activa de la sesión 37. Espera 200 y los campostoken,tipoyexpiraEnMinutos. Repite con contraseña incorrecta (401) y campo vacío (400). Ninguna respuesta debe incluir el hash. - Guarda el token válido como variable local de la colección. En una petición protegida añade
Authorization: Bearer {{token}}. Comprueba acceso permitido y después repite como usuario con rol insuficiente. Un token válido no concede permisos adicionales. - Para observar el contenido del token de prueba, abre la consola del navegador y ejecuta el bloque siguiente. Solo decodifica: la validación de la firma corresponde al servidor. No publiques el token en la documentación.
const tokenDePrueba = prompt('Token de la cuenta ficticia de desarrollo');
const parte = tokenDePrueba.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(parte.padEnd(Math.ceil(parte.length / 4) * 4, '=')), c => c.charCodeAt(0));
JSON.parse(new TextDecoder().decode(bytes));
Localiza sub (identidad), iat (emisión) y exp (caducidad). Modifica un carácter del token y envíalo de nuevo al backend: debe rechazarse con 401. Guarda en el registro de la sesión los estados observados, sin credenciales ni tokens.
Paso 4 · Guardar y usar el token en el cliente de la UD8
Añade el formulario de acceso al cliente y, en su evento submit, envía username y contraseña al login. Comprueba el estado antes de leer el token y guarda solo una respuesta correcta. Centraliza las peticiones protegidas en una función que añada Authorization cuando exista token; no envíes Bearer null. Al recibir 401, muestra que debe iniciar sesión otra vez; un 403 indica que la identidad no tiene permiso. El botón de salida elimina el token del cliente, pero no invalida por sí mismo una copia que conserve otro cliente.
Paso 5 · Comprobar y registrar el resultado del proyecto
- Obtén un token válido y úsalo en una ruta protegida. Prueba después uno alterado, otro caducado y la ausencia de token; ninguno debe dar acceso.
- Repite los casos de roles y propiedad. Cambiar el mecanismo de autenticación no debe ampliar los permisos de ningún usuario.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · El dilema de la revocación de tokens
Un empleado con acceso de Administrador es despedido a las 11:00. Su token JWT expira a las 12:00.
- Durante 60 minutos, ese token sigue siendo criptográficamente válido ante cualquier servidor del mundo.
Investiga las tres estrategias de la industria para mitigar este problema:
- Tokens de vida corta (Short-lived Access Tokens de 10 minutos) + Tokens de refresco (Refresh Tokens de 7 días) almacenados en base de datos.
- Listas negras de revocación en Redis (Token Blacklisting).
- Compara el coste de cada enfoque frente a la sencillez de una sesión clásica con cookies.
Formato de entrega
Incluye esta explicación en el registro de la sesión dentro del repositorio de GitHub, junto al código y las comprobaciones. La entrega es el enlace al repositorio y al commit de la sesión.
JwtService implementado, token generado con clave segura y decodificado en jwt.io.JwtAuthenticationFilter integrado en SecurityFilterChain con política STATELESS y probado en Bruno.Ver respuestas
1 · Porque el Payload no está cifrado, solo está codificado en Base64Url; cualquier intermediario o usuario puede decodificarlo y leer su contenido de inmediato.
2 · Porque recalcula la firma criptográfica con su clave secreta y los datos recibidos; si los datos cambiaron, la firma calculada no coincidirá con la del token y será rechazado.
3 · Que Spring Security nunca creará ni utilizará un objeto HttpSession en la memoria del servidor para almacenar el contexto de seguridad entre peticiones.
4 · Authorization: Bearer <cadena-del-token>.
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
La API rechaza tokens inválidos y conserva las comprobaciones de permisos sobre cada recurso.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.