Curso JWT en Spring Boot 3 #4: Claims Personalizados, RBAC y Seguridad en Producción

Parte 4 de 4 — Seguridad en Producción

Fuente: Adi Goldstein — Unsplash
Bienvenido a la Parte 4 y final del Curso JWT en Spring Boot 3. A lo largo de este curso hemos construido un sistema de autenticación completo paso a paso:
- Parte 1: Fundamentos de JWT y configuración de Spring Security con filtro de autenticación
- Parte 2: Refresh tokens con rotación segura y cookies HttpOnly
- Parte 3: JTI, blacklisting con Redis y logout real
- Parte 4 (este artículo): Claims personalizados, RBAC, algoritmos de firma, seguridad OWASP y hardening para producción
En este artículo final vamos a elevar nuestro sistema de autenticación al nivel que exige un entorno de producción real. No basta con generar y validar tokens: necesitamos control granular de permisos, algoritmos de firma robustos, protección contra ataques conocidos y auditoría completa.
Al finalizar este artículo serás capaz de:
- Agregar claims personalizados al JWT (roles, permisos, tenant)
- Implementar RBAC completo con entidades Role y Permission
- Usar
@PreAuthorizecon expresiones basadas en claims JWT - Elegir el algoritmo de firma correcto para tu arquitectura
- Aplicar las recomendaciones de seguridad OWASP para tokens JWT
- Proteger endpoints de autenticación con rate limiting
- Implementar auditoría y logging de seguridad
- Escribir tests completos para toda la capa de seguridad
- Configurar perfiles por ambiente (dev, qa, prod)
Claims Personalizados en JWT
Hasta ahora nuestro JWT contiene claims estándar: sub (subject/email), iat (issued at), exp (expiration). Pero en una aplicación real necesitamos transportar información adicional para tomar decisiones de autorización sin consultar la base de datos en cada petición.
Los claims personalizados más comunes son:
- roles: Lista de roles del usuario (ADMIN, USER, MODERATOR)
- permissions: Lista de permisos específicos (USER_CREATE, REPORT_VIEW)
- tenantId: Identificador del inquilino en aplicaciones multi-tenant
- userId: ID interno del usuario
- fullName: Nombre para mostrar en el frontend sin consulta extra
Veamos cómo agregar claims personalizados con JJWT 0.12:
1@Service
2@RequiredArgsConstructor
3public class JwtService {
4
5 @Value("${jwt.secret}")
6 private String secretKey;
7
8 @Value("${jwt.access-token-expiration}")
9 private long accessTokenExpiration;
10
11 public String generateToken(UserDetails userDetails, Map<String, Object> extraClaims) {
12 return Jwts.builder()
13 .subject(userDetails.getUsername())
14 .issuedAt(new Date())
15 .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
16 .claim("roles", extractRoles(userDetails))
17 .claim("permissions", extractPermissions(userDetails))
18 .claim("userId", ((CustomUserDetails) userDetails).getId())
19 .claim("tenantId", ((CustomUserDetails) userDetails).getTenantId())
20 .claims(extraClaims)
21 .signWith(getSigningKey(), Jwts.SIG.HS256)
22 .compact();
23 }
24
25 public <T> T extractClaim(String token, String claimName, Class<T> type) {
26 Claims claims = extractAllClaims(token);
27 return claims.get(claimName, type);
28 }
29
30 @SuppressWarnings("unchecked")
31 public List<String> extractRoles(String token) {
32 Claims claims = extractAllClaims(token);
33 return claims.get("roles", List.class);
34 }
35
36 @SuppressWarnings("unchecked")
37 public List<String> extractPermissions(String token) {
38 Claims claims = extractAllClaims(token);
39 return claims.get("permissions", List.class);
40 }
41
42 private List<String> extractRoles(UserDetails userDetails) {
43 return userDetails.getAuthorities().stream()
44 .map(GrantedAuthority::getAuthority)
45 .filter(a -> a.startsWith("ROLE_"))
46 .collect(Collectors.toList());
47 }
48
49 private List<String> extractPermissions(UserDetails userDetails) {
50 return userDetails.getAuthorities().stream()
51 .map(GrantedAuthority::getAuthority)
52 .filter(a -> !a.startsWith("ROLE_"))
53 .collect(Collectors.toList());
54 }
55
56 private Claims extractAllClaims(String token) {
57 return Jwts.parser()
58 .verifyWith(getSigningKey())
59 .build()
60 .parseSignedClaims(token)
61 .getPayload();
62 }
63
64 private SecretKey getSigningKey() {
65 byte[] keyBytes = Decoders.BASE64.decode(secretKey);
66 return Keys.hmacShaKeyFor(keyBytes);
67 }
68}
Implementando RBAC (Role-Based Access Control)
RBAC es el modelo de autorización más utilizado en aplicaciones empresariales. La idea es simple: los usuarios tienen roles, y los roles tienen permisos. El usuario hereda todos los permisos de sus roles.
Vamos a modelar las entidades JPA necesarias:
1@Entity
2@Table(name = "roles")
3@Getter @Setter @NoArgsConstructor
4public class Role {
5
6 @Id
7 @GeneratedValue(strategy = GenerationType.IDENTITY)
8 private Long id;
9
10 @Column(unique = true, nullable = false, length = 50)
11 private String name; // ADMIN, USER, MODERATOR
12
13 @Column(length = 200)
14 private String description;
15
16 @ManyToMany(fetch = FetchType.EAGER)
17 @JoinTable(
18 name = "role_permissions",
19 joinColumns = @JoinColumn(name = "role_id"),
20 inverseJoinColumns = @JoinColumn(name = "permission_id")
21 )
22 private Set<Permission> permissions = new HashSet<>();
23}
24
25@Entity
26@Table(name = "permissions")
27@Getter @Setter @NoArgsConstructor
28public class Permission {
29
30 @Id
31 @GeneratedValue(strategy = GenerationType.IDENTITY)
32 private Long id;
33
34 @Column(unique = true, nullable = false, length = 100)
35 private String name; // USER_CREATE, USER_READ, REPORT_EXPORT
36
37 @Column(length = 200)
38 private String description;
39}
40
41@Entity
42@Table(name = "users")
43@Getter @Setter @NoArgsConstructor
44public class User {
45
46 @Id
47 @GeneratedValue(strategy = GenerationType.IDENTITY)
48 private Long id;
49
50 @Column(unique = true, nullable = false)
51 private String email;
52
53 @Column(nullable = false)
54 private String password;
55
56 private String fullName;
57 private String tenantId;
58 private boolean enabled = true;
59
60 @ManyToMany(fetch = FetchType.EAGER)
61 @JoinTable(
62 name = "user_roles",
63 joinColumns = @JoinColumn(name = "user_id"),
64 inverseJoinColumns = @JoinColumn(name = "role_id")
65 )
66 private Set<Role> roles = new HashSet<>();
67
68 // Método para obtener TODOS los permisos (de todos los roles)
69 public Set<String> getAllPermissions() {
70 return roles.stream()
71 .flatMap(role -> role.getPermissions().stream())
72 .map(Permission::getName)
73 .collect(Collectors.toSet());
74 }
75
76 // Método para obtener los nombres de roles
77 public Set<String> getRoleNames() {
78 return roles.stream()
79 .map(role -> "ROLE_" + role.getName())
80 .collect(Collectors.toSet());
81 }
82}
Ahora necesitamos un UserDetailsService que cargue las authorities desde la base de datos e incluya tanto roles como permisos:
1@Service
2@RequiredArgsConstructor
3public class CustomUserDetailsService implements UserDetailsService {
4
5 private final UserRepository userRepository;
6
7 @Override
8 public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
9 User user = userRepository.findByEmail(email)
10 .orElseThrow(() -> new UsernameNotFoundException(
11 "Usuario no encontrado: " + email));
12
13 // Combinar roles y permisos como GrantedAuthority
14 Set<GrantedAuthority> authorities = new HashSet<>();
15
16 // Agregar roles con prefijo ROLE_
17 user.getRoleNames().forEach(role ->
18 authorities.add(new SimpleGrantedAuthority(role)));
19
20 // Agregar permisos individuales
21 user.getAllPermissions().forEach(permission ->
22 authorities.add(new SimpleGrantedAuthority(permission)));
23
24 return new CustomUserDetails(
25 user.getId(),
26 user.getEmail(),
27 user.getPassword(),
28 user.getFullName(),
29 user.getTenantId(),
30 user.isEnabled(),
31 authorities
32 );
33 }
34}
FetchType.EAGER porque siempre los necesitamos al autenticar al usuario. Si tu tabla de permisos es muy grande, considera usar una caché (Caffeine o Redis) para evitar joins costosos en cada login.
@PreAuthorize con Claims JWT
Spring Security permite proteger métodos individuales con @PreAuthorize. Esto es mucho más granular que proteger rutas completas en SecurityFilterChain.
Primero, habilitamos la seguridad a nivel de método en nuestra configuración:
1@Configuration
2@EnableWebSecurity
3@EnableMethodSecurity(prePostEnabled = true)
4@RequiredArgsConstructor
5public class SecurityConfig {
6
7 private final JwtAuthenticationFilter jwtAuthFilter;
8 private final CustomUserDetailsService userDetailsService;
9
10 @Bean
11 public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
12 http
13 .csrf(AbstractHttpConfigurer::disable)
14 .sessionManagement(session ->
15 session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
16 .authorizeHttpRequests(auth -> auth
17 .requestMatchers("/auth/**").permitAll()
18 .requestMatchers("/actuator/health").permitAll()
19 .anyRequest().authenticated()
20 )
21 .authenticationProvider(authenticationProvider())
22 .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
23
24 return http.build();
25 }
26
27 @Bean
28 public AuthenticationProvider authenticationProvider() {
29 DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
30 provider.setUserDetailsService(userDetailsService);
31 provider.setPasswordEncoder(passwordEncoder());
32 return provider;
33 }
34
35 @Bean
36 public PasswordEncoder passwordEncoder() {
37 return new BCryptPasswordEncoder();
38 }
39}
Ahora podemos usar @PreAuthorize en nuestros controllers:
1@RestController
2@RequestMapping("/api/users")
3@RequiredArgsConstructor
4public class UserController {
5
6 private final UserService userService;
7
8 @GetMapping
9 @PreAuthorize("hasAuthority('USER_READ')")
10 public ResponseEntity<List<UserDto>> getAllUsers() {
11 return ResponseEntity.ok(userService.findAll());
12 }
13
14 @PostMapping
15 @PreAuthorize("hasAuthority('USER_CREATE')")
16 public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
17 return ResponseEntity.status(HttpStatus.CREATED)
18 .body(userService.create(request));
19 }
20
21 @DeleteMapping("/{id}")
22 @PreAuthorize("hasRole('ADMIN')")
23 public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
24 userService.delete(id);
25 return ResponseEntity.noContent().build();
26 }
27
28 @PutMapping("/{id}")
29 @PreAuthorize("hasAuthority('USER_UPDATE') or #id == authentication.principal.id")
30 public ResponseEntity<UserDto> updateUser(
31 @PathVariable Long id,
32 @Valid @RequestBody UpdateUserRequest request) {
33 return ResponseEntity.ok(userService.update(id, request));
34 }
35}
36
37@RestController
38@RequestMapping("/api/reports")
39@RequiredArgsConstructor
40public class ReportController {
41
42 private final ReportService reportService;
43
44 @GetMapping
45 @PreAuthorize("hasAnyAuthority('REPORT_VIEW', 'REPORT_EXPORT')")
46 public ResponseEntity<List<ReportDto>> listReports() {
47 return ResponseEntity.ok(reportService.findAll());
48 }
49
50 @PostMapping("/export")
51 @PreAuthorize("hasAuthority('REPORT_EXPORT') and hasRole('ADMIN')")
52 public ResponseEntity<byte[]> exportReport(@RequestBody ExportRequest request) {
53 return ResponseEntity.ok(reportService.export(request));
54 }
55}
hasRole vs hasAuthority: Spring Security agrega automáticamente el prefijo ROLE_ cuando usas hasRole('ADMIN'), por lo que busca la authority ROLE_ADMIN. En cambio, hasAuthority('USER_CREATE') busca el string exacto. Asegúrate de que tus roles tengan el prefijo al construir las authorities.
También necesitamos actualizar nuestro JwtAuthenticationFilter para reconstruir las authorities desde los claims del JWT:
1@Component
2@RequiredArgsConstructor
3public class JwtAuthenticationFilter extends OncePerRequestFilter {
4
5 private final JwtService jwtService;
6
7 @Override
8 protected void doFilterInternal(
9 HttpServletRequest request,
10 HttpServletResponse response,
11 FilterChain filterChain) throws ServletException, IOException {
12
13 String authHeader = request.getHeader("Authorization");
14 if (authHeader == null || !authHeader.startsWith("Bearer ")) {
15 filterChain.doFilter(request, response);
16 return;
17 }
18
19 String jwt = authHeader.substring(7);
20 String username = jwtService.extractUsername(jwt);
21
22 if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
23 if (jwtService.isTokenValid(jwt)) {
24 // Reconstruir authorities desde los claims del token
25 List<String> roles = jwtService.extractRoles(jwt);
26 List<String> permissions = jwtService.extractPermissions(jwt);
27
28 List<GrantedAuthority> authorities = new ArrayList<>();
29 roles.forEach(r -> authorities.add(new SimpleGrantedAuthority(r)));
30 permissions.forEach(p -> authorities.add(new SimpleGrantedAuthority(p)));
31
32 UsernamePasswordAuthenticationToken authToken =
33 new UsernamePasswordAuthenticationToken(username, null, authorities);
34 authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
35
36 SecurityContextHolder.getContext().setAuthentication(authToken);
37 }
38 }
39
40 filterChain.doFilter(request, response);
41 }
42}
Algoritmos de Firma: HMAC vs RSA vs ECDSA
El algoritmo de firma es una de las decisiones más importantes al implementar JWT en producción. Define cómo se garantiza la integridad y autenticidad del token.
| Característica | HMAC (HS256) | RSA (RS256) | ECDSA (ES256) |
|---|---|---|---|
| Tipo | Simétrico | Asimétrico | Asimétrico |
| Claves | Una clave compartida | Par público/privado | Par público/privado |
| Tamaño de firma | 32 bytes | 256 bytes | 64 bytes |
| Rendimiento firma | Muy rápido | Lento | Moderado |
| Rendimiento verificación | Muy rápido | Rápido | Moderado |
| Caso de uso ideal | Monolito | Microservicios | IoT, móviles |
| Tamaño de clave | 256 bits | 2048-4096 bits | 256 bits |
¿Cuándo usar cada uno?
- HS256: Perfecto para monolitos donde el mismo servicio genera y valida los tokens. Simple de configurar y muy rápido.
- RS256: Ideal para microservicios. El servicio de autenticación firma con la clave privada, y los demás servicios validan con la clave pública. Ningún servicio necesita conocer la clave privada.
- ES256: Cuando necesitas firmas más pequeñas (IoT, aplicaciones móviles con ancho de banda limitado) con la seguridad de un algoritmo asimétrico.
Veamos cómo implementar RS256 con un par de claves RSA:
1@Configuration
2public class RsaKeyConfig {
3
4 @Value("${jwt.rsa.private-key-path}")
5 private String privateKeyPath;
6
7 @Value("${jwt.rsa.public-key-path}")
8 private String publicKeyPath;
9
10 @Bean
11 public RSAPrivateKey rsaPrivateKey() throws Exception {
12 String key = Files.readString(Path.of(privateKeyPath))
13 .replace("-----BEGIN PRIVATE KEY-----", "")
14 .replace("-----END PRIVATE KEY-----", "")
15 .replaceAll("\\s", "");
16
17 byte[] keyBytes = Base64.getDecoder().decode(key);
18 PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
19 return (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(spec);
20 }
21
22 @Bean
23 public RSAPublicKey rsaPublicKey() throws Exception {
24 String key = Files.readString(Path.of(publicKeyPath))
25 .replace("-----BEGIN PUBLIC KEY-----", "")
26 .replace("-----END PUBLIC KEY-----", "")
27 .replaceAll("\\s", "");
28
29 byte[] keyBytes = Base64.getDecoder().decode(key);
30 X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes);
31 return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(spec);
32 }
33}
34
35@Service
36@RequiredArgsConstructor
37public class RsaJwtService {
38
39 private final RSAPrivateKey privateKey;
40 private final RSAPublicKey publicKey;
41
42 @Value("${jwt.access-token-expiration}")
43 private long accessTokenExpiration;
44
45 public String generateToken(UserDetails userDetails) {
46 return Jwts.builder()
47 .subject(userDetails.getUsername())
48 .issuedAt(new Date())
49 .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
50 .claim("roles", extractRoles(userDetails))
51 .claim("permissions", extractPermissions(userDetails))
52 .signWith(privateKey, Jwts.SIG.RS256) // Firma con clave privada
53 .compact();
54 }
55
56 public Claims extractAllClaims(String token) {
57 return Jwts.parser()
58 .verifyWith(publicKey) // Verifica con clave pública
59 .build()
60 .parseSignedClaims(token)
61 .getPayload();
62 }
63
64 // ... demás métodos de extracción igual que antes
65}
Para generar las claves RSA, ejecuta estos comandos en tu terminal:
1# Generar clave privada RSA de 2048 bits
2openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
3
4# Extraer la clave pública
5openssl rsa -pubout -in private_key.pem -out public_key.pem
6
7# Convertir clave privada a formato PKCS8 (requerido por Java)
8openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private_key.pem -out private_key_pkcs8.pem
public_key.pem a los servicios que necesitan validar tokens. La clave privada solo debe existir en el servicio de autenticación, idealmente almacenada en un vault (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault).
Seguridad OWASP para JWT
El OWASP (Open Web Application Security Project) proporciona guías esenciales para proteger aplicaciones web. Veamos las vulnerabilidades más relevantes para JWT y cómo prevenirlas.
A02: Fallas Criptográficas — No incluyas datos sensibles en el payload
El payload de un JWT es visible para cualquiera que intercepte el token. Basta con decodificarlo en Base64:
1# Cualquiera puede leer el payload
2echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiUk9MRV9VU0VSIl19.xxx" \
3 | cut -d'.' -f2 | base64 -d
Regla simple: si no te sentirías cómodo publicando esa información en un tweet, no la pongas en el JWT.
A07: Expiración corta — Máximo 60 minutos para access tokens
Cuanto más corta sea la vida del access token, menor es la ventana de ataque si es comprometido. Recomendación:
- Access token: 15 minutos (ideal) a 60 minutos (máximo)
- Refresh token: 7 días (ideal) a 30 días (máximo)
Ataque de confusión de algoritmo (Algorithm Confusion)
Este ataque ocurre cuando el servidor acepta el algoritmo especificado en el header del JWT sin validarlo. Un atacante puede cambiar el header de RS256 a HS256 y usar la clave pública RSA como secreto HMAC.
1// VULNERABLE — acepta cualquier algoritmo del token
2Jwts.parser()
3 .setSigningKey(publicKey)
4 .build()
5 .parseClaimsJws(token);
6
7// SEGURO — fuerza un algoritmo específico
8Jwts.parser()
9 .verifyWith(publicKey) // JJWT 0.12 fuerza la verificación tipada
10 .requireAlgorithm("RS256") // Rechaza cualquier otro algoritmo
11 .build()
12 .parseSignedClaims(token);
"alg": "none". JJWT 0.12 lo rechaza por defecto, pero si usas otra biblioteca, verifica que esté bloqueado explícitamente. Este ataque permite a un atacante crear tokens válidos sin ninguna firma.
Buenas prácticas de gestión de claves
- Nunca hardcodees las claves en el código fuente
- Usa variables de entorno o un vault de secretos
- Rota las claves periódicamente (cada 90 días como mínimo)
- Mantén un período de gracia durante la rotación donde ambas claves son válidas
- Las claves HMAC deben tener al menos 256 bits de entropía
- Las claves RSA deben ser de al menos 2048 bits (preferiblemente 4096)
Rate Limiting en Endpoints de Autenticación
Los endpoints de login y refresh son los objetivos principales de ataques de fuerza bruta. Sin rate limiting, un atacante puede probar miles de contraseñas por segundo.
Vamos a implementar rate limiting con Bucket4j y Spring Boot:
1<!-- pom.xml -->
2<dependency>
3 <groupId>com.bucket4j</groupId>
4 <artifactId>bucket4j-core</artifactId>
5 <version>8.7.0</version>
6</dependency>
1@Component
2public class RateLimitFilter extends OncePerRequestFilter {
3
4 private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
5
6 private static final long LOGIN_CAPACITY = 5; // 5 intentos
7 private static final long LOGIN_REFILL_MINUTES = 15; // cada 15 minutos
8 private static final long REFRESH_CAPACITY = 10; // 10 refreshes
9 private static final long REFRESH_REFILL_MINUTES = 5; // cada 5 minutos
10
11 @Override
12 protected void doFilterInternal(
13 HttpServletRequest request,
14 HttpServletResponse response,
15 FilterChain filterChain) throws ServletException, IOException {
16
17 String path = request.getRequestURI();
18
19 if ("/auth/login".equals(path) && "POST".equals(request.getMethod())) {
20 String clientIp = getClientIp(request);
21 Bucket bucket = buckets.computeIfAbsent(
22 "login:" + clientIp, k -> createLoginBucket());
23
24 if (!bucket.tryConsume(1)) {
25 response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
26 response.setContentType(MediaType.APPLICATION_JSON_VALUE);
27 response.getWriter().write(
28 "{"error":"Demasiados intentos de inicio de sesión. "
29 + "Intenta de nuevo en 15 minutos."}");
30 return;
31 }
32 }
33
34 if ("/auth/refresh".equals(path) && "POST".equals(request.getMethod())) {
35 String clientIp = getClientIp(request);
36 Bucket bucket = buckets.computeIfAbsent(
37 "refresh:" + clientIp, k -> createRefreshBucket());
38
39 if (!bucket.tryConsume(1)) {
40 response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
41 response.setContentType(MediaType.APPLICATION_JSON_VALUE);
42 response.getWriter().write(
43 "{"error":"Demasiadas solicitudes de refresh. "
44 + "Intenta de nuevo en 5 minutos."}");
45 return;
46 }
47 }
48
49 filterChain.doFilter(request, response);
50 }
51
52 private Bucket createLoginBucket() {
53 return Bucket.builder()
54 .addLimit(Bandwidth.classic(
55 LOGIN_CAPACITY,
56 Refill.intervally(LOGIN_CAPACITY,
57 Duration.ofMinutes(LOGIN_REFILL_MINUTES))))
58 .build();
59 }
60
61 private Bucket createRefreshBucket() {
62 return Bucket.builder()
63 .addLimit(Bandwidth.classic(
64 REFRESH_CAPACITY,
65 Refill.intervally(REFRESH_CAPACITY,
66 Duration.ofMinutes(REFRESH_REFILL_MINUTES))))
67 .build();
68 }
69
70 private String getClientIp(HttpServletRequest request) {
71 String xForwardedFor = request.getHeader("X-Forwarded-For");
72 if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
73 return xForwardedFor.split(",")[0].trim();
74 }
75 return request.getRemoteAddr();
76 }
77
78 @Override
79 protected boolean shouldNotFilter(HttpServletRequest request) {
80 String path = request.getRequestURI();
81 return !path.startsWith("/auth/");
82 }
83}
bucket4j-redis.
Auditoría y Logging de Seguridad
En producción necesitas saber quién hizo qué, cuándo y desde dónde. Un buen sistema de auditoría te permite detectar actividades sospechosas y responder a incidentes de seguridad.
Implementemos un servicio de auditoría que use MDC (Mapped Diagnostic Context) para correlacionar todos los logs de una misma petición:
1@Service
2@Slf4j
3public class SecurityAuditService {
4
5 public void logLoginSuccess(String email, String ipAddress, String userAgent) {
6 MDC.put("action", "LOGIN_SUCCESS");
7 MDC.put("userEmail", email);
8 MDC.put("ipAddress", ipAddress);
9 MDC.put("userAgent", userAgent);
10 MDC.put("transactionId", UUID.randomUUID().toString());
11
12 log.info("Inicio de sesión exitoso para el usuario: {}", email);
13
14 MDC.clear();
15 }
16
17 public void logLoginFailure(String email, String ipAddress, String reason) {
18 MDC.put("action", "LOGIN_FAILURE");
19 MDC.put("userEmail", email);
20 MDC.put("ipAddress", ipAddress);
21 MDC.put("reason", reason);
22 MDC.put("transactionId", UUID.randomUUID().toString());
23
24 log.warn("Intento de inicio de sesión fallido para: {} — Razón: {}", email, reason);
25
26 MDC.clear();
27 }
28
29 public void logTokenRefresh(String email, String ipAddress) {
30 MDC.put("action", "TOKEN_REFRESH");
31 MDC.put("userEmail", email);
32 MDC.put("ipAddress", ipAddress);
33 MDC.put("transactionId", UUID.randomUUID().toString());
34
35 log.info("Token renovado para el usuario: {}", email);
36
37 MDC.clear();
38 }
39
40 public void logLogout(String email, String ipAddress) {
41 MDC.put("action", "LOGOUT");
42 MDC.put("userEmail", email);
43 MDC.put("ipAddress", ipAddress);
44 MDC.put("transactionId", UUID.randomUUID().toString());
45
46 log.info("Cierre de sesión para el usuario: {}", email);
47
48 MDC.clear();
49 }
50
51 public void logAccessDenied(String email, String resource, String requiredPermission) {
52 MDC.put("action", "ACCESS_DENIED");
53 MDC.put("userEmail", email);
54 MDC.put("resource", resource);
55 MDC.put("requiredPermission", requiredPermission);
56 MDC.put("transactionId", UUID.randomUUID().toString());
57
58 log.warn("Acceso denegado — Usuario: {}, Recurso: {}, Permiso requerido: {}",
59 email, resource, requiredPermission);
60
61 MDC.clear();
62 }
63
64 public void logSuspiciousActivity(String email, String ipAddress, String detail) {
65 MDC.put("action", "SUSPICIOUS_ACTIVITY");
66 MDC.put("userEmail", email);
67 MDC.put("ipAddress", ipAddress);
68 MDC.put("transactionId", UUID.randomUUID().toString());
69
70 log.error("Actividad sospechosa detectada — Usuario: {}, IP: {}, Detalle: {}",
71 email, ipAddress, detail);
72
73 MDC.clear();
74 }
75}
Configura Logback para incluir los campos MDC en formato JSON (ideal para herramientas como ELK Stack o Grafana Loki):
1<!-- logback-spring.xml -->
2<configuration>
3 <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
4 <file>logs/security-audit.log</file>
5 <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
6 <fileNamePattern>logs/security-audit.%d{yyyy-MM-dd}.log</fileNamePattern>
7 <maxHistory>90</maxHistory>
8 </rollingPolicy>
9 <encoder class="net.logstash.logback.encoder.LogstashEncoder">
10 <includeMdcKeyName>action</includeMdcKeyName>
11 <includeMdcKeyName>userEmail</includeMdcKeyName>
12 <includeMdcKeyName>ipAddress</includeMdcKeyName>
13 <includeMdcKeyName>transactionId</includeMdcKeyName>
14 <includeMdcKeyName>resource</includeMdcKeyName>
15 <includeMdcKeyName>reason</includeMdcKeyName>
16 </encoder>
17 </appender>
18
19 <logger name="com.tuapp.security" level="INFO" additivity="false">
20 <appender-ref ref="JSON_FILE" />
21 </logger>
22</configuration>
transactionId permite correlacionar todos los logs de una misma operación. En una arquitectura de microservicios, propaga este ID a través de headers HTTP (X-Transaction-Id) para tener trazabilidad end-to-end.
Testing Completo de Seguridad JWT
Un sistema de seguridad sin tests es una bomba de tiempo. Vamos a escribir tests que cubran los escenarios más críticos usando JUnit 5 y MockMvc.
1@WebMvcTest(UserController.class)
2@Import({SecurityConfig.class, JwtService.class})
3class UserControllerSecurityTest {
4
5 @Autowired
6 private MockMvc mockMvc;
7
8 @MockBean
9 private UserService userService;
10
11 @MockBean
12 private CustomUserDetailsService userDetailsService;
13
14 @Autowired
15 private JwtService jwtService;
16
17 private String adminToken;
18 private String userToken;
19
20 @BeforeEach
21 void setUp() {
22 // Crear token con rol ADMIN y permiso USER_CREATE
23 List<GrantedAuthority> adminAuthorities = List.of(
24 new SimpleGrantedAuthority("ROLE_ADMIN"),
25 new SimpleGrantedAuthority("USER_CREATE"),
26 new SimpleGrantedAuthority("USER_READ"),
27 new SimpleGrantedAuthority("USER_UPDATE")
28 );
29 UserDetails adminDetails = new User("[email protected]", "password", adminAuthorities);
30 adminToken = jwtService.generateToken(adminDetails, Map.of());
31
32 // Crear token con rol USER solamente
33 List<GrantedAuthority> userAuthorities = List.of(
34 new SimpleGrantedAuthority("ROLE_USER"),
35 new SimpleGrantedAuthority("USER_READ")
36 );
37 UserDetails userDetails = new User("[email protected]", "password", userAuthorities);
38 userToken = jwtService.generateToken(userDetails, Map.of());
39 }
40
41 @Test
42 void getAllUsers_conPermisoUserRead_retorna200() throws Exception {
43 // Arrange
44 when(userService.findAll()).thenReturn(List.of());
45
46 // Act & Assert
47 mockMvc.perform(get("/api/users")
48 .header("Authorization", "Bearer " + userToken))
49 .andExpect(status().isOk());
50 }
51
52 @Test
53 void createUser_sinPermisoUserCreate_retorna403() throws Exception {
54 // Act & Assert (userToken no tiene USER_CREATE)
55 mockMvc.perform(post("/api/users")
56 .header("Authorization", "Bearer " + userToken)
57 .contentType(MediaType.APPLICATION_JSON)
58 .content("{\"email\":\"[email protected]\",\"password\":\"Pass1234!\"}"))
59 .andExpect(status().isForbidden());
60 }
61
62 @Test
63 void createUser_conPermisoUserCreate_retorna201() throws Exception {
64 // Arrange
65 UserDto dto = new UserDto(1L, "[email protected]", "Nuevo Usuario");
66 when(userService.create(any())).thenReturn(dto);
67
68 // Act & Assert
69 mockMvc.perform(post("/api/users")
70 .header("Authorization", "Bearer " + adminToken)
71 .contentType(MediaType.APPLICATION_JSON)
72 .content("{\"email\":\"[email protected]\",\"password\":\"Pass1234!\"}"))
73 .andExpect(status().isCreated());
74 }
75
76 @Test
77 void deleteUser_sinRolAdmin_retorna403() throws Exception {
78 // Act & Assert
79 mockMvc.perform(delete("/api/users/1")
80 .header("Authorization", "Bearer " + userToken))
81 .andExpect(status().isForbidden());
82 }
83
84 @Test
85 void deleteUser_conRolAdmin_retorna204() throws Exception {
86 // Arrange
87 doNothing().when(userService).delete(1L);
88
89 // Act & Assert
90 mockMvc.perform(delete("/api/users/1")
91 .header("Authorization", "Bearer " + adminToken))
92 .andExpect(status().isNoContent());
93 }
94
95 @Test
96 void cualquierEndpoint_sinToken_retorna401() throws Exception {
97 mockMvc.perform(get("/api/users"))
98 .andExpect(status().isUnauthorized());
99 }
100
101 @Test
102 void cualquierEndpoint_conTokenExpirado_retorna401() throws Exception {
103 // Arrange — generar token que ya expiró
104 String expiredToken = Jwts.builder()
105 .subject("[email protected]")
106 .issuedAt(new Date(System.currentTimeMillis() - 3600000))
107 .expiration(new Date(System.currentTimeMillis() - 1800000))
108 .signWith(jwtService.getSigningKey(), Jwts.SIG.HS256)
109 .compact();
110
111 // Act & Assert
112 mockMvc.perform(get("/api/users")
113 .header("Authorization", "Bearer " + expiredToken))
114 .andExpect(status().isUnauthorized());
115 }
116
117 @Test
118 void cualquierEndpoint_conTokenManipulado_retorna401() throws Exception {
119 // Arrange — alterar el payload del token
120 String manipulatedToken = adminToken.substring(0, adminToken.lastIndexOf('.'))
121 + ".invalidSignature";
122
123 // Act & Assert
124 mockMvc.perform(get("/api/users")
125 .header("Authorization", "Bearer " + manipulatedToken))
126 .andExpect(status().isUnauthorized());
127 }
128}
También puedes crear una anotación personalizada para simplificar los tests:
1@Retention(RetentionPolicy.RUNTIME)
2@WithSecurityContext(factory = WithMockJwtSecurityContextFactory.class)
3public @interface WithMockJwt {
4 String email() default "[email protected]";
5 String[] roles() default {"USER"};
6 String[] permissions() default {};
7}
8
9public class WithMockJwtSecurityContextFactory
10 implements WithSecurityContextFactory<WithMockJwt> {
11
12 @Override
13 public SecurityContext createSecurityContext(WithMockJwt annotation) {
14 SecurityContext context = SecurityContextHolder.createEmptyContext();
15
16 List<GrantedAuthority> authorities = new ArrayList<>();
17 for (String role : annotation.roles()) {
18 authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
19 }
20 for (String permission : annotation.permissions()) {
21 authorities.add(new SimpleGrantedAuthority(permission));
22 }
23
24 UsernamePasswordAuthenticationToken auth =
25 new UsernamePasswordAuthenticationToken(
26 annotation.email(), null, authorities);
27 context.setAuthentication(auth);
28 return context;
29 }
30}
31
32// Uso en tests:
33@Test
34@WithMockJwt(email = "[email protected]", roles = {"ADMIN"}, permissions = {"USER_CREATE"})
35void createUser_conAnotacionPersonalizada_retorna201() throws Exception {
36 when(userService.create(any())).thenReturn(new UserDto(1L, "[email protected]", "Test"));
37
38 mockMvc.perform(post("/api/users")
39 .contentType(MediaType.APPLICATION_JSON)
40 .content("{\"email\":\"[email protected]\",\"password\":\"Pass1234!\"}"))
41 .andExpect(status().isCreated());
42}
Configuración por Ambiente
Cada ambiente tiene necesidades diferentes de seguridad. En desarrollo quieres velocidad y conveniencia; en producción quieres máxima seguridad.
1# application.yml — configuración base
2spring:
3 application:
4 name: jwt-security-app
5
6jwt:
7 algorithm: HS256
8 access-token-expiration: 900000 # 15 minutos
9 refresh-token-expiration: 604800000 # 7 días
10
11---
12# application-dev.yml
13spring:
14 config:
15 activate:
16 on-profile: dev
17
18jwt:
19 algorithm: HS256
20 secret: ZGV2LXNlY3JldC1rZXktMjU2LWJpdHMtbWluaW11bS1sZW5ndGg=
21 access-token-expiration: 3600000 # 1 hora (más largo para desarrollo)
22 refresh-token-expiration: 2592000000 # 30 días
23
24logging:
25 level:
26 com.tuapp.security: DEBUG
27
28---
29# application-qa.yml
30spring:
31 config:
32 activate:
33 on-profile: qa
34
35jwt:
36 algorithm: HS256
37 secret: ${JWT_SECRET} # Variable de entorno, no hardcodeada
38 access-token-expiration: 900000 # 15 minutos
39 refresh-token-expiration: 604800000 # 7 días
40
41logging:
42 level:
43 com.tuapp.security: INFO
44
45---
46# application-prod.yml
47spring:
48 config:
49 activate:
50 on-profile: prod
51
52jwt:
53 algorithm: RS256
54 rsa:
55 private-key-path: ${JWT_RSA_PRIVATE_KEY_PATH} # Vault / Secrets Manager
56 public-key-path: ${JWT_RSA_PUBLIC_KEY_PATH}
57 access-token-expiration: 900000 # 15 minutos (estricto)
58 refresh-token-expiration: 604800000 # 7 días
59
60server:
61 ssl:
62 enabled: true
63 key-store: ${SSL_KEYSTORE_PATH}
64 key-store-password: ${SSL_KEYSTORE_PASSWORD}
65 key-store-type: PKCS12
66
67logging:
68 level:
69 com.tuapp.security: WARN
70 root: INFO
Para seleccionar el servicio JWT correcto según el algoritmo configurado, usa una factory:
1@Configuration
2@RequiredArgsConstructor
3public class JwtServiceFactory {
4
5 @Value("${jwt.algorithm}")
6 private String algorithm;
7
8 @Bean
9 @ConditionalOnProperty(name = "jwt.algorithm", havingValue = "HS256")
10 public JwtService hmacJwtService() {
11 return new HmacJwtService(); // Usa clave simétrica
12 }
13
14 @Bean
15 @ConditionalOnProperty(name = "jwt.algorithm", havingValue = "RS256")
16 public JwtService rsaJwtService(RSAPrivateKey privateKey, RSAPublicKey publicKey) {
17 return new RsaJwtService(privateKey, publicKey); // Usa par de claves
18 }
19}
Checklist de Seguridad para Producción
Antes de hacer deploy de tu sistema JWT a producción, revisa cada punto de este checklist:
Algoritmo y Claves
- ☑ Usar RS256 o ES256 en producción (o HS256 con clave de al menos 256 bits)
- ☑ Claves almacenadas en vault de secretos (nunca en código fuente ni archivos de configuración)
- ☑ Plan de rotación de claves cada 90 días
- ☑ Algoritmo
nonebloqueado explícitamente - ☑ Validación de algoritmo en el parser (prevención de algorithm confusion)
Tokens
- ☑ Access token con expiración máxima de 60 minutos (idealmente 15)
- ☑ Refresh token con expiración máxima de 30 días (idealmente 7)
- ☑ JTI (JWT ID) único en cada token para blacklisting
- ☑ Refresh token con rotación: cada uso genera un nuevo par
- ☑ Detección de reutilización de refresh tokens revocados
- ☑ Blacklisting de access tokens con Redis y TTL automático
Transporte y Almacenamiento
- ☑ HTTPS obligatorio en todos los endpoints (HSTS habilitado)
- ☑ Refresh tokens en cookies HttpOnly, Secure, SameSite=Strict
- ☑ Access tokens solo en memoria (no localStorage)
- ☑ Headers de seguridad: X-Content-Type-Options, X-Frame-Options, CSP
Protección contra Ataques
- ☑ Rate limiting en
/auth/login(máximo 5 intentos / 15 min) - ☑ Rate limiting en
/auth/refresh(máximo 10 / 5 min) - ☑ CORS configurado solo para dominios permitidos
- ☑ CSRF deshabilitado solo porque usamos tokens Bearer (no cookies de sesión)
- ☑ Payload del JWT sin datos sensibles (PII, contraseñas, tokens de terceros)
Auditoría y Monitoreo
- ☑ Logging de todos los eventos de seguridad (login, logout, refresh, acceso denegado)
- ☑ TransactionId / correlationId en todos los logs
- ☑ Alertas configuradas para actividad sospechosa (múltiples login fallidos)
- ☑ Retención de logs de auditoría por al menos 90 días
- ☑ Dashboard de monitoreo (Grafana, Datadog, CloudWatch)
Plan de Contingencia
- ☑ Procedimiento documentado para compromiso de claves
- ☑ Capacidad de revocar todos los tokens activos en emergencia
- ☑ Backups de las claves de firma en ubicación segura separada
- ☑ Runbook para incidentes de seguridad relacionados con autenticación
Conclusión del Curso
¡Felicidades! Has completado el Curso JWT en Spring Boot 3. A lo largo de cuatro artículos construimos un sistema de autenticación y autorización de nivel producción:
Resumen de lo construido
1┌─────────────────────────────────────────────────────────┐
2│ ARQUITECTURA FINAL │
3├─────────────────────────────────────────────────────────┤
4│ │
5│ Cliente (SPA/Mobile) │
6│ │ │
7│ ▼ │
8│ ┌─────────────────────────────────┐ │
9│ │ Rate Limit Filter │ ← Bucket4j │
10│ └──────────────┬──────────────────┘ │
11│ │ │
12│ ▼ │
13│ ┌─────────────────────────────────┐ │
14│ │ JWT Authentication Filter │ ← Valida token │
15│ │ • Extrae claims │ ← Roles/Perms │
16│ │ • Verifica blacklist (Redis) │ ← JTI check │
17│ └──────────────┬──────────────────┘ │
18│ │ │
19│ ▼ │
20│ ┌─────────────────────────────────┐ │
21│ │ @PreAuthorize │ ← RBAC │
22│ │ hasRole / hasAuthority │ │
23│ └──────────────┬──────────────────┘ │
24│ │ │
25│ ▼ │
26│ ┌─────────────────────────────────┐ │
27│ │ Controllers + Services │ │
28│ └──────────────┬──────────────────┘ │
29│ │ │
30│ ▼ │
31│ ┌────────┐ ┌────────┐ ┌────────────────┐ │
32│ │ Users │ │ Roles │ │ Refresh Tokens │ │
33│ │ (DB) │ │ (DB) │ │ (DB) │ │
34│ └────────┘ └────────┘ └────────────────┘ │
35│ │
36│ ┌─────────────────────────────────┐ │
37│ │ Redis │ │
38│ │ • Token Blacklist (JTI+TTL) │ │
39│ │ • Rate Limit (distribuido) │ │
40│ └─────────────────────────────────┘ │
41│ │
42│ ┌─────────────────────────────────┐ │
43│ │ Security Audit Service │ │
44│ │ • MDC + JSON Logs │ │
45│ │ • ELK / Grafana Loki │ │
46│ └─────────────────────────────────┘ │
47│ │
48└─────────────────────────────────────────────────────────┘
Lo que cubrimos en cada parte
- Parte 1 — Fundamentos: Estructura de un JWT, JJWT 0.12, JwtService, JwtAuthenticationFilter, SecurityFilterChain, protección de endpoints por roles.
- Parte 2 — Refresh Tokens: Entidad JPA, rotación de tokens, detección de reutilización, cookies HttpOnly, manejo de errores con ProblemDetail.
- Parte 3 — JTI y Blacklisting: JWT ID único, blacklisting con Redis y TTL automático, logout real que invalida ambos tokens, limpieza de tokens expirados.
- Parte 4 — Producción: Claims personalizados, RBAC completo, @PreAuthorize, algoritmos de firma (HS256/RS256/ES256), seguridad OWASP, rate limiting, auditoría, testing y configuración por ambiente.
Próximos pasos
Tu sistema de autenticación JWT es sólido y está listo para producción. Aquí hay algunas áreas donde puedes seguir mejorando:
- MFA / TOTP: Agrega autenticación de dos factores con códigos temporales (Google Authenticator, Authy). Usa la librería
dev.samstevens.totp. - OAuth2 / OpenID Connect: Permite login con Google, GitHub o Microsoft usando Spring Authorization Server.
- Autenticación en microservicios: Usa un API Gateway (Spring Cloud Gateway) como punto central de autenticación y propaga el JWT a los servicios internos.
- Passkeys / WebAuthn: La nueva generación de autenticación sin contraseñas basada en biometría y claves criptográficas.
- Monitoreo avanzado: Integra con OpenTelemetry para trazabilidad distribuida de tus flujos de autenticación.
Gracias por seguir este curso completo. Si tienes preguntas o sugerencias, déjalas en los comentarios. ¡Éxito con tu proyecto!
Comments
Sign in to leave a comment
No comments yet. Be the first!
Related Articles
Stay updated
Get notified when I publish new articles. No spam, unsubscribe anytime.