OIDC Logout

当最终用户能够登录您的应用程序时,请务必考虑他们的注销方式。 通常情况下,需要考虑三种使用场景:

  1. 我只想执行本地注销

  2. 由我的应用程序发起的注销我的应用程序和 OIDC 提供商

  3. 由 OIDC 提供商发起的注销我的应用程序和 OIDC 提供商

Local Logout

为了执行本地注销,无需特殊的 OIDC 配置,Spring Security 会自动建立一个本地注销端点,您可以使用 xref:servlet/authentication/logout.adoc[configure through the logout() DSL。

OpenID Connect 1.0 Client-Initiated Logout

OpenID Connect 会话管理 1.0 允许客户端退出提供程序中的最终用户。其中一种可用的策略是 RP-Initiated Logout

如果 OpenID 提供程序同时支持会话管理和 Discovery ,则客户端可以从 OpenID 提供程序的 Discovery Metadata 中获得 end_session_endpoint URL 。配置 ClientRegistration 及其 issuer-uri 如下所示,便可以执行此操作:

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,如 https://app.example.org 可以在请求时替换它。

OpenID Connect 1.0 Back-Channel Logout

OpenID Connect 会话管理 1.0 允许提供程序向客户端发起 API 调用,从而将最终用户退出客户端。这称为 OIDC Back-Channel Logout

为了启用它,您可以这样在 DSL 中建立后端注销端点:

  • 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 HttpSessionEventListener sessionEventListener() {
    return new HttpSessionEventListener();
}
@Bean
open fun sessionEventListener(): HttpSessionEventListener {
    return HttpSessionEventListener()
}

这将使如 HttpSession#invalidate 被调用,会话也会从内存中移除。

这就是全部!

这将建立端点 /logout/connect/back-channel/{registrationId},OIDC 提供商能请求该端点来使应用程序中最终用户给定会话失效。

oidcLogout 需要 oauth2Login 也进行配置。

oidcLogout 需要将会话 cookie 称为 JSESSIONID,才能通过后通道正确注销每个会话。

Back-Channel Logout Architecture

考虑标识符为 registrationIdClientRegistration

后端登出流程的总体流程如下:

  1. 在登录时,Spring Security 将 ID 令牌、CSRF 令牌和提供者会话 ID(如果存在)与您的应用程序的会话 id 相关联,在其中实现 OidcSessionStrategy

  2. 然后在注销时,您的 OIDC 提供商将调用 API /logout/connect/back-channel/registrationId,其中包括一个注销令牌,用于指示 sub(最终用户)或 sid(提供者会话 ID)进行注销。

  3. Spring Security 验证令牌的签名和声明。

  4. 如果令牌包含 `sid`声明,则仅终止与该提供者会话相关联的 Client 的会话。

  5. 否则,如果令牌包含 `sub`声明,则终止该 Client 的所有与最终用户相关的会话。

请记住,Spring Security 的 OIDC 支持是多租户的。这意味着它只会终止其客户端与注销令牌中的 aud 声明匹配的会话。

Customizing the OIDC Provider Session Strategy

默认情况下,Spring Security 在内存中存储 OIDC Provider 会话和客户端会话之间的所有链接。

存在多个情况,例如集群应用程序,存储在一个单独的位置(如数据库)中会很好。

你可以通过配置一个自定义 OidcSessionStrategy 来实现此目的:

  • Java

  • Kotlin

@Component
public final class MySpringDataOidcSessionStrategy implements OidcSessionStrategy {
    private final OidcProviderSessionRepository sessions;

    // ...

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

    @Override
    public OidcSessionInformation(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 MySpringDataOidcSessionStrategy: OidcSessionStrategy {
    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(...);
    }
}