OpenID Connect を使った Spring Boot SSO: Spring Security, Resource Server および Spring Cloud Gateway と Casdoor
Spring Boot にシングルサインオンを追加するには、各アプリを Casdoor などの OpenID Connect プロバイダーのアプリケーションとして登録し、Spring Security をプロバイダーの Issuer URL に向けます。そして、アプリの種類に応じて適切な Spring モジュールを選択します。サーバー側レンダリングの Web アプリには oauth2Login()、ベアラートークンを受け取る REST API には oauth2ResourceServer().jwt()、ゲートウェイがマイクロサービス群のユーザー認証を一括で行う場合には TokenRelay フィルターを使った Spring Cloud Gateway が適しています。すべてのアプリが同じ Casdoor にユーザーを送るため、Casdoor がセッションを記憶しており、SSO が実現されます。2番目のアプリにリダイレクトされた際、Casdoor はすでにユーザーを知っているため、ログインフォームを表示せずにそのまま戻ってきます。この記事では、これらの3つの構成パターンすべてを1つの Casdoor に対してセットアップし、それぞれ動作する application.yml とカスタマイズした SecurityFilterChain を提供します。
3つの構成パターンの概要
| パターン | Spring 依存関係 | ログインを保持する主体 | 使用する Casdoor エンドポイント | フィルターチェーン |
|---|---|---|---|---|
| Web アプリ | spring-boot-starter-oauth2-client | アプリ (HTTP セッション) | discovery, authorize, token, userinfo, JWKS, logout | oauth2Login() を使った SecurityFilterChain |
| REST API | spring-boot-starter-oauth2-resource-server | なし、すべてのリクエストがベアラートークンを保持 | 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 では、2つの 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 管理コンソールでアプリケーションを作成 (または既存のものを使用) し、その Client ID と Client secret をコピーします。Spring アプリが使用するすべてのコールバック URL を Redirect URLs に追加します。Spring Security のデフォルトは {baseUrl}/login/oauth2/code/{registrationId} です。したがって、登録 ID が casdoor でポート 8080 でアプリを実行している場合、URL は http://localhost:8080/login/oauth2/code/casdoor になります。OIDC ログアウトを使用する場合は、ユーザーがログアウト後に着地するページ (例: http://localhost:8080/) も追加してください。リストに含まれていない post_logout_redirect_uri に対して Casdoor は拒否応答を返します。
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 |
サポートされているスコープは openid, email, profile, address, phone, offline_access であり、さらにアプリケーションで定義されたカスタムスコープも含まれます。トークンはデフォルトで RS256 によって署名され、証明書名が kid として含まれます。各アプリケーションには、/.well-known/<application>/openid-configuration という独自のディスカバリー文書もあります。詳細と OAuth 2.0 endpoints の完全なリストについては、Connect a standard OIDC client を参照してください。
Casdoor のトークンにおける Spring にとって重要な2点:
- Issuer は完全に一致する必要があります。 Spring は、ディスカバリー文書の
issuerを設定したissuer-uriと比較し、その後すべてのトークンのissクレームと比較します。Casdoor は、app.confのorigin設定から Issuer を導出するか、originが空の場合は Host ヘッダーから導出します。ブラウザが公開 URL を使用する一方で、Spring アプリが Docker 内の内部ホスト名 (例えばhttp://casdoor:8000) を介して Casdoor にアクセスする場合、すべてのトークンが同じissを持つようにoriginを公開 URL に設定してください。Configuration を参照してください。 - ロールは2つの形式で提供されます。
profileスコープが付与されている場合、/api/userinfoはrolesをロール名のリストとして返します。デフォルトのJWTtoken format におけるアクセストークンは、rolesをnameフィールドを持つロールオブジェクトのリストとして含みます。以下のコードは両方の形式を処理します。Casdoor のアクセストークンにはまた、スペース区切りの文字列としてのscopeも含まれており、Spring はこれをデフォルトでSCOPE_...権限に変換します。
プロトコルの背景については、What is OpenID Connect および ID token vs access token を参照してください。
1. oauth2Login() を使用した Spring Boot Web アプリ
spring-boot-starter-web、spring-boot-starter-security および spring-boot-starter-oauth2-client を追加します。次に、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
起動時、Spring Boot は https://door.example.com/.well-known/openid-configuration を取得し、authorization、token、userinfo および JWKS URI を自動的に設定します。その後、Spring は ID トークンの署名、issuer、audience および nonce を検証し、scope に profile と email が含まれているため /api/userinfo を呼び出し、クレームをマージします。preferred_username は Casdoor のユーザー名です。userinfo の name は表示名であり、デフォルトの sub はユーザーの UUID です。
独自の構成がない場合、Spring Boot はすべての URL でログインを要求します。以下のカスタマイズされた SecurityFilterChain は、ホームページへのアクセスを開き、Casdoor のロールを ROLE_ 権限にマッピングし、アプリだけでなく Casdoor からもログアウトします:
@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 を読み取り、id_token_hint と post_logout_redirect_uri を含めて Casdoor の /api/logout にリダイレクトするため、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. OAuth2 リソースサーバーとしての Spring Boot API
REST API はリダイレクトを行いません。Authorization: Bearer <access token> を受け入れ、Casdoor の JWKS に対して JWT 署名を検証し、クレームを確認します。spring-boot-starter-web、spring-boot-starter-security および 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>
このファイルだけで、Spring Boot は署名、有効期限、iss および aud をチェックする JwtDecoder を構築します。issuer-uri の横に jwk-set-uri を設定すると、キーがディスカバリ経由ではなく直接取得され、issuer も引き続き検証されます。audience はトークンを発行した Casdoor アプリケーションのクライアント ID です。SPA またはセクション 3 のゲートウェイがトークンを取得する場合、そのアプリケーションのクライアント ID を使用してください。
追加する価値のある Casdoor 固有のチェックが 1 つあります。Casdoor は ID トークンとアクセストークンを同じキーで署名し、両方にクライアント ID を audience として付与するため、デフォルトのバリデーターはベアラートークンとして送信された ID トークンも受け入れてしまいます。すべての Casdoor トークンには tokenType クレーム (access-token、id-token または refresh-token) があり、これによりチェックが簡単になります。JwtDecoder Bean を定義すると Boot のものが置き換えられるため、Bean で issuer と 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;
}
}
コンバーターは SCOPE_ 権限を保持し、roles クレームから名前またはオブジェクトの有無に関係なく ROLE_ 権限を追加し、name クレーム (Casdoor ユーザー名) を principal として使用します。アプリケーションが JWT-Standard トークン形式を使用する場合は、代わりに preferred_username を principal クレームとして使用してください。API はブラウザのクッキーを見ることがないため、ここで CSRF とセッションを無効にするのは正しい選択です。これらの 2 行を 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 トークンはユーザーではなくアプリケーションを表すため、ロールがありません。どのクレームを確認すべきか、そしてその理由の詳細については、JWT validation および OAuth scopes を参照してください。
3. TokenRelay を使用した Spring Cloud Gateway
マイクロサービスでは、ゲートウェイがログインを処理し、サービスをリソースサーバーとして維持するのが一般的なレイアウトです。ブラウザは常にゲートウェイとのみ通信し、セッションクッキーを保持します。ゲートウェイはユーザーのアクセストークンを保存し、TokenRelay フィルターが各プロキシリクエストに Authorization: Bearer ... として追加します。セクション 2 のサービスは通常どおりこれを検証します。
Spring Cloud 2025.0 BOM を使用して、spring-cloud-starter-gateway-server-webflux と spring-boot-starter-oauth2-client を追加します:
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.* が設定されている場合にのみ作成され、デフォルトの認可済みクライアントストアはメモリ内にあるため、複数のレプリカを持つゲートウェイには共有ストアまたはスティッキーセッションが必要です。背後にあるサービスでは、audiences をゲートウェイのクライアント ID に設定します。
Spring Cloud Gateway は WebFlux 上で動作するため、セキュリティ構成はサーブレットの SecurityFilterChain ではなく、ServerHttpSecurity から構築される SecurityWebFilterChain です:
@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 保護はオンのままにしておきます。
既存の 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 を取り、クラスパス上の Security starter に応じて上記の spring.security.oauth2.* プロパティを設定します。クライアントを casdoor として登録し、OAuth2 ログインとサーブレットリソースサーバーの両方について Casdoor ロールを ROLE_ 権限にマッピングし、Casdoor Java SDK サービスを Bean として公開します。そのプロパティの優先度は最低なので、それらのいずれかをオーバーライドできます。セットアップについては Spring Boot を参照してください。
よくあるエラー
- "The Issuer ... provided in the configuration metadata did not match the requested issuer":
issuer-uriが Casdoor の報告する値と一致していません。末尾のスラッシュを削除し、ホスト名が異なる場合は Casdoor でoriginを設定してください。 redirect_uriが Casdoor に拒否されました: コールバック URL がアプリケーションの Redirect URLs に含まれていません。リバースプロキシを使用している場合、特にスキームとポートを確認してください。- "Missing attribute 'preferred_username'":
profileスコープがリクエストされていないか、アプリケーションのトークンフィールドでNameが省略されています。 - API で
invalid_tokenエラーによる 401 が発生: トークンが別のアプリケーション由来(audが不正)であるか、期限切れであるか、ID トークンです。
参考文献
- Casdoor のディスカバリードキュメントおよびエンドポイント:
object/wellknown_oidc_discovery.go - Casdoor のトークンクレーム:
object/token_jwt.go - Spring Boot 3.5 リファレンス: Spring Security, OAuth2 クライアントおよびリソースサーバー
- Spring Security: OIDC クライアント発起ログアウト
- Spring Security: OAuth 2.0 リソースサーバー JWT
- Spring Cloud Gateway: TokenRelay GatewayFilter ファクトリー
- Spring Cloud Gateway: starter
spring-cloud-starter-gateway-server-webflux - Spring Cloud 2025.0 リリースノート
- casdoor-spring-boot-starter