OIDC 登出

當終端使用者能夠登入您的應用程式後,考慮他們如何登出也很重要。

一般來說,有三種用例需要您考慮

  1. 我只想執行本地登出

  2. 我想由我的應用程式發起,同時登出我的應用程式和 OIDC Provider

  3. 我想由 OIDC Provider 發起,同時登出我的應用程式和 OIDC Provider

本地登出

要執行本地登出,無需特殊的 OIDC 配置。Spring Security 會自動搭建一個本地登出端點,您可以透過 配置 logout() DSL 進行配置。

OpenID Connect 1.0 客戶端發起登出

OpenID Connect 會話管理 1.0 允許使用客戶端在 Provider 端登出終端使用者。其中一種可用策略是 RP-Initiated Logout

如果 OpenID Provider 同時支援會話管理和 Discovery,客戶端可以從 OpenID Provider 的 Discovery Metadata 中獲取 end_session_endpoint URL。您可以透過配置帶有 issuer-uriClientRegistration 來實現,如下所示

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-Initiated Logout 的 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 後端通道登出

OpenID Connect 會話管理 1.0 允許 Provider 透過向客戶端發起 API 呼叫來在客戶端登出終端使用者。這被稱為 OIDC 後端通道登出

要啟用此功能,您可以在 DSL 中搭建後端通道登出端點,如下所示

  • Java

  • Kotlin

@Bean
OidcBackChannelLogoutHandler oidcLogoutHandler() {
	return new OidcBackChannelLogoutHandler();
}

@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
fun oidcLogoutHandler(): OidcBackChannelLogoutHandler {
    return OidcBackChannelLogoutHandler()
}

@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 Provider 可以透過請求此端點來使您應用程式中給定終端使用者的會話失效。

oidcLogout 要求 oauth2Login 也已配置。
oidcLogout 要求會話 cookie 必須命名為 JSESSIONID,以便通過後端通道正確登出每個會話。

後端通道登出架構

考慮一個識別符號為 registrationIdClientRegistration

後端通道登出的整體流程如下

  1. 登入時,Spring Security 在其 OidcSessionRegistry 實現中,將 ID 令牌、CSRF 令牌和 Provider 會話 ID(如有)關聯到您應用程式的會話 ID。

  2. 然後,在登出時,您的 OIDC Provider 會向 /logout/connect/back-channel/registrationId 發起 API 呼叫,並在其中包含一個 Logout Token,該令牌指示要登出的 sub(終端使用者)或 sid(Provider 會話 ID)。

  3. Spring Security 驗證令牌的簽名和宣告。

  4. 如果令牌包含 sid 宣告,則只有與該 Provider 會話相關的客戶端會話被終止。

  5. 否則,如果令牌包含 sub 宣告,則該終端使用者的所有客戶端會話都將被終止。

請記住,Spring Security 的 OIDC 支援是多租戶的。這意味著它只會終止其 Client 與 Logout Token 中的 aud 宣告匹配的會話。

這種架構實現中一個值得注意的部分是,它會為每個相應的會話在內部傳播傳入的後端通道請求。最初,這可能看起來不必要。但是,請記住 Servlet API 不提供對 HttpSession 儲存的直接訪問。透過進行內部登出呼叫,現在可以驗證相應的會話。

此外,在內部偽造登出呼叫允許針對該會話和相應的 SecurityContext 執行每組 LogoutHandler

自定義會話登出端點

釋出 OidcBackChannelLogoutHandler 後,會話登出端點為 {baseUrl}/logout/connect/back-channel/{registrationId}

如果未配置 OidcBackChannelLogoutHandler,則 URL 為 {baseUrl}/logout/connect/back-channel/{registrationId},不推薦使用此方式,因為它需要傳遞 CSRF 令牌,這可能會根據應用程式使用的倉庫型別而具有挑戰性。

如果您需要自定義端點,可以按如下方式提供 URL

  • Java

  • Kotlin

http
    // ...
    .oidcLogout((oidc) -> oidc
        .backChannel((backChannel) -> backChannel
            .logoutUri("https://:9000/logout/connect/back-channel/+{registrationId}+")
        )
    );
http {
    oidcLogout {
        backChannel {
            logoutUri = "https://:9000/logout/connect/back-channel/+{registrationId}+"
        }
    }
}

預設情況下,會話登出端點使用 JSESSIONID cookie 將會話與相應的 OidcSessionInformation 關聯起來。

然而,Spring Session 中的預設 cookie 名稱是 SESSION

您可以在 DSL 中配置 Spring Session 的 cookie 名稱,如下所示

  • Java

  • Kotlin

@Bean
OidcBackChannelLogoutHandler oidcLogoutHandler(OidcSessionRegistry sessionRegistry) {
    OidcBackChannelLogoutHandler logoutHandler = new OidcBackChannelLogoutHandler(oidcSessionRegistry);
    logoutHandler.setSessionCookieName("SESSION");
    return logoutHandler;
}
@Bean
open fun oidcLogoutHandler(val sessionRegistry: OidcSessionRegistry): OidcBackChannelLogoutHandler {
    val logoutHandler = OidcBackChannelLogoutHandler(sessionRegistry)
    logoutHandler.setSessionCookieName("SESSION")
    return logoutHandler
}

自定義 OIDC Provider 會話登錄檔

預設情況下,Spring Security 在記憶體中儲存 OIDC Provider 會話和客戶端會話之間的所有連結。

在許多情況下,例如叢集應用程式,最好將其儲存在單獨的位置,例如資料庫中。

您可以透過配置自定義 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(...);
    }
}