SSO Spring Boot avec OpenID Connect : Spring Security, Resource Server et Spring Cloud Gateway avec Casdoor
Pour ajouter l'authentification unique (SSO) à Spring Boot, enregistrez chaque application en tant qu'application dans un fournisseur OpenID Connect tel que Casdoor, pointez Spring Security vers l'URL d'émetteur du fournisseur, et choisissez le module Spring correspondant à l'application : oauth2Login() pour une application web rendue côté serveur, oauth2ResourceServer().jwt() pour une API REST qui reçoit des jetons bearer, et Spring Cloud Gateway avec le filtre TokenRelay lorsqu'une passerelle authentifie les utilisateurs pour un ensemble de microservices. Les utilisateurs bénéficient du SSO car toutes les applications les redirigent vers le même Casdoor, qui mémorise la session : la deuxième application effectue une redirection, Casdoor connaît déjà l'utilisateur, et celui-ci revient directement sans formulaire de connexion. Cet article met en place ces trois configurations contre un seul Casdoor, chacune avec un fichier application.yml fonctionnel et un SecurityFilterChain personnalisé.
Les trois configurations en un coup d'œil
| Configuration | Dépendance Spring | Qui détient la connexion | Endpoints Casdoor utilisés | Chaîne de filtres |
|---|---|---|---|---|
| Application web | spring-boot-starter-oauth2-client | L'application (session HTTP) | discovery, authorize, token, userinfo, JWKS, logout | SecurityFilterChain avec oauth2Login() |
| API REST | spring-boot-starter-oauth2-resource-server | Personne, chaque requête porte un jeton bearer | JWKS (et discovery si vous utilisez issuer-uri) | SecurityFilterChain avec oauth2ResourceServer() |
| Passerelle + microservices | spring-cloud-starter-gateway-server-webflux + spring-boot-starter-oauth2-client | La passerelle (session WebFlux) | Même chose que pour l'application web ; les services derrière utilisent JWKS | SecurityWebFilterChain avec oauth2Login(), TokenRelay sur les routes |
Hypothèses de version : Spring Boot 3.5 avec Spring Security 6.5, Java 17, et Spring Cloud 2025.0 (Spring Cloud Gateway 4.3). Sur Spring Boot 4, les deux starters Security sont nommés spring-boot-starter-security-oauth2-client et spring-boot-starter-security-oauth2-resource-server. Gateway 4.3 et 5.0 utilisent tous deux le préfixe de propriété spring.cloud.gateway.server.webflux.* utilisé ci-dessous ; le préfixe plus ancien spring.cloud.gateway.* et l'artefact spring-cloud-starter-gateway sont dépréciés depuis Spring Cloud 2025.0.
Ce que Casdoor fournit à Spring
Dans la console d'administration Casdoor, créez une application (ou réutilisez-en une) et copiez son Client ID et son Client secret. Ajoutez tous les callbacks que les applications Spring utiliseront aux Redirect URLs. La valeur par défaut de Spring Security est {baseUrl}/login/oauth2/code/{registrationId}, donc avec l'ID d'enregistrement casdoor et une application sur le port 8080, l'URL est http://localhost:8080/login/oauth2/code/casdoor. Si vous utilisez la déconnexion OIDC, ajoutez également la page où les utilisateurs atterrissent ensuite, par exemple http://localhost:8080/ ; Casdoor refuse un post_logout_redirect_uri qui ne figure pas dans la liste.
Casdoor publie un document de découverte standard à /.well-known/openid-configuration, donc Spring n'a besoin que de l'émetteur. Voici les endpoints qu'il annonce, tirés du code source de Casdoor :
| Champ de métadonnées | Chemin Casdoor |
|---|---|
issuer | L'origine du backend, e.g. https://door.example.com (sans barre oblique finale) |
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 |
Les scopes pris en charge sont openid, email, profile, address, phone et offline_access, ainsi que tout scope personnalisé défini sur l'application. Les jetons sont signés avec RS256 par défaut et portent le nom du certificat comme kid. Chaque application a également son propre document de découverte à /.well-known/<application>/openid-configuration ; voir Connecter un client OIDC standard pour les détails et la liste complète des endpoints OAuth 2.0.
Deux éléments dans les jetons de Casdoor comptent pour Spring :
- L'émetteur doit correspondre exactement. Spring compare l'
issuerdans le document de découverte avec l'issuer-urique vous configurez, puis avec la revendicationissde chaque jeton. Casdoor dérive l'émetteur du paramètreorigindansapp.conf, ou de l'en-tête Host lorsqueoriginest vide. Si vos applications Spring accèdent à Casdoor via un nom d'hôte interne (par exemplehttp://casdoor:8000dans Docker) tandis que les navigateurs utilisent un nom public, définissezoriginsur l'URL publique afin que chaque jeton obtienne le mêmeiss. Voir Configuration. - Les rôles se présentent sous deux formes.
/api/userinforenvoierolessous forme de liste de noms de rôles lorsque le scopeprofilea été accordé. Le jeton d'accès dans le format de jetonJWTpar défaut contientrolessous forme de liste d'objets de rôle, chacun ayant un champname. Le code ci-dessous gère les deux cas. Les jetons d'accès de Casdoor portent égalementscopesous forme de chaîne séparée par des espaces, que Spring transforme en autoritésSCOPE_...par défaut.
Si vous souhaitez des informations générales sur le protocole, consultez Qu'est-ce qu'OpenID Connect et ID token vs access token.
1. Application web Spring Boot avec oauth2Login()
Ajoutez spring-boot-starter-web, spring-boot-starter-security et spring-boot-starter-oauth2-client. Configurez ensuite le fournisseur via l'émetteur (issuer) :
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
Au démarrage, Spring Boot récupère https://door.example.com/.well-known/openid-configuration et remplit les URI d'autorisation, de jeton, d'informations utilisateur et JWKS. Spring valide ensuite la signature du ID token, l'émetteur, l'audience et le nonce, appelle /api/userinfo car profile et email sont dans la portée (scope), et fusionne les claims. preferred_username est le nom d'utilisateur Casdoor ; name dans les infos utilisateur est le nom affiché, et le sub par défaut est l'UUID de l'utilisateur.
Sans configuration personnalisée, Spring Boot exige une connexion pour chaque URL. Le SecurityFilterChain personnalisé ci-dessous ouvre la page d'accueil, mappe les rôles Casdoor vers des autorités ROLE_, et déconnecte à la fois Casdoor et l'application :
@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") correspond désormais aux utilisateurs ayant le rôle Casdoor admin. OidcClientInitiatedLogoutSuccessHandler lit end_session_endpoint depuis le document de découverte et redirige vers /api/logout de Casdoor avec id_token_hint et post_logout_redirect_uri, ce qui termine également la session Casdoor. Sans cela, /logout ne supprime que la session Spring et la prochaine connexion sera silencieuse.
Dans un contrôleur, l'utilisateur connecté est 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 page étape par étape plus ancienne pour cette configuration est Spring Security OAuth.
2. API Spring Boot comme serveur de ressources OAuth2
Une API REST ne doit rediriger personne. Elle accepte Authorization: Bearer <access token>, vérifie la signature JWT contre le JWKS de Casdoor et contrôle les claims. Ajoutez spring-boot-starter-web, spring-boot-starter-security et 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>
Avec seulement ce fichier, Spring Boot construit un JwtDecoder qui vérifie la signature, l'expiration, iss et aud. Définir jwk-set-uri à côté de issuer-uri signifie que les clés sont récupérées directement au lieu de passer par la découverte, et l'émetteur est toujours validé. L'audience est l'ID client de l'application Casdoor qui a émis le jeton : si une application monopage ou la passerelle de la section 3 obtient le jeton, utilisez l'ID client de cette application.
Il y a une vérification spécifique à Casdoor qui vaut la peine d'être ajoutée. Casdoor signe les ID tokens et les access tokens avec la même clé et donne les deux l'ID client comme audience, donc les validateurs par défaut accepteraient aussi un ID token envoyé comme bearer token. Chaque jeton Casdoor a un claim tokenType (access-token, id-token ou refresh-token), ce qui rend la vérification simple. Définir un bean JwtDecoder remplace celui de Boot, donc le bean réajoute les vérifications de l'émetteur et de l'audience :
@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;
}
}
Le convertisseur conserve les autorités SCOPE_, ajoute des autorités ROLE_ depuis le claim roles qu'il contienne des noms ou des objets, et utilise le claim name (le nom d'utilisateur Casdoor) comme principal. Si l'application utilise le format de jeton JWT-Standard, utilisez preferred_username comme claim principal à la place. Désactiver CSRF et les sessions est correct ici car l'API ne voit jamais de cookie navigateur ; ne copiez pas ces deux lignes dans l'application web.
Pour essayer sans navigateur, obtenez un jeton machine avec le grant client credentials et appelez l'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 jeton client credentials représente l'application, pas un utilisateur, donc il n'a pas de rôles. Pour plus d'informations sur quels claims vérifier et pourquoi, consultez Validation JWT et Portées OAuth.
3. Spring Cloud Gateway avec TokenRelay
Avec les microservices, une architecture courante consiste à laisser la passerelle gérer la connexion et à garder les services comme serveurs de ressources. Le navigateur ne communique qu'avec la passerelle et détient un cookie de session ; la passerelle stocke le jeton d'accès de l'utilisateur et le filtre TokenRelay l'ajoute en tant que Authorization: Bearer ... à chaque requête proxifiée. Les services de la section 2 le valident normalement.
Ajoutez spring-cloud-starter-gateway-server-webflux et spring-boot-starter-oauth2-client, avec le BOM 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= sans argument transmet le jeton de l'utilisateur connecté. La fabrique de filtres TokenRelay n'est créée que lorsque spring.security.oauth2.client.* est configuré, et le magasin de clients autorisés par défaut est en mémoire, donc une passerelle avec plusieurs répliques nécessite un magasin partagé ou des sessions collantes (sticky sessions). Les services derrière elle définissent audiences sur l'ID client de la passerelle.
Spring Cloud Gateway fonctionne sur WebFlux, donc sa configuration de sécurité est une SecurityWebFilterChain construite à partir de ServerHttpSecurity, et non la SecurityFilterChain servlet :
@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();
}
}
Gardez les décisions d'autorisation dans les services, où les rôles sont dans le jeton ; la passerelle s'assure simplement qu'il y a un utilisateur connecté. La protection CSRF reste activée dans la passerelle car elle est basée sur des cookies.
Les pages existantes Spring Cloud Gateway et Spring Cloud montrent une conception différente, où un GlobalFilter personnalisé vérifie un utilisateur Casdoor stocké dans la session de la passerelle.
Alternative : le starter Casdoor Spring Boot
Si vous préférez ne pas écrire le YAML, casdoor-spring-boot-starter 2.x (Spring Boot 3 et 4, Java 17+) prend casdoor.endpoint, casdoor.client-id, casdoor.client-secret, casdoor.organization-name et casdoor.application-name, et définit les propriétés spring.security.oauth2.* ci-dessus selon le starter Security présent sur le classpath. Il enregistre le client sous le nom casdoor, mappe les rôles Casdoor vers des autorités ROLE_ pour la connexion OAuth2 et le serveur de ressources servlet, et expose les services du Casdoor Java SDK en tant que beans. Ses propriétés ont la priorité la plus basse, donc vous pouvez toujours les remplacer. Voir Spring Boot pour la configuration.
Erreurs courantes
- "The Issuer ... provided in the configuration metadata did not match the requested issuer" : l'
issuer-uridiffère de celui rapporté par Casdoor. Supprimez toute barre oblique finale et définissezorigindans Casdoor si les noms d'hôte sont différents. redirect_urirejeté par Casdoor : le callback ne figure pas dans les Redirect URLs de l'application. Vérifiez le schéma et le port, surtout derrière un reverse proxy.- "Missing attribute 'preferred_username'" : la portée
profilen'a pas été demandée, ou les champs de token de l'application omettentName. - Erreur 401 avec une erreur
invalid_tokensur l'API : le token provient d'une autre application (audincorrect), il a expiré, ou c'est un ID token.
Références
- Document de découverte et endpoints de Casdoor :
object/wellknown_oidc_discovery.go - Claims du token Casdoor :
object/token_jwt.go - Référence Spring Boot 3.5 : Spring Security, client OAuth2 et serveur de ressources
- Spring Security : déconnexion initiée par le client OIDC
- Spring Security : JWT pour le serveur de ressources OAuth 2.0
- Spring Cloud Gateway : factory TokenRelay GatewayFilter
- Spring Cloud Gateway : starter
spring-cloud-starter-gateway-server-webflux - Notes de version Spring Cloud 2025.0
- casdoor-spring-boot-starter