此版本仍在开发中,尚未被视为稳定版本。如需最新稳定版本,请使用 Spring Security 7.0.4spring-doc.cadn.net.cn

OIDC 登出

一旦最终用户能够登录到您的应用程序,就需要考虑他们如何注销。spring-doc.cadn.net.cn

一般来说,您需要考虑以下三种使用场景:spring-doc.cadn.net.cn

  1. 我只想执行本地注销spring-doc.cadn.net.cn

  2. 我希望由我的应用程序发起,同时注销我的应用程序和 OIDC 提供商spring-doc.cadn.net.cn

  3. 我希望由 OIDC 提供商发起,同时注销我的应用程序和 OIDC 提供商spring-doc.cadn.net.cn

本地登出

要执行本地注销,无需特殊的 OIDC 配置。 Spring Security 会自动提供一个本地注销端点,您可以通过 logout() DSL 进行配置spring-doc.cadn.net.cn

OpenID Connect 1.0 客户端发起的注销

OpenID Connect 会话管理 1.0 允许通过客户端(Client)将最终用户从提供者(Provider)处注销。 其中一种可用的策略是RP 发起的注销(RP-Initiated Logout)spring-doc.cadn.net.cn

如果 OpenID 提供商同时支持会话管理(Session Management)和发现(Discovery),客户端可以从 OpenID 提供商的发现元数据(Discovery Metadata)中获取 URLhttps://openid.net/specs/openid-connect-session-1_0.html#OPMetadata。 您可以通过使用 ClientRegistration 配置 issuer-uri 来实现这一点,如下所示:spring-doc.cadn.net.cn

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 发起的注销(RP-Initiated Logout),配置方式如下:spring-doc.cadn.net.cn

@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,将替换它。spring-doc.cadn.net.cn

默认情况下,OidcClientInitiatedLogoutSuccessHandler 使用标准HTTP重定向和GET 方法跳转到注销URL。 若要使用POST 请求进行注销,请将重定向策略设置为FormPostRedirectStrategy,例如通过OidcClientInitiatedLogoutSuccessHandler.setRedirectStrategy(new FormPostRedirectStrategy())实现。spring-doc.cadn.net.cn

OpenID Connect 1.0 后端通道登出

OpenID Connect 会话管理 1.0 允许通过由提供者(Provider)向客户端(Client)发起 API 调用,从而在客户端注销最终用户。 这被称为OIDC 后端通道登出(OIDC Back-Channel Logout)spring-doc.cadn.net.cn

要启用此功能,您可以在 DSL 中按如下方式设置 Back-Channel Logout(后端通道登出)端点:spring-doc.cadn.net.cn

@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 {
        authorizeHttpRequests {
            authorize(anyRequest, authenticated)
        }
        oauth2Login { }
        oidcLogout {
            backChannel { }
        }
    }
    return http.build()
}

然后,你需要一种方式来监听 Spring Security 发布的事件,以便移除旧的 OidcSessionInformation 条目,如下所示:spring-doc.cadn.net.cn

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

这样设置后,如果调用 HttpSession#invalidate,该会话也会从内存中移除。spring-doc.cadn.net.cn

就这样了!spring-doc.cadn.net.cn

这将启用端点 /logout/connect/back-channel/{registrationId},供 OIDC 提供商请求以使您应用程序中指定终端用户的会话失效。spring-doc.cadn.net.cn

oidcLogout 要求同时配置 oauth2Login
oidcLogout 要求会话 cookie 的名称必须为 JSESSIONID,以便通过后端通道正确注销每个会话。

后通道登出架构

考虑一个标识符为 ClientRegistrationregistrationIdspring-doc.cadn.net.cn

后端通道(Back-Channel)注销的整体流程如下所示:spring-doc.cadn.net.cn

  1. 在登录时,Spring Security 会将其 OidcSessionRegistry 实现中将 ID Token、CSRF Token 和提供者会话 ID(如果有的话)与您的应用程序会话 ID 进行关联。spring-doc.cadn.net.cn

  2. 然后在注销时,您的 OIDC 提供商会调用 /logout/connect/back-channel/registrationId 接口,并附带一个注销Tokens(Logout Token),该Tokens指明要注销的用户 sub(最终用户)或 sid(提供商会话 ID)。spring-doc.cadn.net.cn

  3. Spring Security 验证Tokens的签名和声明。spring-doc.cadn.net.cn

  4. 如果Tokens包含 sid 声明,则仅终止与该提供者会话相关联的客户端会话。spring-doc.cadn.net.cn

  5. 否则,如果Tokens包含 sub 声明,则该终端用户的所有客户端会话都将被终止。spring-doc.cadn.net.cn

请记住,Spring Security 的 OIDC 支持是多租户的。 这意味着它只会终止那些客户端与注销Tokens(Logout Token)中的 aud 声明相匹配的会话。

该架构实现中一个值得注意的部分是,它会为每个对应的会话在内部传播传入的后端通道请求。 起初,这看起来似乎没有必要。 然而,请记住,Servlet API 并未提供对 HttpSession 存储的直接访问权限。 通过发起一次内部注销调用,现在就可以使相应的会话失效。spring-doc.cadn.net.cn

此外,在内部伪造一个注销调用,可使每组 LogoutHandler 针对该会话及其对应的 SecurityContext 执行。spring-doc.cadn.net.cn

自定义会话登出端点

发布 OidcBackChannelLogoutHandler 后,会话注销端点为 {baseUrl}/logout/connect/back-channel/{registrationId}spring-doc.cadn.net.cn

如果未连接 OidcBackChannelLogoutHandler,那么 URL 是 {baseUrl}/logout/connect/back-channel/{registrationId},这不被推荐,因为需要传递一个 CSRF Tokens,这取决于应用程序使用的仓库类型可能会比较困难。spring-doc.cadn.net.cn

如果你需要自定义端点,可以按如下方式提供 URL:spring-doc.cadn.net.cn

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

默认情况下,会话注销端点使用 JSESSIONID cookie 来关联会话与对应的 OidcSessionInformationspring-doc.cadn.net.cn

然而,Spring Session 中的默认 Cookie 名称为 SESSIONspring-doc.cadn.net.cn

你可以在 DSL 中像这样配置 Spring Session 的 Cookie 名称:spring-doc.cadn.net.cn

@Bean
OidcBackChannelLogoutHandler oidcLogoutHandler(OidcSessionRegistry oidcSessionRegistry) {
    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 提供商会话注册表

默认情况下,Spring Security 会在内存中存储 OIDC 提供商会话与客户端会话之间的所有关联。spring-doc.cadn.net.cn

在某些情况下(例如集群应用程序),将此内容存储在单独的位置(如数据库)中会更为理想。spring-doc.cadn.net.cn

你可以通过配置一个自定义的 OidcSessionRegistry 来实现这一点,如下所示:spring-doc.cadn.net.cn

@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(...);
    }
}