メインコンテンツにスキップ

OpenID Connect を使った Spring Boot SSO: Spring Security, Resource Server および Spring Cloud Gateway と Casdoor

· 1分読む
Yang Luo
Casdoor Maintainer

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, logoutoauth2Login() を使った SecurityFilterChain
REST APIspring-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 をロール名のリストとして返します。デフォルトの JWT token 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 経由でプロバイダーを設定します:

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 を取得し、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 からもログアウトします:

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 を読み取り、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 を追加します:

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 は署名、有効期限、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 のチェックを再追加します:

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 クレームから名前またはオブジェクトの有無に関係なく 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 を追加します:

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.* が設定されている場合にのみ作成され、デフォルトの認可済みクライアントストアはメモリ内にあるため、複数のレプリカを持つゲートウェイには共有ストアまたはスティッキーセッションが必要です。背後にあるサービスでは、audiences をゲートウェイのクライアント ID に設定します。

Spring Cloud Gateway は WebFlux 上で動作するため、セキュリティ構成はサーブレットの SecurityFilterChain ではなく、ServerHttpSecurity から構築される SecurityWebFilterChain です:

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 保護はオンのままにしておきます。

既存の 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 トークンです。

参考文献​