Saltar al contenido principal

Capítulo 3: JWT (JSON Web Tokens)

Introducción​

Las sesiones son perfectas para las aplicaciones web tradicionales, pero las API REST necesitan ser stateless (sin estado). JWT (JSON Web Token) es la solución estándar para autenticar las solicitudes API sin almacenar sesión en el lado del servidor.


🎯 Objetivos del Capítulo​

Al final de este capítulo, usted sabrá:

  1. ✅ Comprender el funcionamiento de JWT
  2. ✅ Decodificar la estructura de un token
  3. ✅ Generar y validar tokens
  4. ✅ Implementar un filtro de autenticación JWT
  5. ✅ Gestionar los refresh tokens

1. ¿Por qué JWT?​

Sesión vs JWT​

AspectoSesiónJWT
AlmacenamientoServidor (memoria/BDD)Cliente (localStorage/cookie)
EscalabilidadDifícil (sticky sessions)Fácil (stateless)
MóvilComplicadoPerfecto
MicroserviciosProblemáticoIdeal

Flujo JWT​


2. Estructura de un JWT​

Las 3 Partes​

Un JWT está compuesto por 3 partes separadas por puntos:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
└──────────── HEADER ──────────────────┴────────────────────── PAYLOAD ────────────────────────────────┴── SIGNATURE ──

Header (Encabezado)​

{
"alg": "HS256", // Algoritmo de firma
"typ": "JWT" // Tipo de token
}

Codificado en Base64: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Payload (Carga Útil)​

{
"sub": "1234567890", // Subject (ID usuario)
"name": "John Doe", // Claims personalizadas
"email": "john@example.com",
"roles": ["ROLE_USER"],
"iat": 1516239022, // Issued At (timestamp)
"exp": 1516242622 // Expiración (timestamp)
}

Firma​

HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
aviso

El payload está codificado, ¡no cifrado! Nunca ponga datos sensibles (contraseña, tarjeta bancaria...) allí.


3. Implementación con Spring Boot​

3.1 Dependencias​

<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- JJWT (Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
</dependencies>

3.2 Configuración​

# application.properties
jwt.secret=suClaveSecretaMuyLargaYComplejaConAlMenos256Bits
jwt.expiration=86400000
jwt.refresh-expiration=604800000
@Configuration
@ConfigurationProperties(prefix = "jwt")
@Data
public class JwtProperties {
private String secret;
private long expiration; // 24h en ms
private long refreshExpiration; // 7 días en ms
}

3.3 JwtService​

@Service
@RequiredArgsConstructor
public class JwtService {

private final JwtProperties jwtProperties;

private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecret());
return Keys.hmacShaKeyFor(keyBytes);
}

// Generar un Access Token
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}

public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration()))
.signWith(getSigningKey())
.compact();
}

// Generar un Refresh Token
public String generateRefreshToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshExpiration()))
.signWith(getSigningKey())
.compact();
}

// Extraer el username
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}

// Extraer un claim
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}

private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}

// Validar el token
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
}

private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}

private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
}

3.4 JwtAuthenticationFilter​

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtService jwtService;
private final UserDetailsService userDetailsService;

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {

// Recuperar el header Authorization
final String authHeader = request.getHeader("Authorization");

// Verificar el formato Bearer
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}

// Extraer el token
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);

// Verificar si no está ya autenticado
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

// Cargar el usuario
UserDetails userDetails = userDetailsService.loadUserByUsername(username);

// Validar el token
if (jwtService.isTokenValid(jwt, userDetails)) {

// Crear la autenticación
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);

authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

// Almacenar en el SecurityContext
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}

filterChain.doFilter(request, response);
}
}

3.5 SecurityConfig para JWT​

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Desactivar CSRF para API REST
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}

@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
}

4. Controlador de Autenticación​

DTOs​

// Solicitud de login
@Data
public class LoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
}

// Solicitud de registro
@Data
public class RegisterRequest {
@NotBlank
@Size(min = 3, max = 50)
private String username;

@NotBlank
@Email
private String email;

@NotBlank
@Size(min = 6)
private String password;
}

// Respuesta con tokens
@Data
@Builder
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String username;
private List<String> roles;
}

// Solicitud de refresh
@Data
public class RefreshTokenRequest {
@NotBlank
private String refreshToken;
}

AuthController​

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtService jwtService;
private final UserService userService;

@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
// Autenticar
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);

// Cargar el usuario
UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());

// Generar los tokens
String accessToken = jwtService.generateToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);

return ResponseEntity.ok(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.username(userDetails.getUsername())
.roles(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.build());
}

@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
// Crear el usuario
User user = userService.registerUser(
request.getUsername(),
request.getEmail(),
request.getPassword()
);

// Cargar los UserDetails
UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername());

// Generar los tokens
String accessToken = jwtService.generateToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);

return ResponseEntity.status(HttpStatus.CREATED)
.body(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.username(user.getUsername())
.roles(List.of("ROLE_USER"))
.build());
}

@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
String username = jwtService.extractUsername(request.getRefreshToken());
UserDetails userDetails = userDetailsService.loadUserByUsername(username);

if (jwtService.isTokenValid(request.getRefreshToken(), userDetails)) {
String accessToken = jwtService.generateToken(userDetails);

return ResponseEntity.ok(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(request.getRefreshToken()) // Reutilizar
.username(username)
.roles(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.build());
}

return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}

5. Prueba con cURL​

Login​

# Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"password"}'

# Respuesta:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"username": "user",
"roles": ["ROLE_USER"]
}

Acceso Protegido​

# Sin token
curl http://localhost:8080/api/users
# 401 Unauthorized

# Con token
curl http://localhost:8080/api/users \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
# 200 OK

Refresh Token​

curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"eyJhbGciOiJIUzI1NiJ9..."}'

6. Buenas Prácticas​

Almacenamiento del Token (Frontend)​

// ✅ httpOnly Cookie (lo más seguro)
// Gestionado por el servidor

// ⚠️ localStorage (vulnerable a XSS)
localStorage.setItem('token', response.accessToken);

// ⚠️ sessionStorage (mejor que localStorage)
sessionStorage.setItem('token', response.accessToken);

Duración de Vida​

Access Token:  15 minutos - 1 hora (corto)
Refresh Token: 7 días - 30 días (largo)

Revocación​

// Opción 1: Blacklist en BDD
@Entity
public class RevokedToken {
private String tokenId;
private LocalDateTime revokedAt;
}

// Opción 2: Versión de usuario
// Cambiar user.tokenVersion invalida todos los tokens antiguos

7. Resumen​

Estructura JWT​

HEADER.PAYLOAD.SIGNATURE

Componentes Clave​

ComponenteRol
JwtServiceGenera y valida los tokens
JwtAuthenticationFilterIntercepta y procesa los tokens
AuthControllerEndpoints login/register/refresh

Flujo Completo​

1. POST /api/auth/login → Access + Refresh tokens
2. GET /api/data (Bearer token) → Datos
3. Token expirado → POST /api/auth/refresh → Nuevo Access token

🎯 Próximo Paso​

JWT es perfecto para la autenticación directa, pero a veces se quiere delegar la autenticación a un tercero (Google, GitHub...). Ese es el rol de OAuth2.

➡️ Capítulo 4: OAuth2 y Login Social


⬅️ Volver al Módulo 8