Spring SecurityLección 09
APIs stateless con JWT
Un JWT es una credencial que se valida sola. Cómo configurar el OAuth2 Resource Server, de dónde saca las claves, cómo convertir claims en authorities y qué no resuelve un JWT.
Al terminar sabrás
- Proteger una API con
oauth2ResourceServersin escribir un filtro propio. - Configurar la validación de firma, emisor y audiencia.
- Convertir scopes o roles del token en authorities.
- Emitir y validar tus propios JWT con claves RSA.
Un JWT es una credencial que se valida sola: el servidor comprueba la firma con una clave y confía en lo que dice el token sin consultar ninguna base de datos. Eso es lo que lo hace escalable, y también lo que lo hace peligroso si se valida mal. Spring Security trae el validador hecho y auditado: el OAuth2 Resource Server. (Repasa la anatomía de un JWT en sesiones, cookies y tokens).
No escribas tu propio filtro JWT
Sección titulada «No escribas tu propio filtro JWT»Evita
// ¿Rechaza alg:none? ¿Valida exp, iss, aud?// ¿Evita la confusión de algoritmos?// ¿Responde WWW-Authenticate correctamente?class JwtAuthFilter extends OncePerRequestFilter { // 120 líneas de parseo a mano…}Prefiere
http.oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults()));Esa línea añade BearerTokenAuthenticationFilter, un JwtAuthenticationProvider con su JwtDecoder, y un entry point que responde 401 con WWW-Authenticate: Bearer error="invalid_token".
Dependencia (en Spring Boot 4 el nombre del starter lleva security-):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId></dependency>De dónde saca la clave
Sección titulada «De dónde saca la clave»Una de estas, según tu emisor:
spring: security: oauth2: resourceserver: jwt: # 1) Descubre claves y valida iss a partir del emisor (lo más habitual) issuer-uri: https://auth.example.com # 2) …o solo el endpoint de claves, sin validar iss # jwk-set-uri: https://auth.example.com/oauth2/jwks # 3) …o una clave pública fija # public-key-location: classpath:certs/public.pem audiences: notes-api # exige que aud contenga este valor| Claim | Lo valida Spring por defecto |
|---|---|
| Firma | Sí. Por defecto NimbusJwtDecoder solo confía en RS256 |
exp / nbf | Sí, con 60 segundos de tolerancia de reloj |
iss | Sí, si configuras issuer-uri |
aud | Solo si configuras audiences |
typ (header) | Sí desde la 7.0: debe ser JWT o no estar |
sub | No se valida: pasa a ser authentication.getName() |
Si necesitas control total, declara tu propio JwtDecoder con validadores adicionales:
@BeanJwtDecoder jwtDecoder() { NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation("https://auth.example.com").build(); decoder.setJwtValidator(JwtValidators.createDefaultWithValidators( new JwtIssuerValidator("https://auth.example.com"), new JwtAudienceValidator("notes-api"), new JwtClaimValidator<String>("tenant", "acme"::equals))); return decoder;}Simétrico o asimétrico
Sección titulada «Simétrico o asimétrico»- HS256 (HMAC): la misma clave firma y verifica. Todo servicio que valida también podría emitir tokens. Aceptable solo si emisor y validador son la misma aplicación.
- RS256 / ES256: el emisor firma con la privada; los demás verifican con la pública, que se publica en el endpoint JWKS. Es lo que usan Keycloak, Auth0, Google y Spring Authorization Server.
De claims a authorities
Sección titulada «De claims a authorities»Por defecto, el claim scope (o scp) se convierte en authorities con prefijo SCOPE_: "notes:read notes:write" → SCOPE_notes:read, SCOPE_notes:write. En las reglas:
.requestMatchers(HttpMethod.GET, "/api/notes/**").hasAuthority("SCOPE_notes:read").requestMatchers("/api/notes/**").hasAuthority("SCOPE_notes:write")Si tu emisor pone los roles en otro claim, personaliza el conversor:
@BeanJwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter(); roles.setAuthoritiesClaimName("roles"); // {"roles": ["ADMIN", "USER"]} roles.setAuthorityPrefix("ROLE_"); // → ROLE_ADMIN, ROLE_USER
JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(roles); return converter;}(Para claims anidados, como realm_access.roles de Keycloak, pasa una lambda propia a setJwtGrantedAuthoritiesConverter que lea el mapa y devuelva la lista).
En el controlador, el principal es un Jwt:
@GetMapping("/api/me")Map<String, Object> me(@AuthenticationPrincipal Jwt jwt) { return Map.of("sub", jwt.getSubject(), "scope", jwt.getClaimAsString("scope"));}El problema que un JWT no resuelve: revocar
Sección titulada «El problema que un JWT no resuelve: revocar»Como nadie consulta la base de datos, desactivar a un usuario no invalida los tokens que ya tiene. Las mitigaciones estándar:
- Access tokens cortos (5–15 minutos) y refresh tokens que sí se guardan y se pueden revocar.
- Una lista de
jtirevocados en Redis (reintroduce estado). - Tokens opacos con introspección (
opaqueToken(...)en lugar dejwt(...)), cuando la revocación inmediata es imprescindible.
Mini lab · 45 min
La API emite y valida sus propios JWT
Objetivo: Reemplazar HTTP Basic en /api/** por tokens Bearer RS256, manteniendo Basic solo para obtener el token.
- Añade
spring-boot-starter-security-oauth2-resource-servery genera un par de claves (nunca las subas a git en un proyecto real):
mkdir -p src/main/resources/certs && cd src/main/resources/certsopenssl genrsa -out keypair.pem 2048openssl rsa -in keypair.pem -pubout -out public.pemopenssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in keypair.pem -out private.pem- Enlázalas y declara encoder y decoder:
@ConfigurationProperties(prefix = "rsa") // rsa.public-key=classpath:certs/public.pem …record RsaKeyProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) { }
@BeanJwtEncoder jwtEncoder(RsaKeyProperties rsa) { return NimbusJwtEncoder.withKeyPair(rsa.publicKey(), rsa.privateKey()).build();}
@BeanJwtDecoder jwtDecoder(RsaKeyProperties rsa) { return NimbusJwtDecoder.withPublicKey(rsa.publicKey()).build();}- Un servicio que emite el token a partir de la autenticación del login:
@Serviceclass TokenService {
private final JwtEncoder encoder;
TokenService(JwtEncoder encoder) { this.encoder = encoder; }
String issue(Authentication auth) { Instant now = Instant.now(); boolean admin = auth.getAuthorities().stream() .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); JwtClaimsSet claims = JwtClaimsSet.builder() .issuer("https://notes.local") .issuedAt(now) .expiresAt(now.plus(15, ChronoUnit.MINUTES)) .subject(auth.getName()) .audience(List.of("notes-api")) .claim("scope", admin ? "notes:read notes:write notes:admin" : "notes:read notes:write") .build(); return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue(); }}- Dos cadenas:
POST /api/auth/tokencon HTTP Basic que devuelvetokenService.issue(authentication), y el resto de/api/**conoauth2ResourceServer(o -> o.jwt(withDefaults())), ambas stateless y sin CSRF. UsahasAuthority("SCOPE_notes:admin")en/api/admin/**. - Prueba el flujo:
TOKEN=$(curl -s -X POST -u ana:ana-password-123 http://localhost:8080/api/auth/token)curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/notesPreguntas:
- Decodifica el token. ¿Qué información de
anapuede leer cualquiera que lo intercepte? - Cambia una letra del payload y envíalo. ¿Qué dice la cabecera
WWW-Authenticate? - Desactiva a
anaen la base de datos y usa su token anterior. ¿Funciona? ¿Qué cambiarías para que no? - Pon la expiración en 1 minuto y espera 2. ¿Por qué a veces sigue funcionando hasta casi el minuto 2?
Pista
La última pregunta tiene que ver con un valor por defecto de la tabla de claims.
Mostrar respuestaOcultar respuesta
sub(ana),iss,aud,scope,iatyexp: quién es, qué puede hacer y hasta cuándo. Por eso no se ponen datos sensibles en el payload.WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", con un 401. La firma ya no corresponde al contenido.- Sí funciona hasta que expire: la API no consulta la base de datos. Para cortarlo antes: tokens más cortos con refresh tokens revocables, una lista de tokens revocados, o comprobar el estado del usuario en un
JwtClaimValidator/conversor que consulte una caché (reintroduciendo algo de estado). - Porque
JwtTimestampValidatoraplica 60 segundos de tolerancia de reloj (clock skew) para no rechazar tokens válidos por pequeñas diferencias entre servidores.