OIDC 注销

一旦最终用户能够登录到您的应用程序,务必考虑他们将如何注销。

一般来说,您需要考虑三种用例:

  1. 我只想执行本地注销

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

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

本地注销

要执行本地注销,不需要特殊的 OIDC 配置。Spring Security 自动启动一个本地注销端点,您可以通过 logout() DSL 进行配置

OpenID Connect 1.0 客户端发起注销

OpenID Connect 会话管理 1.0 允许使用客户端注销提供商的最终用户。可用的策略之一是RP 发起的注销

如果 OpenID 提供商同时支持会话管理和发现,则客户端可以从 OpenID 提供商的发现元数据中获取 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

此外,您应该配置 OidcClientInitiatedLogoutSuccessHandler,它实现了 RP 发起的注销,如下所示:

  • 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 允许通过让提供商向客户端发出 API 调用来注销客户端的最终用户。这称为OIDC 后端通道注销

要启用此功能,您可以在 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 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,以便通过后端通道正确注销每个会话。

后端通道注销架构

考虑一个标识符为 registrationIdClientRegistration

后端通道注销的整体流程如下所示:

  1. 登录时,Spring Security 会在其OidcSessionRegistry实现中将 ID 令牌、CSRF 令牌和提供程序会话 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(...);
    }
}