Spring Boot SSO mit OpenID Connect: Spring Security, Resource Server und Spring Cloud Gateway mit Casdoor
Um Single Sign-On zu Spring Boot hinzuzufügen, registrieren Sie jede App als Anwendung bei einem OpenID Connect-Anbieter wie Casdoor, verweisen Sie Spring Security auf die Issuer-URL des Anbieters und wählen Sie das Spring-Modul aus, das zur App passt: oauth2Login() für eine serverseitig gerenderte Web-App, oauth2ResourceServer().jwt() für eine REST-API, die Bearer-Tokens empfängt, und Spring Cloud Gateway mit dem TokenRelay-Filter, wenn ein Gateway Benutzer für einen Satz von Microservices authentifiziert. Benutzer erhalten SSO, weil jede App sie an denselben Casdoor sendet, der sich die Sitzung merkt: Die zweite App leitet um, Casdoor kennt den Benutzer bereits, und er kehrt direkt ohne Login-Formular zurück. Dieser Beitrag richtet alle drei Varianten gegen einen Casdoor ein, jeweils mit einer funktionierenden application.yml und einer angepassten SecurityFilterChain.
Die drei Varianten im Überblick
| Variante | Spring-Abhängigkeit | Wer hält die Anmeldung | Verwendete Casdoor-Endpunkte | Filterkette |
|---|---|---|---|---|
| Web-App | spring-boot-starter-oauth2-client | Die App (HTTP-Sitzung) | discovery, authorize, token, userinfo, JWKS, logout | SecurityFilterChain mit oauth2Login() |
| REST API | spring-boot-starter-oauth2-resource-server | Niemand, jeder请求 trägt ein Bearer-Token | JWKS (und discovery, wenn Sie issuer-uri verwenden) | SecurityFilterChain mit oauth2ResourceServer() |
| Gateway + Microservices | spring-cloud-starter-gateway-server-webflux + spring-boot-starter-oauth2-client | Das Gateway (WebFlux-Sitzung) | Wie die Web-App; dahinterliegende Services nutzen JWKS | SecurityWebFilterChain mit oauth2Login(), TokenRelay auf Routen |
Versionsannahmen: Spring Boot 3.5 mit Spring Security 6.5, Java 17 und Spring Cloud 2025.0 (Spring Cloud Gateway 4.3). In Spring Boot 4 heißen die beiden Security-Starter spring-boot-starter-security-oauth2-client und spring-boot-starter-security-oauth2-resource-server. Gateway 4.3 und 5.0 verwenden beide das unten verwendete Präfix spring.cloud.gateway.server.webflux.*; das ältere Präfix spring.cloud.gateway.* und das Artefakt spring-cloud-starter-gateway sind seit Spring Cloud 2025.0 veraltet.
Was Casdoor Spring bietet
Erstellen Sie in der Casdoor-Admin-Konsole eine Anwendung (oder nutzen Sie eine vorhandene) und kopieren Sie deren Client ID und Client secret. Fügen Sie jeden Callback, den die Spring-Apps verwenden werden, zu Redirect URLs hinzu. Der Standardwert von Spring Security ist {baseUrl}/login/oauth2/code/{registrationId}, also lautet die URL bei der Registrierungs-ID casdoor und einer App auf Port 8080 http://localhost:8080/login/oauth2/code/casdoor. Wenn Sie OIDC-Logout verwenden, fügen Sie auch die Seite hinzu, auf der die Benutzer danach landen, z. B. http://localhost:8080/; Casdoor lehnt eine post_logout_redirect_uri ab, die nicht in der Liste steht.
Casdoor veröffentlicht ein standardmäßiges Discovery-Dokument unter /.well-known/openid-configuration, sodass Spring nur den Issuer benötigt. Dies sind die beworbenen Endpunkte, entnommen aus dem Casdoor-Quellcode:
| Metadatenfeld | Casdoor-Pfad |
|---|---|
issuer | Der Backend-Origin, z. B. https://door.example.com (kein abschließender Schrägstrich) |
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 |
Unterstützte Scopes sind openid, email, profile, address, phone und offline_access, sowie alle benutzerdefinierten Scopes, die für die Anwendung definiert sind. Tokens werden standardmäßig mit RS256 signiert und enthalten den Zertifikatsnamen als kid. Jede Anwendung hat auch ihr eigenes Discovery-Dokument unter /.well-known/<application>/openid-configuration; siehe Connect a standard OIDC client für Details und die vollständige Liste der OAuth 2.0 endpoints.
Zwei Dinge in Casdoors Tokens sind für Spring wichtig:
- Der Issuer muss exakt übereinstimmen. Spring vergleicht den
issuerim Discovery-Dokument mit der konfiguriertenissuer-uriund später mit demiss-Claim jedes Tokens. Casdoor leitet den Issuer aus derorigin-Einstellung inapp.confab oder vom Host-Header, wennoriginleer ist. Wenn Ihre Spring-Apps Casdoor über einen internen Hostnamen erreichen (z. B.http://casdoor:8000innerhalb von Docker), während Browser einen öffentlichen verwenden, setzen Sieoriginauf die öffentliche URL, damit jedes Token denselbenisserhält. Siehe Configuration. - Rollen kommen in zwei Formen vor.
/api/userinfogibtrolesals Liste von Rollennamen zurück, wenn der Scopeprofilegewährt wurde. Das Access-Token im Standard-JWTtoken format enthältrolesals Liste von Rollenobjekten, jeweils mit einem Feldname. Der Code unten behandelt beide Fälle. Casdoor Access-Tokens enthalten außerdemscopeals durch Leerzeichen getrennte Zeichenkette, die Spring standardmäßig inSCOPE_...Authorities umwandelt.
Wenn Sie Hintergrundwissen zum Protokoll benötigen, lesen Sie Was ist OpenID Connect und ID-Token vs. Access-Token.
1. Spring Boot Web-App mit oauth2Login()
Fügen Sie spring-boot-starter-web, spring-boot-starter-security und spring-boot-starter-oauth2-client hinzu. Konfigurieren Sie dann den Anbieter über den 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
Beim Start holt sich Spring Boot https://door.example.com/.well-known/openid-configuration und füllt die URIs für Autorisierung, Token, Userinfo und JWKS auf. Spring validiert anschließend die Signatur, den Issuer, die Audience und den Nonce des ID-Tokens, ruft /api/userinfo auf, da profile und email im Scope enthalten sind, und führt die Claims zusammen. preferred_username ist der Casdoor-Benutzername; name in der Userinfo ist der Anzeigename, und das Standard-sub ist die UUID des Benutzers.
Ohne eigene Konfiguration erfordert Spring Boot eine Anmeldung für jede URL. Die untenstehende angepasste SecurityFilterChain öffnet die Startseite, bildet Casdoor-Rollen auf ROLE_-Berechtigungen ab und meldet Benutzer sowohl von Casdoor als auch von der App ab:
@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") entspricht nun Benutzern, die die Casdoor-Rolle admin haben. OidcClientInitiatedLogoutSuccessHandler liest end_session_endpoint aus dem Discovery-Dokument und leitet zu Casdoors /api/logout weiter mit id_token_hint und post_logout_redirect_uri, sodass auch die Casdoor-Sitzung beendet wird. Ohne diesen Handler löscht /logout nur die Spring-Sitzung, und die nächste Anmeldung erfolgt stillschweigend.
In einem Controller ist der angemeldete Benutzer ein OidcUser:
@GetMapping("/me")
Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of("name", user.getName(), "email", user.getEmail(),
"authorities", user.getAuthorities().toString());
}
Die ältere Schritt-für-Schritt-Seite für diese Einrichtung finden Sie unter Spring Security OAuth.
2. Spring Boot API als OAuth2 Resource Server
Eine REST-API sollte niemanden umleiten. Sie akzeptiert Authorization: Bearer <access token>, prüft die JWT-Signatur gegen Casdoors JWKS und überprüft die Claims. Fügen Sie spring-boot-starter-web, spring-boot-starter-security und spring-boot-starter-oauth2-resource-server hinzu:
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>
Mit nur dieser Datei erstellt Spring Boot einen JwtDecoder, der die Signatur, das Ablaufdatum, iss und aud prüft. Wenn jwk-set-uri neben issuer-uri gesetzt wird, werden die Schlüssel direkt abgerufen statt über Discovery, aber der Issuer wird weiterhin validiert. Die Audience ist die Client-ID der Casdoor-Anwendung, die das Token ausgestellt hat: Wenn eine Single-Page-App oder das Gateway aus Abschnitt 3 das Token erhält, verwenden Sie die Client-ID dieser Anwendung.
Es gibt eine casdoor-spezifische Prüfung, die es wert ist, hinzugefügt zu werden. Casdoor signiert ID-Tokens und Access-Tokens mit demselben Schlüssel und weist beiden die Client-ID als Audience zu, sodass die Standard-Validatoren auch ein ID-Token akzeptieren würden, das als Bearer-Token gesendet wird. Jedes Casdoor-Token enthält einen tokenType-Claim (access-token, id-token oder refresh-token), was die Prüfung vereinfacht. Wenn Sie einen JwtDecoder-Bean definieren, ersetzt er den von Boot bereitgestellten, daher fügt der Bean die Prüfungen für Issuer und Audience wieder hinzu:
@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;
}
}
Der Converter behält die SCOPE_-Berechtigungen bei, fügt ROLE_-Berechtigungen aus dem roles-Claim hinzu (unabhängig davon, ob er Namen oder Objekte enthält) und verwendet den name-Claim (den Casdoor-Benutzernamen) als Principal. Wenn die Anwendung das JWT-Standard-Tokenformat verwendet, verwenden Sie stattdessen preferred_username als Principal-Claim. Das Deaktivieren von CSRF und Sitzungen ist hier richtig, da die API niemals Browser-Cookies sieht; kopieren Sie diese beiden Zeilen nicht in die Web-App.
Um dies ohne Browser zu testen, holen Sie sich ein Machine-Token mit dem Client Credentials Grant und rufen Sie die API auf:
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
Ein Client-Credentials-Token repräsentiert die Anwendung, nicht einen Benutzer, daher hat es keine Rollen. Weitere Informationen dazu, welche Claims geprüft werden sollten und warum, finden Sie unter JWT-Validierung und OAuth-Scopes.
3. Spring Cloud Gateway mit TokenRelay
Bei Microservices ist ein übliches Layout, dass das Gateway die Anmeldung übernimmt und die Services als Resource Server fungieren. Der Browser kommuniziert nur mit dem Gateway und hält ein Sitzungscookie; das Gateway speichert das Access-Token des Benutzers, und der TokenRelay-Filter fügt es jedem proxied Request als Authorization: Bearer ... hinzu. Die Services aus Abschnitt 2 validieren es wie üblich.
Fügen Sie spring-cloud-starter-gateway-server-webflux und spring-boot-starter-oauth2-client hinzu, zusammen mit dem Spring Cloud 2025.0 BOM:
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= ohne Argument relayed das Token des angemeldeten Benutzers. Die TokenRelay-Filter-Fabrik wird nur erstellt, wenn spring.security.oauth2.client.* konfiguriert ist, und der Standard-Authorized-Client-Speicher ist im Speicher, daher benötigt ein Gateway mit mehreren Replikas einen gemeinsamen Speicher oder Sticky Sessions. Die dahinterliegenden Services setzen audiences auf die Client-ID des Gateways.
Spring Cloud Gateway läuft auf WebFlux, daher ist seine Sicherheitskonfiguration eine SecurityWebFilterChain, die aus ServerHttpSecurity erstellt wird, nicht die Servlet-SecurityFilterChain:
@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();
}
}
Behalten Sie Autorisierungsentscheidungen in den Services, wo die Rollen im Token enthalten sind; das Gateway stellt nur sicher, dass ein angemeldeter Benutzer vorhanden ist. Der CSRF-Schutz bleibt im Gateway aktiv, da er Cookie-basiert ist.
Die vorhandenen Seiten Spring Cloud Gateway und Spring Cloud zeigen ein anderes Design, bei dem ein benutzerdefinierter GlobalFilter einen in der Gateway-Sitzung gespeicherten Casdoor-Benutzer prüft.
Alternative: der Casdoor Spring Boot Starter
Wenn Sie lieber kein YAML schreiben möchten, nimmt casdoor-spring-boot-starter 2.x (Spring Boot 3 und 4, Java 17+) casdoor.endpoint, casdoor.client-id, casdoor.client-secret, casdoor.organization-name und casdoor.application-name entgegen und setzt die oben genannten spring.security.oauth2.*-Eigenschaften für whichever Security Starter sich auf dem Classpath befindet. Es registriert den Client als casdoor, bildet Casdoor-Rollen auf ROLE_-Berechtigungen ab für sowohl OAuth2 Login als auch den Servlet Resource Server und stellt die Dienste des Casdoor Java SDK als Beans zur Verfügung. Seine Eigenschaften haben die niedrigste Priorität, sodass Sie sie weiterhin überschreiben können. Siehe Spring Boot für die Einrichtung.
Häufige Fehler
- "The Issuer ... provided in the configuration metadata did not match the requested issuer": Die
issuer-uriunterscheidet sich von dem, was Casdoor meldet. Entfernen Sie einen abschließenden Schrägstrich und setzen Sieoriginin Casdoor, falls die Hostnamen unterschiedlich sind. redirect_urivon Casdoor abgelehnt: Der Callback ist nicht in den Redirect URLs der Anwendung enthalten. Überprüfen Sie das Schema und den Port, insbesondere hinter einem Reverse Proxy.- "Missing attribute 'preferred_username'": Der
profile-Scope wurde nicht angefordert oder die Token-Felder der Anwendung lassenNameaus. - 401 mit einem
invalid_token-Fehler bei der API: Das Token stammt von einer anderen Anwendung (falscheaud), es ist abgelaufen oder es handelt sich um ein ID-Token.
Referenzen
- Casdoor Discovery-Dokument und Endpunkte:
object/wellknown_oidc_discovery.go - Casdoor-Token-Ansprüche:
object/token_jwt.go - Spring Boot 3.5 Referenz: Spring Security, OAuth2 Client und Resource Server
- Spring Security: OIDC client-initiated logout
- Spring Security: OAuth 2.0 Resource Server JWT
- Spring Cloud Gateway: TokenRelay GatewayFilter Factory
- Spring Cloud Gateway: Starter
spring-cloud-starter-gateway-server-webflux - Spring Cloud 2025.0 Release Notes
- casdoor-spring-boot-starter