OIDC 登出

一旦最終使用者能夠登入您的應用程式,考量他們將如何登出是很重要的。

一般來說,有三種使用案例供您考量

  1. 我只想執行本地登出

  2. 我想登出我的應用程式和 OIDC 提供者,由我的應用程式啟動

  3. 我想登出我的應用程式和 OIDC 提供者,由 OIDC 提供者啟動

本地登出

若要執行本地登出,不需要特殊的 OIDC 組態。Spring Security 會自動架設本地登出端點,您可以透過 logout() DSL 進行組態

OpenID Connect 1.0 用戶端啟動的登出

OpenID Connect Session Management 1.0 允許透過用戶端登出提供者端的最終使用者。其中一種可用的策略是 RP 啟動的登出

如果 OpenID 提供者同時支援 Session Management 和 Discovery,則用戶端可以從 OpenID 提供者的 Discovery Metadata 取得 end_session_endpoint URL。您可以透過使用 issuer-uri 組態 ClientRegistration 來達成,如下所示

spring:
  security:
    oauth2:
      client:
        registration:
          okta:
            client-id: okta-client-id
            client-secret: okta-client-secret
            ...
        provider:
          okta:
            issuer-uri: https://dev-1234.oktapreview.com

此外,您應該組態實作 RP 啟動登出的 OidcClientInitiatedLogoutSuccessHandler,如下所示

  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class OAuth2LoginSecurityConfig {

	@Autowired
	private ClientRegistrationRepository clientRegistrationRepository;

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http
			.authorizeHttpRequests(authorize -> authorize
				.anyRequest().authenticated()
			)
			.oauth2Login(withDefaults())
			.logout(logout -> logout
				.logoutSuccessHandler(oidcLogoutSuccessHandler())
			);
		return http.build();
	}

	private LogoutSuccessHandler oidcLogoutSuccessHandler() {
		OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
				new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);

		// Sets the location that the End-User's User Agent will be redirected to
		// after the logout has been performed at the Provider
		oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");

		return oidcLogoutSuccessHandler;
	}
}
@Configuration
@EnableWebSecurity
class OAuth2LoginSecurityConfig {
    @Autowired
    private lateinit var clientRegistrationRepository: ClientRegistrationRepository

    @Bean
    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeHttpRequests {
                authorize(anyRequest, authenticated)
            }
            oauth2Login { }
            logout {
                logoutSuccessHandler = oidcLogoutSuccessHandler()
            }
        }
        return http.build()
    }

    private fun oidcLogoutSuccessHandler(): LogoutSuccessHandler {
        val oidcLogoutSuccessHandler = OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository)

        // Sets the location that the End-User's User Agent will be redirected to
        // after the logout has been performed at the Provider
        oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}")
        return oidcLogoutSuccessHandler
    }
}

OidcClientInitiatedLogoutSuccessHandler 支援 {baseUrl} 預留位置。如果使用,應用程式的基礎 URL,例如 app.example.org,會在請求時取代它。

OpenID Connect 1.0 Back-Channel 登出

OpenID Connect Session Management 1.0 允許透過讓提供者對用戶端進行 API 呼叫,在用戶端登出最終使用者。這稱為 OIDC Back-Channel 登出

若要啟用此功能,您可以在 DSL 中架設 Back-Channel 登出端點,如下所示

  • Java

  • Kotlin

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests((authorize) -> authorize
            .anyRequest().authenticated()
        )
        .oauth2Login(withDefaults())
        .oidcLogout((logout) -> logout
            .backChannel(Customizer.withDefaults())
        );
    return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        authorizeRequests {
            authorize(anyRequest, authenticated)
        }
        oauth2Login { }
        oidcLogout {
            backChannel { }
        }
    }
    return http.build()
}

然後,您需要一種方式來監聽 Spring Security 發布的事件,以移除舊的 OidcSessionInformation 項目,如下所示

  • Java

  • Kotlin

@Bean
public HttpSessionEventPublisher sessionEventPublisher() {
    return new HttpSessionEventPublisher();
}
@Bean
open fun sessionEventPublisher(): HttpSessionEventPublisher {
    return HttpSessionEventPublisher()
}

這將使其在呼叫 HttpSession#invalidate 時,工作階段也會從記憶體中移除。

就這樣!

這將架設端點 /logout/connect/back-channel/{registrationId},OIDC 提供者可以請求該端點來使您的應用程式中最終使用者的給定工作階段失效。

oidcLogout 需要同時組態 oauth2Login
oidcLogout 需要將工作階段 Cookie 命名為 JSESSIONID,才能透過 Back-Channel 正確登出每個工作階段。

Back-Channel 登出架構

考量 ClientRegistration,其識別碼為 registrationId

Back-Channel 登出的整體流程如下

  1. 在登入時,Spring Security 會在其 OidcSessionRegistry 實作中將 ID Token、CSRF Token 和提供者工作階段 ID (如果有的話) 與您應用程式的工作階段 ID 關聯起來。

  2. 然後在登出時,您的 OIDC 提供者會對 /logout/connect/back-channel/registrationId 進行 API 呼叫,其中包含一個登出權杖,指示要登出的 sub (最終使用者) 或 sid (提供者工作階段 ID)。

  3. Spring Security 會驗證權杖的簽章和宣告。

  4. 如果權杖包含 sid 宣告,則只會終止與該提供者工作階段相關聯的用戶端工作階段。

  5. 否則,如果權杖包含 sub 宣告,則會終止該最終使用者的所有用戶端工作階段。

請記住,Spring Security 的 OIDC 支援是多租戶的。這表示它只會終止用戶端符合登出權杖中 aud 宣告的工作階段。

自訂 OIDC 提供者工作階段登錄

預設情況下,Spring Security 會在記憶體中儲存 OIDC 提供者工作階段與用戶端工作階段之間的所有連結。

在許多情況下,例如叢集應用程式,最好將此儲存在不同的位置,例如資料庫。

您可以透過組態自訂 OidcSessionRegistry 來達成此目的,如下所示

  • Java

  • Kotlin

@Component
public final class MySpringDataOidcSessionRegistry implements OidcSessionRegistry {
    private final OidcProviderSessionRepository sessions;

    // ...

    @Override
    public void saveSessionInformation(OidcSessionInformation info) {
        this.sessions.save(info);
    }

    @Override
    public OidcSessionInformation removeSessionInformation(String clientSessionId) {
       return this.sessions.removeByClientSessionId(clientSessionId);
    }

    @Override
    public Iterable<OidcSessionInformation> removeSessionInformation(OidcLogoutToken token) {
        return token.getSessionId() != null ?
            this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
            this.sessions.removeBySubjectAndIssuerAndAudience(...);
    }
}
@Component
class MySpringDataOidcSessionRegistry: OidcSessionRegistry {
    val sessions: OidcProviderSessionRepository

    // ...

    @Override
    fun saveSessionInformation(info: OidcSessionInformation) {
        this.sessions.save(info)
    }

    @Override
    fun removeSessionInformation(clientSessionId: String): OidcSessionInformation {
       return this.sessions.removeByClientSessionId(clientSessionId);
    }

    @Override
    fun removeSessionInformation(token: OidcLogoutToken): Iterable<OidcSessionInformation> {
        return token.getSessionId() != null ?
            this.sessions.removeBySessionIdAndIssuerAndAudience(...) :
            this.sessions.removeBySubjectAndIssuerAndAudience(...);
    }
}