SSO de Spring Boot con OpenID Connect: Spring Security, Resource Server y Spring Cloud Gateway con Casdoor
Para agregar inicio de sesión único a Spring Boot, registre cada aplicación como una aplicación en un proveedor de OpenID Connect como Casdoor, apunte Spring Security a la URL del emisor del proveedor y elija el módulo de Spring que coincida con la aplicación: oauth2Login() para una aplicación web renderizada en el servidor, oauth2ResourceServer().jwt() para una API REST que recibe tokens bearer, y Spring Cloud Gateway con el filtro TokenRelay cuando una puerta de enlace inicia sesión a los usuarios para un conjunto de microservicios. Los usuarios obtienen SSO porque todas las aplicaciones los envían al mismo Casdoor, que recuerda la sesión: la segunda aplicación redirige, Casdoor ya conoce al usuario y este vuelve directamente sin un formulario de inicio de sesión. Esta publicación configura las tres formas contra un solo Casdoor, cada una con un application.yml funcional y un SecurityFilterChain personalizado.
Las tres formas de un vistazo
| Forma | Dependencia de Spring | Quién mantiene el inicio de sesión | Endpoints de Casdoor utilizados | Cadena de filtros |
|---|---|---|---|---|
| Aplicación web | spring-boot-starter-oauth2-client | La aplicación (sesión HTTP) | discovery, authorize, token, userinfo, JWKS, logout | SecurityFilterChain con oauth2Login() |
| API REST | spring-boot-starter-oauth2-resource-server | Nadie, cada solicitud lleva un token bearer | JWKS (y discovery si usa issuer-uri) | SecurityFilterChain con oauth2ResourceServer() |
| Puerta de enlace + microservicios | spring-cloud-starter-gateway-server-webflux + spring-boot-starter-oauth2-client | La puerta de enlace (sesión WebFlux) | Igual que la aplicación web; los servicios detrás usan JWKS | SecurityWebFilterChain con oauth2Login(), TokenRelay en rutas |
Suposiciones de versión: Spring Boot 3.5 con Spring Security 6.5, Java 17 y Spring Cloud 2025.0 (Spring Cloud Gateway 4.3). En Spring Boot 4, los dos starters de Security se llaman spring-boot-starter-security-oauth2-client y spring-boot-starter-security-oauth2-resource-server. Gateway 4.3 y 5.0 utilizan ambos el prefijo de propiedad spring.cloud.gateway.server.webflux.* utilizado a continuación; el prefijo más antiguo spring.cloud.gateway.* y el artefacto spring-cloud-starter-gateway están obsoletos desde Spring Cloud 2025.0.
Qué ofrece Casdoor a Spring
En la consola de administración de Casdoor, cree una aplicación (o reutilice una) y copie su Client ID y Client secret. Agregue todas las devoluciones de llamada que usarán las aplicaciones Spring a Redirect URLs. El valor predeterminado de Spring Security es {baseUrl}/login/oauth2/code/{registrationId}, por lo que con el ID de registro casdoor y una aplicación en el puerto 8080, la URL es http://localhost:8080/login/oauth2/code/casdoor. Si utiliza cierre de sesión OIDC, agregue también la página donde aterrizarán los usuarios después, por ejemplo http://localhost:8080/; Casdoor rechaza un post_logout_redirect_uri que no esté en la lista.
Casdoor publica un documento de descubrimiento estándar en /.well-known/openid-configuration, por lo que Spring solo necesita el emisor. Estos son los endpoints que anuncia, tomados del código fuente de Casdoor:
| Campo de metadatos | Ruta de Casdoor |
|---|---|
issuer | El origen del backend, p. ej. https://door.example.com (sin barra final) |
authorization_endpoint | /login/oauth/authorize |
token_endpoint | /api/login/oauth/access_token |
userinfo_endpoint | /api/userinfo |
jwks_uri | /.well-known/jwks |
end_session_endpoint | /api/logout |
Los ámbitos admitidos son openid, email, profile, address, phone y offline_access, además de cualquier ámbito personalizado definido en la aplicación. Los tokens se firman con RS256 de forma predeterminada y llevan el nombre del certificado como kid. Cada aplicación también tiene su propio documento de descubrimiento en /.well-known/<application>/openid-configuration; consulte Conectar un cliente OIDC estándar para obtener detalles y la lista completa de endpoints OAuth 2.0.
Dos cosas en los tokens de Casdoor importan para Spring:
- El emisor debe coincidir exactamente. Spring compara el
issueren el documento de descubrimiento con elissuer-urique configure, y luego con la reclamaciónissde cada token. Casdoor deriva el emisor de la configuraciónoriginenapp.conf, o del encabezado Host cuandooriginestá vacío. Si sus aplicaciones Spring llegan a Casdoor a través de un nombre de host interno (digamoshttp://casdoor:8000dentro de Docker) mientras que los navegadores usan uno público, establezcaoriginen la URL pública para que todos los tokens reciban el mismoiss. Consulte Configuración. - Los roles vienen en dos formas.
/api/userinfodevuelverolescomo una lista de nombres de roles cuando se otorgó el ámbitoprofile. El token de acceso en el formato de tokenJWTpredeterminado contienerolescomo una lista de objetos de rol, cada uno con un camponame. El código siguiente maneja ambos. Los tokens de acceso de Casdoor también llevanscopecomo una cadena separada por espacios, que Spring convierte en autoridadesSCOPE_...de forma predeterminada.
Si desea información general sobre el protocolo, consulte ¿Qué es OpenID Connect? y Token de ID vs. token de acceso.
1. Aplicación web Spring Boot con oauth2Login()
Agregue spring-boot-starter-web, spring-boot-starter-security y spring-boot-starter-oauth2-client. Luego configure el proveedor mediante el emisor:
server:
port: 8080
spring:
security:
oauth2:
client:
registration:
casdoor:
client-id: <client-id>
client-secret: <client-secret>
authorization-grant-type: authorization_code
scope: openid,profile,email
provider:
casdoor:
issuer-uri: https://door.example.com
user-name-attribute: preferred_username
Al iniciarse, Spring Boot obtiene https://door.example.com/.well-known/openid-configuration y completa las URI de autorización, token, userinfo y JWKS. Luego, Spring valida la firma del token de ID, el emisor, la audiencia y el nonce, llama a /api/userinfo porque profile y email están en el ámbito (scope), y fusiona las reclamaciones (claims). preferred_username es el nombre de usuario de Casdoor; name en userinfo es el nombre para mostrar, y el valor predeterminado de sub es el UUID del usuario.
Sin su propia configuración, Spring Boot requiere inicio de sesión para cada URL. El siguiente SecurityFilterChain personalizado abre la página de inicio, asigna los roles de Casdoor a las autoridades ROLE_ y cierra la sesión tanto en Casdoor como en la aplicación:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository clientRegistrations) throws Exception {
OidcClientInitiatedLogoutSuccessHandler oidcLogout =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrations);
oidcLogout.setPostLogoutRedirectUri("{baseUrl}/");
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**", "/error").permitAll()
.requestMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated())
.oauth2Login(login -> login
.userInfoEndpoint(userInfo -> userInfo
.userAuthoritiesMapper(casdoorRoles())))
.logout(logout -> logout
.logoutSuccessHandler(oidcLogout));
return http.build();
}
private GrantedAuthoritiesMapper casdoorRoles() {
return authorities -> {
Set<GrantedAuthority> mapped = new HashSet<>(authorities);
for (GrantedAuthority authority : authorities) {
if (authority instanceof OidcUserAuthority oidc && oidc.getUserInfo() != null) {
List<String> roles = oidc.getUserInfo().getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(role -> mapped.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
}
}
return mapped;
};
}
}
hasRole("admin") ahora coincide con los usuarios que tienen el rol admin de Casdoor. OidcClientInitiatedLogoutSuccessHandler lee end_session_endpoint del documento de descubrimiento y redirige al /api/logout de Casdoor con id_token_hint y post_logout_redirect_uri, por lo que también se termina la sesión de Casdoor. Sin esto, /logout solo limpia la sesión de Spring y el siguiente inicio de sesión será silencioso.
En un controlador, el usuario conectado es un OidcUser:
@GetMapping("/me")
Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of("name", user.getName(), "email", user.getEmail(),
"authorities", user.getAuthorities().toString());
}
La página anterior paso a paso para esta configuración está disponible en Spring Security OAuth.
2. API de Spring Boot como servidor de recursos OAuth2
Una API REST no debe redirigir a nadie. Acepta Authorization: Bearer <access token>, verifica la firma JWT contra el JWKS de Casdoor y revisa las reclamaciones. Agregue spring-boot-starter-web, spring-boot-starter-security y spring-boot-starter-oauth2-resource-server:
server:
port: 9000
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://door.example.com
jwk-set-uri: https://door.example.com/.well-known/jwks
audiences: <client-id>
Con solo este archivo, Spring Boot crea un JwtDecoder que verifica la firma, la expiración, iss y aud. Establecer jwk-set-uri junto con issuer-uri significa que las claves se obtienen directamente en lugar de a través del descubrimiento, y el emisor sigue siendo validado. La audiencia es el ID de cliente de la aplicación de Casdoor que emitió el token: si una aplicación de una sola página o la puerta de enlace de la sección 3 obtienen el token, utilice el ID de cliente de esa aplicación.
Hay una comprobación específica de Casdoor que vale la pena agregar. Casdoor firma los tokens de ID y los tokens de acceso con la misma clave y otorga a ambos el ID de cliente como audiencia, por lo que los validadores predeterminados también aceptarían un token de ID enviado como token bearer. Cada token de Casdoor tiene una reclamación tokenType (access-token, id-token o refresh-token), lo que hace que la comprobación sea simple. Definir un bean JwtDecoder reemplaza el de Boot, por lo que el bean vuelve a agregar las comprobaciones de emisor y audiencia:
@Configuration
@EnableWebSecurity
public class ApiSecurityConfig {
@Bean
JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
@Value("${spring.security.oauth2.resourceserver.jwt.audiences}") String clientId) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer),
new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
aud -> aud != null && aud.contains(clientId)),
new JwtClaimValidator<String>("tokenType", "access-token"::equals)));
return decoder;
}
@Bean
SecurityFilterChain apiFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("admin")
.anyRequest().authenticated())
.oauth2ResourceServer(server -> server
.jwt(jwt -> jwt
.decoder(jwtDecoder)
.jwtAuthenticationConverter(casdoorJwtConverter())))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable());
return http.build();
}
private JwtAuthenticationConverter casdoorJwtConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setPrincipalClaimName("name");
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));
if (jwt.getClaims().get("roles") instanceof Collection<?> roles) {
for (Object role : roles) {
Object name = role instanceof Map<?, ?> map ? map.get("name") : role;
if (name instanceof String roleName && !roleName.isEmpty()) {
authorities.add(new SimpleGrantedAuthority("ROLE_" + roleName));
}
}
}
return authorities;
});
return converter;
}
}
El conversor mantiene las autoridades SCOPE_, agrega autoridades ROLE_ desde la reclamación roles, ya sea que contenga nombres u objetos, y utiliza la reclamación name (el nombre de usuario de Casdoor) como principal. Si la aplicación utiliza el formato de token JWT-Standard, use preferred_username como reclamación principal en su lugar. Deshabilitar CSRF y sesiones es correcto aquí porque la API nunca ve una cookie del navegador; no copie esas dos líneas en la aplicación web.
Para probarlo sin un navegador, obtenga un token de máquina con la concesión de credenciales de cliente y llame a la API:
TOKEN=$(curl -s -u '<client-id>:<client-secret>' \
-d grant_type=client_credentials \
https://door.example.com/api/login/oauth/access_token | jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" http://localhost:9000/api/hello
Un token de credenciales de cliente representa a la aplicación, no a un usuario, por lo que no tiene roles. Para más detalles sobre qué reclamaciones verificar y por qué, consulte Validación JWT y Ámbitos OAuth.
3. Spring Cloud Gateway con TokenRelay
En microservicios, un diseño común es permitir que la puerta de enlace gestione el inicio de sesión y mantener los servicios como servidores de recursos. El navegador solo habla con la puerta de enlace y mantiene una cookie de sesión; la puerta de enlace almacena el token de acceso del usuario y el filtro TokenRelay lo agrega como Authorization: Bearer ... a cada solicitud proxy. Los servicios de la sección 2 lo validan normalmente.
Agregue spring-cloud-starter-gateway-server-webflux y spring-boot-starter-oauth2-client, con el BOM de Spring Cloud 2025.0:
server:
port: 8080
spring:
application:
name: gateway
security:
oauth2:
client:
registration:
casdoor:
client-id: <client-id>
client-secret: <client-secret>
authorization-grant-type: authorization_code
scope: openid,profile,email
provider:
casdoor:
issuer-uri: https://door.example.com
user-name-attribute: preferred_username
cloud:
gateway:
server:
webflux:
routes:
- id: orders
uri: http://localhost:9000
predicates:
- Path=/api/orders/**
filters:
- TokenRelay=
- id: users
uri: http://localhost:9001
predicates:
- Path=/api/users/**
filters:
- TokenRelay=
TokenRelay= sin argumento retransmite el token del usuario conectado. La fábrica de filtros TokenRelay solo se crea cuando se configura spring.security.oauth2.client.*, y el almacén predeterminado de clientes autorizados está en memoria, por lo que una puerta de enlace con varias réplicas necesita un almacén compartido o sesiones adherentes (sticky sessions). Los servicios detrás de ella establecen audiences en el ID de cliente de la puerta de enlace.
Spring Cloud Gateway se ejecuta en WebFlux, por lo que su configuración de seguridad es una SecurityWebFilterChain construida desde ServerHttpSecurity, no la SecurityFilterChain de servlets:
@Configuration
@EnableWebFluxSecurity
public class GatewaySecurityConfig {
@Bean
SecurityWebFilterChain gatewayFilterChain(ServerHttpSecurity http,
ReactiveClientRegistrationRepository clientRegistrations) {
OidcClientInitiatedServerLogoutSuccessHandler oidcLogout =
new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrations);
oidcLogout.setPostLogoutRedirectUri("{baseUrl}/");
http
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/", "/actuator/health").permitAll()
.anyExchange().authenticated())
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessHandler(oidcLogout));
return http.build();
}
}
Mantenga las decisiones de autorización en los servicios, donde los roles están en el token; la puerta de enlace solo asegura que haya un usuario conectado. La protección CSRF permanece activada en la puerta de enlace porque es basada en cookies.
Las páginas existentes de Spring Cloud Gateway y Spring Cloud muestran un diseño diferente, donde un GlobalFilter personalizado verifica un usuario de Casdoor almacenado en la sesión de la puerta de enlace.
Alternativa: el starter de Casdoor para Spring Boot
Si prefiere no escribir YAML, casdoor-spring-boot-starter 2.x (Spring Boot 3 y 4, Java 17+) acepta casdoor.endpoint, casdoor.client-id, casdoor.client-secret, casdoor.organization-name y casdoor.application-name, y establece las propiedades spring.security.oauth2.* anteriores según cuál starter de Security esté en la ruta de clase. Registra el cliente como casdoor, mapea los roles de Casdoor a autoridades ROLE_ tanto para el inicio de sesión OAuth2 como para el servidor de recursos de servlets, y expone los servicios del SDK de Java de Casdoor como beans. Sus propiedades tienen la menor precedencia, por lo que aún puede sobrescribir cualquiera de ellas. Consulte Spring Boot para la configuración.
Errores comunes
- "The Issuer ... provided in the configuration metadata did not match the requested issuer": el
issuer-uridifiere de lo que informa Casdoor. Elimine cualquier barra final y establezcaoriginen Casdoor si los nombres de host difieren. redirect_urirechazado por Casdoor: la URL de devolución de llamada no está en las Redirect URLs de la aplicación. Verifique el esquema y el puerto, especialmente detrás de un proxy inverso.- "Missing attribute 'preferred_username'": no se solicitó el ámbito
profile, o los campos de token de la aplicación omitenName. - Error 401 con
invalid_tokenen la API: el token proviene de otra aplicación (audincorrecto), ha expirado o es un ID token.
Referencias
- Documento de descubrimiento y endpoints de Casdoor:
object/wellknown_oidc_discovery.go - Claims del token de Casdoor:
object/token_jwt.go - Referencia de Spring Boot 3.5: Spring Security, cliente OAuth2 y servidor de recursos
- Spring Security: cierre de sesión iniciado por el cliente OIDC
- Spring Security: JWT para servidor de recursos OAuth 2.0
- Spring Cloud Gateway: fábrica de filtros TokenRelay GatewayFilter
- Spring Cloud Gateway: starter
spring-cloud-starter-gateway-server-webflux - Notas de la versión de Spring Cloud 2025.0
- casdoor-spring-boot-starter