跳到主内容

Spring Boot SSO with OpenID Connect: Spring Security, Resource Server and Spring Cloud Gateway with Casdoor

· 阅读时间:一分钟
Yang Luo
Casdoor Maintainer

要为 Spring Boot 添加单点登录 (SSO),请将每个应用注册为 OpenID Connect 提供商(如 Casdoor)中的应用,将 Spring Security 指向该提供商的 issuer URL,并选择与应用匹配的 Spring 模块:对于服务器端渲染的 Web 应用使用 oauth2Login(),对于接收 bearer 令牌的 REST API 使用 oauth2ResourceServer().jwt(),当通过一个网关为一组微服务用户登录时,使用带有 TokenRelay 过滤器的 Spring Cloud Gateway。用户获得 SSO 是因为所有应用都将他们发送到同一个 Casdoor,后者记住了会话:第二个应用重定向时,Casdoor 已经知道该用户,因此他们会直接返回而无需显示登录表单。本文针对单个 Casdoor 实例设置这三种模式,每种模式都包含可用的 application.yml 和自定义的 SecurityFilterChain。

三种模式概览​

模式Spring 依赖谁持有登录状态使用的 Casdoor 端点过滤器链
Web 应用spring-boot-starter-oauth2-client应用(HTTP 会话)discovery, authorize, token, userinfo, JWKS, logout带有 oauth2Login() 的 SecurityFilterChain
REST APIspring-boot-starter-oauth2-resource-server无人,每个请求携带 bearer 令牌JWKS(如果使用 issuer-uri 则还有 discovery)带有 oauth2ResourceServer() 的 SecurityFilterChain
网关 + 微服务spring-cloud-starter-gateway-server-webflux + spring-boot-starter-oauth2-client网关(WebFlux 会话)与 Web 应用相同;其后的服务使用 JWKS带有 oauth2Login() 的 SecurityWebFilterChain,路由上配置 TokenRelay

版本假设:Spring Boot 3.5 搭配 Spring Security 6.5、Java 17 以及 Spring Cloud 2025.0(Spring Cloud Gateway 4.3)。在 Spring Boot 4 中,这两个 Security starter 的名称分别为 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 2025.0 起,旧的 spring.cloud.gateway.* 前缀和 spring-cloud-starter-gateway artifact 已被弃用。

Casdoor 为 Spring 提供的内容​

在 Casdoor 管理控制台中,创建一个应用(或复用现有应用)并复制其 Client ID 和 Client secret。将 Spring 应用将使用的所有回调添加到 Redirect URLs 中。Spring Security 的默认值是 {baseUrl}/login/oauth2/code/{registrationId},因此如果 registration ID 为 casdoor 且应用运行在 8080 端口,URL 将是 http://localhost:8080/login/oauth2/code/casdoor。如果你使用 OIDC 登出,还需添加用户随后着陆的页面,例如 http://localhost:8080/;Casdoor 会拒绝不在列表中的 post_logout_redirect_uri。

Casdoor 在 /.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

支持的 scope 包括 openid, email, profile, address, phone 和 offline_access,以及应用上定义的任何自定义 scope。令牌默认使用 RS256 签名,并将证书名称作为 kid 携带。每个应用在其自己的发现文档 /.well-known/<application>/openid-configuration 中也有详细信息;有关详情和完整的 OAuth 2.0 endpoints 列表,请参阅 Connect a standard OIDC client。

Casdoor 令牌中有两点需要注意以适配 Spring:

  • Issuer 必须完全匹配。 Spring 会将发现文档中的 issuer 与你配置的 issuer-uri 进行比较,并在后续验证每个令牌的 iss claim。Casdoor 从 app.conf 中的 origin 设置派生 issuer,或者当 origin 为空时从 Host header 派生。如果你的 Spring 应用通过内部主机名(例如 Docker 内部的 http://casdoor:8000)访问 Casdoor,而浏览器使用公共主机名,请将 origin 设置为公共 URL,以便每个令牌都具有相同的 iss。参见 Configuration。
  • 角色有两种形式。 当授予了 profile scope 时,/api/userinfo 返回 roles 作为角色名称列表。在默认的 JWT token format 中,access token 包含 roles 作为角色对象列表,每个对象都有一个 name 字段。下面的代码处理这两种情况。Casdoor access token 还携带 scope 作为空格分隔的字符串,Spring 默认将其转换为 SCOPE_... authorities。

如果你想了解该协议的背景,请参阅 What is OpenID Connect 和 ID token vs access token。

1. Spring Boot Web 应用使用 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 并填充授权、令牌、用户信息和 JWKS URI。随后 Spring 验证 ID 令牌的签名、issuer、audience 和 nonce,因为 scope 中包含 profile 和 email,所以调用 /api/userinfo 并合并 claims。preferred_username 是 Casdoor 用户名;userinfo 中的 name 是显示名称,默认的 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") 现在匹配拥有 Casdoor 角色 admin 的用户。OidcClientInitiatedLogoutSuccessHandler 从发现文档中读取 end_session_endpoint,并重定向到 Casdoor 的 /api/logout,附带 id_token_hint 和 post_logout_redirect_uri,从而结束 Casdoor 会话。如果没有它,/logout 仅清除 Spring 会话,下次登录将是静默的。

在控制器中,已登录用户是一个 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. Spring Boot API 作为 OAuth2 资源服务器​

REST API 不应重定向任何人。它接受 Authorization: Bearer <access token>,根据 Casdoor 的 JWKS 检查 JWT 签名,并检查 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。在 issuer-uri 旁边设置 jwk-set-uri 意味着密钥是直接获取的,而不是通过发现机制获取,但 issuer 仍会被验证。Audience 是签发令牌的 Casdoor 应用的 client ID:如果令牌是由单页应用或第 3 节中的网关获取的,请使用该应用的 client ID。

有一个值得添加的 Casdoor 特定检查。Casdoor 使用相同的密钥对 ID 令牌和访问令牌进行签名,并将两者都赋予客户端 ID 作为 audience,因此默认验证器也会接受作为 bearer 令牌发送的 ID 令牌。每个 Casdoor 令牌都有一个 tokenType claim(access-token、id-token 或 refresh-token),这使得检查变得简单。定义 JwtDecoder bean 会替换 Boot 的默认实现,因此该 bean 需要重新添加 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_ 权限,从 roles claim 中添加 ROLE_ 权限(无论其中包含的是名称还是对象),并使用 name claim(即 Casdoor 用户名)作为 principal。如果应用使用 JWT-Standard 令牌格式,请改用 preferred_username 作为 principal claim。在这里禁用 CSRF 和 session 是正确的,因为 API 永远不会看到浏览器 cookie;不要将这两行代码复制到 Web 应用中。

要在没有浏览器的情况下尝试,请使用 client credentials grant 获取机器令牌并调用 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 validation 和 OAuth scopes。

3. 使用 TokenRelay 的 Spring Cloud Gateway​

在微服务架构中,常见的布局是让网关处理登录,并将服务保持为资源服务器。浏览器只与网关通信并持有 session cookie;网关存储用户的访问令牌,并且 TokenRelay 过滤器将其作为 Authorization: Bearer ... 添加到每个代理请求中。第 2 节中的服务照常对其进行验证。

添加 spring-cloud-starter-gateway-server-webflux 和 spring-boot-starter-oauth2-client,并使用 Spring Cloud 2025.0 BOM:

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= 中继已登录用户的令牌。只有当配置了 spring.security.oauth2.client.* 时才会创建 TokenRelay 过滤器工厂,且默认的 authorized-client store 是在内存中,因此具有多个副本的网关需要一个共享 store 或粘性会话。其后的服务将 audiences 设置为网关的 client ID。

Spring Cloud Gateway 运行在 WebFlux 上,因此其安全配置是从 ServerHttpSecurity 构建的 SecurityWebFilterChain,而不是 servlet 的 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();
}
}

将授权决策保留在服务中,那里有令牌中的角色;网关只确保存在已登录用户。由于网关是基于 cookie 的,CSRF 保护保持开启。

现有的 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,并根据 classpath 上的 Security starter 设置上述 spring.security.oauth2.* 属性。它将客户端注册为 casdoor,为 OAuth2 登录和 servlet 资源服务器将 Casdoor 角色映射到 ROLE_ 权限,并将 Casdoor Java SDK 服务暴露为 beans。其属性优先级最低,因此你仍然可以覆盖其中的任何属性。有关设置信息,请参阅 Spring Boot。

常见错误​

  • "The Issuer ... provided in the configuration metadata did not match the requested issuer":issuer-uri 与 Casdoor 报告的 issuer 不一致。请移除末尾的斜杠,如果主机名不同,请在 Casdoor 中设置 origin。
  • redirect_uri 被 Casdoor 拒绝:回调地址不在应用的 Redirect URLs 中。请检查协议和端口,特别是在反向代理之后。
  • "Missing attribute 'preferred_username'":未请求 profile 范围,或应用的令牌字段未包含 Name。
  • API 返回带有 invalid_token 错误的 401:令牌来自其他应用(aud 错误),已过期,或者是 ID 令牌。

参考资料​