OIDC Logout

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

  1. 我只想执行本地注销

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

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

Local Logout

无需特殊 OIDC 配置即可执行本地注销。Spring Security 会自动启用一个本地注销端点,可以 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

此外,您应该配置 OidcClientInitiatedServerLogoutSuccessHandler,它实现 RP 发起登出,如下所示:

  • Java

  • Kotlin

@Configuration
@EnableWebFluxSecurity
public class OAuth2LoginSecurityConfig {

	@Autowired
	private ReactiveClientRegistrationRepository clientRegistrationRepository;

	@Bean
	public SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception {
		http
			.authorizeExchange((authorize) -> authorize
				.anyExchange().authenticated()
			)
			.oauth2Login(withDefaults())
			.logout((logout) -> logout
				.logoutSuccessHandler(oidcLogoutSuccessHandler())
			);
		return http.build();
	}

	private ServerLogoutSuccessHandler oidcLogoutSuccessHandler() {
		OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler =
				new OidcClientInitiatedServerLogoutSuccessHandler(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
@EnableWebFluxSecurity
class OAuth2LoginSecurityConfig {
    @Autowired
    private lateinit var clientRegistrationRepository: ReactiveClientRegistrationRepository

    @Bean
    open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
        http {
            authorizeExchange {
                authorize(anyExchange, authenticated)
            }
            oauth2Login { }
            logout {
                logoutSuccessHandler = oidcLogoutSuccessHandler()
            }
        }
        return http.build()
    }

    private fun oidcLogoutSuccessHandler(): ServerLogoutSuccessHandler {
        val oidcLogoutSuccessHandler = OidcClientInitiatedServerLogoutSuccessHandler(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
    }
}

OidcClientInitiatedServerLogoutSuccessHandler 支持 {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 SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception {
    http
        .authorizeExchange((authorize) -> authorize
            .anyExchange().authenticated()
        )
        .oauth2Login(withDefaults())
        .oidcLogout((logout) -> logout
            .backChannel(Customizer.withDefaults())
        );
    return http.build();
}
@Bean
open fun filterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    http {
        authorizeExchange {
            authorize(anyExchange, authenticated)
        }
        oauth2Login { }
        oidcLogout {
            backChannel { }
        }
    }
    return http.build()
}

这就是全部!

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

oidcLogout 需要 oauth2Login 也进行配置。

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

Back-Channel Logout Architecture

考虑标识符为 registrationIdClientRegistration

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

  1. 在登录期间,Spring Security 将 ID 令牌、CSRF 令牌和提供程序会话 ID(如果有)与您应用程序的会话 id 关联起来,并在其 `ReactiveOidcSessionStrategy`实现中。

  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 会话和客户端会话之间的所有链接。

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

您可以通过配置一个自定义 ReactiveOidcSessionStrategy 来实现此目的,如下所示:

  • 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: ReactiveOidcSessionStrategy {
    val sessions: OidcProviderSessionRepository

    // ...

    @Override
    fun saveSessionInformation(info: OidcSessionInformation): Mono<Void> {
        return this.sessions.save(info)
    }

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

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