Перейти до основного вмісту

SSO для Spring Boot через OpenID Connect: Spring Security, Resource Server та Spring Cloud Gateway з Casdoor

· Читання за 12 хв
Yang Luo
Casdoor Maintainer

Щоб додати єдиний вхід (SSO) до Spring Boot, зареєструйте кожен додаток як application в провайдері OpenID Connect, наприклад Casdoor, вкажіть Spring Security URL емітента провайдера та оберіть модуль Spring, що відповідає вашому додатку: oauth2Login() для серверного рендерингу веб-додатків, oauth2ResourceServer().jwt() для REST API, який отримує bearer-токени, і Spring Cloud Gateway з фільтром TokenRelay, коли один шлюз здійснює вхід користувачів для набору мікросервісів. Користувачі отримують SSO, оскільки кожен додаток пересилає їх до того самого Casdoor, який запам'ятовує сесію: другий додаток виконує редирект, Casdoor вже знає користувача, і він повертається без форми входу. У цій статті налаштовано всі три варіанти проти одного Casdoor, кожен із робочим application.yml та кастомізованим SecurityFilterChain.

Три варіанти на погляд​

ВаріантЗалежність SpringХто зберігає вхідВикористовувані кінцеві точки CasdoorЛанцюжок фільтрів
Веб-додатокspring-boot-starter-oauth2-clientДодаток (HTTP-сесія)discovery, authorize, token, userinfo, JWKS, logoutSecurityFilterChain з oauth2Login()
REST APIspring-boot-starter-oauth2-resource-serverНіхто, кожен запит має bearer-токенJWKS (і discovery, якщо використовується issuer-uri)SecurityFilterChain з oauth2ResourceServer()
Шлюз + мікросервісиspring-cloud-starter-gateway-server-webflux + spring-boot-starter-oauth2-clientШлюз (WebFlux-сесія)Так само, як у веб-додатку; сервіси поза ним використовують JWKSSecurityWebFilterChain з oauth2Login(), TokenRelay на маршрутах

Припущення щодо версій: Spring Boot 3.5 зі Spring Security 6.5, Java 17 та Spring Cloud 2025.0 (Spring Cloud Gateway 4.3). У Spring Boot 4 два стартери Security мають назви spring-boot-starter-security-oauth2-client та spring-boot-starter-security-oauth2-resource-server. Gateway 4.3 і 5.0 обидва використовують префікс властивостей spring.cloud.gateway.server.webflux.*, наведений нижче; старший префікс spring.cloud.gateway.* та артефакт spring-cloud-starter-gateway вважаються застарілими починаючи з Spring Cloud 2025.0.

Що Casdoor надає Spring​

У адміністративній консолі Casdoor створіть application (або використайте наявне) та скопіюйте його Client ID і Client secret. Додайте всі callback-URL, які використовуватимуть додатки Spring, до Redirect URLs. Стандартне значення Spring Security — {baseUrl}/login/oauth2/code/{registrationId}, тому з registration ID casdoor та додатком на порту 8080 URL буде http://localhost:8080/login/oauth2/code/casdoor. Якщо ви використовуєте OIDC logout, також додайте сторінку, на яку потрапляють користувачі після цього, наприклад http://localhost:8080/; Casdoor відхиляє post_logout_redirect_uri, якого немає в списку.

Casdoor публікує стандартний документ discovery за адресою /.well-known/openid-configuration, тому Spring потрібен лише issuer. Ось кінцеві точки, які він оголошує, взяті з вихідного коду Casdoor:

Поле метаданихШлях Casdoor
issuerБекенд-джерело, напр. https://door.example.com (без завершального слеша)
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

Підтримувані scopes: openid, email, profile, address, phone та offline_access, а також будь-які власні scopes, визначені в application. Токени за замовчуванням підписані за допомогою RS256 і містять назву сертифіката як kid. Кожне application також має свій документ discovery за адресою /.well-known/<application>/openid-configuration; див. Підключення стандартного OIDC-клієнта для деталей та повного списку кінцевих точок OAuth 2.0.

Дві речі в токенах Casdoor важливі для Spring:

  • Issuer повинен точно збігатися. Spring порівнює issuer у документі discovery з налаштуваним вами issuer-uri, а пізніше з претензією iss кожного токена. Casdoor виводить issuer з параметра origin у app.conf або з заголовка Host, якщо origin порожній. Якщо ваші додатки Spring звертаються до Casdoor через внутрішнє ім'я хоста (наприклад, http://casdoor:8000 всередині Docker), тоді як браузери використовують публічне, встановіть origin на публічний URL, щоб кожен токен мав однаковий iss. Див. Конфігурація.
  • Ролі подаються у двох формах. /api/userinfo повертає roles як список назв ролей, якщо було надано scope profile. Access token у стандартному форматі токенів JWT token format містить roles як список об'єктів ролей, кожен з полем name. Код нижче обробляє обидва випадки. Access tokens Casdoor також містять scope як рядок, розділений пробілами, який Spring за замовчуванням перетворює на authorities у вигляді SCOPE_....

Якщо вам потрібна загальна інформація про протокол, перегляньте статті Що таке OpenID Connect та ID token проти access token.

1. Веб-застосунок Spring Boot з oauth2Login()​

Додайте залежності spring-boot-starter-web, spring-boot-starter-security і spring-boot-starter-oauth2-client. Потім налаштуйте провайдера через issuer:

application.yml
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

Під час запуску Spring Boot отримує дані з https://door.example.com/.well-known/openid-configuration і заповнює URI для авторизації, токенів, userinfo та JWKS. Потім Spring перевіряє підпис ID токена, issuer, audience і nonce, викликає /api/userinfo, оскільки в scope включені profile і email, та об'єднує claims. preferred_username — це ім'я користувача Casdoor; name у userinfo — відображуване ім'я, а стандартний sub — UUID користувача.

Без власної конфігурації Spring Boot вимагає входу для кожного URL. Наведений нижче налаштований SecurityFilterChain відкриває домашню сторінку, зіставляє ролі Casdoor з правами ROLE_ та виконує вихід як із Casdoor, так і із застосунку:

SecurityConfig.java
@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") відповідає користувачам із роллю admin у Casdoor. OidcClientInitiatedLogoutSuccessHandler читає end_session_endpoint з документа discovery і переспрямовує на /api/logout у Casdoor з параметрами id_token_hint і post_logout_redirect_uri, тому сесія Casdoor також завершується. Без цього /logout лише очищає сесію Spring, а наступний вхід буде безшовним (silent login).

У контролері авторизований користувач доступний як OidcUser:

@GetMapping("/me")
Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of("name", user.getName(), "email", user.getEmail(),
"authorities", user.getAuthorities().toString());
}

Старіша покрокова інструкція для цього налаштування доступна на сторінці Spring Security OAuth.

2. API Spring Boot як ресурсний сервер OAuth2​

REST API не повинно нікого переспрямовувати. Воно приймає заголовок Authorization: Bearer <access token>, перевіряє підпис JWT за допомогою JWKS Casdoor і валідує claims. Додайте залежності spring-boot-starter-web, spring-boot-starter-security і spring-boot-starter-oauth2-resource-server:

application.yml
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>

Лише за наявності цього файлу Spring Boot створює JwtDecoder, який перевіряє підпис, термін дії, iss і aud. Встановлення jwk-set-uri поряд з issuer-uri означає, що ключі отримуються напряму, а не через discovery, але issuer все одно валідується. Audience — це client ID застосунку Casdoor, який видав токен: якщо токен отримує односторінковий застосунок (SPA) або шлюз із розділу 3, використовуйте client ID цього застосунку.

Варто додати одну специфічну для Casdoor перевірку. Casdoor підписує ID токени та access токени одним і тим самим ключем і надає їм однаковий audience (client ID), тому стандартні валідатори також прийняли б ID токен, надісланий як bearer токен. Кожен токен Casdoor має claim tokenType (access-token, id-token або refresh-token), що спрощує перевірку. Означення біна JwtDecoder замінює стандартний біт Boot, тому цей біт повторно додає перевірки issuer і audience:

ApiSecurityConfig.java
@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;
}
}

Конвертер зберігає права SCOPE_, додає права ROLE_ з claim roles (незалежно від того, містить він назви чи об'єкти) і використовує claim name (ім'я користувача Casdoor) як principal. Якщо застосунок використовує формат токенів JWT-Standard, замість цього використовуйте preferred_username як claim principal. Відключення CSRF і сесій тут доречне, оскільки API ніколи не бачить браузерних cookie; не копіюйте ці два рядки у веб-застосунок.

Щоб протестувати це без браузера, отримайте машинний токен за допомогою grant типу client credentials і викличте 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

Токен client credentials представляє застосунок, а не користувача, тому він не має ролей. Для детальної інформації про те, які claims перевіряти і чому, перегляньте статті Валідація JWT та OAuth scopes.

3. Spring Cloud Gateway з TokenRelay​

У мікросервісній архітектурі поширений підхід — покладати обробку входу на шлюз, залишаючи сервіси ресурсними серверами. Браузер взаємодіє лише зі шлюзом і зберігає сесійну cookie; шлюз зберігає access токен користувача, а фільтр TokenRelay додає його як Authorization: Bearer ... до кожного проксійованого запиту. Сервіси з розділу 2 валідують його звичайним способом.

Додайте залежності spring-cloud-starter-gateway-server-webflux і spring-boot-starter-oauth2-client, використовуючи BOM Spring Cloud 2025.0:

application.yml
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= без аргументу передає токен авторизованого користувача. Фабрика фільтра TokenRelay створюється лише тоді, коли налаштовано spring.security.oauth2.client.*, а сховище авторизованих клієнтів за замовчуванням знаходиться в пам'яті, тому для шлюзу з кількома репліками потрібне спільне сховище або sticky sessions. Сервіси позаду нього мають встановити audiences на client ID шлюзу.

Spring Cloud Gateway працює на базі WebFlux, тому його конфігурація безпеки являє собою SecurityWebFilterChain, побудований з ServerHttpSecurity, а не сервлетний SecurityFilterChain:

GatewaySecurityConfig.java
@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();
}
}

Залишайте рішення щодо авторизації в сервісах, де ролі містяться в токені; шлюз лише перевіряє наявність авторизованого користувача. Захист CSRF залишається увімкненим у шлюзі, оскільки він базується на cookie.

Існуючі сторінки Spring Cloud Gateway та Spring Cloud демонструють інший дизайн, де кастомний GlobalFilter перевіряє користувача Casdoor, збереженого в сесії шлюзу.

Альтернатива: Casdoor Spring Boot starter​

Якщо ви не хочете писати YAML вручну, casdoor-spring-boot-starter версії 2.x (Spring Boot 3 і 4, Java 17+) приймає параметри casdoor.endpoint, casdoor.client-id, casdoor.client-secret, casdoor.organization-name та casdoor.application-name і встановлює наведені вище властивості spring.security.oauth2.* для будь-якого Security starter, присутнього в classpath. Він реєструє клієнта як casdoor, зіставляє ролі Casdoor з правами ROLE_ для OAuth2 login і сервлетного ресурсного сервера, а також надає сервіси Casdoor Java SDK як біни. Його властивості мають найнижчий пріоритет, тому ви можете перевизначити будь-яку з них. Інструкцію з налаштування дивіться на сторінці Spring Boot.

Поширені помилки​

  • "The Issuer ... provided in the configuration metadata did not match the requested issuer": значення issuer-uri відрізняється від того, яке повідомляє Casdoor. Видаліть будь-який кінцевий слеш і встановіть origin у Casdoor, якщо імена хостів відрізняються.
  • redirect_uri відхилено Casdoor: зворотний виклик (callback) не додано до Redirect URLs додатка. Перевірте схему та порт, особливо при роботі за реверсним проксі.
  • "Missing attribute 'preferred_username'": не було надіслано запит на область видимості (scope) profile, або поля токенів додатка не містять Name.
  • Помилка 401 з кодом invalid_token при зверненні до API: токен був отриманий іншим додатком (неправильний aud), він прострочений або це ID-токен.

Посилання​