跳到主要内容
版本:7.0.2

身份验证持久化与会话管理

DeepSeek V3 中英对照 Session Management Authentication Persistence and Session Management

一旦你拥有了一个能够验证请求的应用程序,接下来重要的是考虑如何持久化并恢复该验证结果,以便在未来的请求中使用。

默认情况下,此操作会自动完成,因此无需额外代码,但了解 HttpSecurityrequireExplicitSave 的含义非常重要。

如果你愿意,可以进一步了解 requireExplicitSave 的作用其重要性。否则,在大多数情况下,本节内容已经完成。

但在离开之前,请考虑以下应用场景是否适用于您的项目:

理解会话管理的组成部分

会话管理支持由多个协同工作的组件构成,以提供相应功能。这些组件包括:SecurityContextHolderFilterSecurityContextPersistenceFilter 以及 SessionManagementFilter

备注

在 Spring Security 6 中,默认不再配置 SecurityContextPersistenceFilterSessionManagementFilter。此外,任何应用程序应仅配置 SecurityContextHolderFilterSecurityContextPersistenceFilter 中的一个,切勿同时配置两者。

SessionManagementFilter

SessionManagementFilter 会检查 SecurityContextRepository 中的内容与 SecurityContextHolder 的当前内容,以确定用户是否在当前请求期间(通常通过非交互式认证机制,例如预认证或记住我功能[1])已被认证。如果存储库中包含安全上下文,该过滤器将不执行任何操作。如果存储库中不包含安全上下文,且线程本地的 SecurityContext 包含一个(非匿名的)Authentication 对象,该过滤器会假定用户已由过滤器链中前一个过滤器完成认证。随后,它将调用已配置的 SessionAuthenticationStrategy

如果用户当前未通过身份验证,过滤器将检查是否请求了无效的会话ID(例如由于超时),并在已配置的情况下调用 InvalidSessionStrategy。最常见的处理方式是直接重定向到固定URL,这一行为已被封装在标准实现 SimpleRedirectInvalidSessionStrategy 中。通过命名空间配置无效会话URL时(如前文所述),同样会使用此策略。

弃用 SessionManagementFilter

在 Spring Security 5 中,默认配置依赖于 SessionManagementFilter 来检测用户是否刚刚完成认证,并调用 SessionAuthenticationStrategy。但这样做的问题是,在典型的配置中,这意味着每个请求都必须读取 HttpSession

在 Spring Security 6 中,默认情况下认证机制本身必须调用 SessionAuthenticationStrategy。这意味着无需检测 Authentication 何时完成,因此无需为每个请求读取 HttpSession

迁移 SessionManagementFilter 时的注意事项

在 Spring Security 6 中,默认情况下不再使用 SessionManagementFilter,因此,sessionManagement DSL 中的某些方法将不会产生任何效果。

方法替代方案
sessionAuthenticationErrorUrl在你的认证机制中配置一个 AuthenticationFailureHandler
sessionAuthenticationFailureHandler在你的认证机制中配置一个 AuthenticationFailureHandler
sessionAuthenticationStrategy在你的认证机制中配置一个 SessionAuthenticationStrategy,如上文所述

如果你尝试使用这些方法中的任何一个,都会抛出异常。

自定义认证信息存储位置

默认情况下,Spring Security 会将安全上下文存储在 HTTP 会话中。然而,你可能出于以下几种原因想要自定义这一行为:

  • 你可能需要在 HttpSessionSecurityContextRepository 实例上调用单独的设置器

  • 你可能需要将安全上下文存储在缓存或数据库中,以实现水平扩展

首先,你需要创建一个 SecurityContextRepository 的实现,或者使用现有的实现,例如 HttpSessionSecurityContextRepository,然后你可以在 HttpSecurity 中设置它。

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
SecurityContextRepository repo = new MyCustomSecurityContextRepository();
http
// ...
.securityContext((context) -> context
.securityContextRepository(repo)
);
return http.build();
}
备注

上述配置在 SecurityContextHolderFilter参与式认证过滤器(如 UsernamePasswordAuthenticationFilter)上设置了 SecurityContextRepository。若要在无状态过滤器中也进行设置,请参阅如何为无状态认证自定义 SecurityContextRepository

如果你使用的是自定义身份验证机制,可能需要自行存储身份验证信息

手动存储 Authentication 对象

在某些情况下,例如,您可能需要手动验证用户身份,而不是依赖Spring Security过滤器。您可以使用自定义过滤器或Spring MVC控制器端点来实现这一点。如果您希望在请求之间保存身份验证信息,例如在HttpSession中,您需要执行以下操作:

private SecurityContextRepository securityContextRepository =
new HttpSessionSecurityContextRepository(); 1

@PostMapping("/login")
public void login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) { 2
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
loginRequest.getUsername(), loginRequest.getPassword()); 3
Authentication authentication = authenticationManager.authenticate(token); 4
SecurityContext context = securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication); 5
securityContextHolderStrategy.setContext(context);
securityContextRepository.saveContext(context, request, response); 6
}

class LoginRequest {

private String username;
private String password;

// getters and setters
}
  • 向控制器添加 SecurityContextRepository

  • 注入 HttpServletRequestHttpServletResponse 以便能够保存 SecurityContext

  • 使用提供的凭据创建一个未经认证的 UsernamePasswordAuthenticationToken

  • 调用 AuthenticationManager#authenticate 来认证用户

  • 创建一个 SecurityContext 并在其中设置 Authentication

  • SecurityContext 保存到 SecurityContextRepository

就是这样。如果你不确定上面示例中的 securityContextHolderStrategy 是什么,可以在使用 SecurityContextStrategy 章节中了解更多信息。

正确清除认证

如果你正在使用 Spring Security 的登出支持,那么它会为你处理许多事务,包括清除和保存上下文。但是,假设你需要手动将用户从你的应用中登出。在这种情况下,你需要确保正确清除和保存上下文

为无状态认证配置持久化

有时,我们并不需要创建和维护一个 HttpSession,例如,为了在多个请求之间保持认证状态。某些认证机制,如 HTTP Basic,是无状态的,因此会在每个请求上重新对用户进行认证。

如果您不希望创建会话,可以使用 SessionCreationPolicy.STATELESS,如下所示:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}

上述配置将 SecurityContextRepository 配置 为使用 NullSecurityContextRepository,同时 防止请求被保存到会话中

如果你正在使用 SessionCreationPolicy.NEVER,可能会注意到应用程序仍在创建 HttpSession。在大多数情况下,这是因为请求被保存在会话中,以便认证成功后重新请求已认证的资源。要避免这种情况,请参考如何防止请求被保存部分。

在会话中存储无状态认证

如果出于某些原因,您正在使用无状态的身份验证机制,但仍希望将身份验证信息存储在会话中,那么可以使用 HttpSessionSecurityContextRepository 来代替 NullSecurityContextRepository

对于 HTTP Basic 认证,你可以添加一个 ObjectPostProcessor,用于修改 BasicAuthenticationFilter 所使用的 SecurityContextRepository

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
http
// ...
.httpBasic((basic) -> basic
.addObjectPostProcessor(new ObjectPostProcessor<BasicAuthenticationFilter>() {
@Override
public <O extends BasicAuthenticationFilter> O postProcess(O filter) {
filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository());
return filter;
}
})
);

return http.build();
}

上述内容同样适用于其他认证机制,例如Bearer Token Authentication

理解显式保存要求

在Spring Security 5中,默认行为是通过SecurityContextPersistenceFilterSecurityContext自动保存到SecurityContextRepository。保存操作必须在HttpServletResponse提交之前、且在SecurityContextPersistenceFilter执行之前完成。遗憾的是,当请求尚未完成时(即在提交HttpServletResponse之前)自动持久化SecurityContext可能会让用户感到意外。同时,跟踪状态以确定是否需要保存的操作较为复杂,有时会导致对SecurityContextRepository(例如HttpSession)进行不必要的写入。

基于这些原因,SecurityContextPersistenceFilter 已被弃用,并由 SecurityContextHolderFilter 替代。在 Spring Security 6 中,默认行为是 SecurityContextHolderFilter 只会从 SecurityContextRepository 读取 SecurityContext 并将其填充到 SecurityContextHolder 中。现在,如果用户希望 SecurityContext 在请求之间保持持久化,则必须使用 SecurityContextRepository 显式地保存 SecurityContext。这种做法消除了歧义,并通过仅在必要时才写入 SecurityContextRepository(例如 HttpSession)来提高性能。

工作原理

总而言之,当 requireExplicitSavetrue 时,Spring Security 会设置 SecurityContextHolderFilter 而非 SecurityContextPersistenceFilter

配置并发会话控制

若您希望对单个用户的登录能力施加限制,Spring Security 原生支持此功能,只需进行以下简单配置。首先,您需要在配置中添加以下监听器,以便 Spring Security 能够及时获取会话生命周期事件:

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}

然后在您的安全配置中添加以下行:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement((session) -> session
.maximumSessions(1)
);
return http.build();
}

这将防止用户多次登录——第二次登录将使第一次登录失效。

您也可以根据用户身份进行调整。例如,管理员可能被允许拥有多个会话:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
AuthorizationManager<?> isAdmin = AuthorityAuthorizationManager.hasRole("ADMIN");
http
.sessionManagement((session) -> session
.maximumSessions((authentication) -> isAdmin.authorize(() -> authentication, null).isGranted() ? -1 : 1)
);
return http.build();
}

使用 Spring Boot,你可以通过以下方式测试上述配置:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class MaximumSessionsTests {

@Autowired
private MockMvc mvc;

@Test
void loginOnSecondLoginThenFirstSessionTerminated() throws Exception {
MvcResult mvcResult = this.mvc.perform(formLogin())
.andExpect(authenticated())
.andReturn();

MockHttpSession firstLoginSession = (MockHttpSession) mvcResult.getRequest().getSession();

this.mvc.perform(get("/").session(firstLoginSession))
.andExpect(authenticated());

this.mvc.perform(formLogin()).andExpect(authenticated());

// first session is terminated by second login
this.mvc.perform(get("/").session(firstLoginSession))
.andExpect(unauthenticated());
}

}

你可以尝试使用最大会话数示例

此外,通常您可能希望阻止第二次登录,在这种情况下,可以使用:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement((session) -> session
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
);
return http.build();
}

第二次登录将被拒绝。所谓“拒绝”,是指如果使用基于表单的登录,用户将被重定向到 authentication-failure-url。如果第二次认证是通过另一种非交互式机制(例如“记住我”)进行的,则会向客户端发送“未授权”(401)错误。如果您希望使用错误页面,可以在 session-management 元素中添加 session-authentication-error-url 属性。

使用 Spring Boot,你可以通过以下方式测试上述配置:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
public class MaximumSessionsPreventLoginTests {

@Autowired
private MockMvc mvc;

@Test
void loginOnSecondLoginThenPreventLogin() throws Exception {
MvcResult mvcResult = this.mvc.perform(formLogin())
.andExpect(authenticated())
.andReturn();

MockHttpSession firstLoginSession = (MockHttpSession) mvcResult.getRequest().getSession();

this.mvc.perform(get("/").session(firstLoginSession))
.andExpect(authenticated());

// second login is prevented
this.mvc.perform(formLogin()).andExpect(unauthenticated());

// first session is still valid
this.mvc.perform(get("/").session(firstLoginSession))
.andExpect(authenticated());
}

}

如果您正在为基于表单的登录使用自定义身份验证过滤器,那么您必须显式配置并发会话控制支持。您可以通过 Maximum Sessions Prevent Login 示例 来尝试此功能。

备注

如果你正在使用自定义的 UserDetails 实现,请确保重写 equals()hashCode() 方法。Spring Security 中的默认 SessionRegistry 实现依赖于一个内存中的 Map,该 Map 使用这些方法来正确识别和管理用户会话。如果未能重写这些方法,可能会导致会话跟踪和用户比较出现意外行为。

检测超时

会话会自动过期,无需额外操作来确保安全上下文被移除。尽管如此,Spring Security 能够检测到会话何时过期,并执行您指定的特定操作。例如,当用户使用已过期的会话发起请求时,您可能希望重定向到特定的端点。这可以通过 HttpSecurity 中的 invalidSessionUrl 来实现:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement((session) -> session
.invalidSessionUrl("/invalidSession")
);
return http.build();
}

请注意,若使用此机制检测会话超时,当用户注销后未关闭浏览器便重新登录时,可能会误报错误。这是因为会话失效时会话Cookie并未被清除,即使用户已注销,该Cookie仍会被重新提交。若您遇到此情况,建议配置注销功能以清除会话Cookie

自定义无效会话策略

invalidSessionUrl 是一个便捷方法,用于通过 SimpleRedirectInvalidSessionStrategy 实现 来设置 InvalidSessionStrategy。如果您想自定义行为,可以实现 InvalidSessionStrategy 接口,并使用 invalidSessionStrategy 方法进行配置:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement((session) -> session
.invalidSessionStrategy(new MyCustomInvalidSessionStrategy())
);
return http.build();
}

您可以在注销时显式删除JSESSIONID cookie,例如通过在注销处理程序中使用Clear-Site-Data标头来实现:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.logout((logout) -> logout
.addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(COOKIES)))
);
return http.build();
}

这样做的好处是与容器无关,并且适用于任何支持 Clear-Site-Data 标头的容器。

作为替代方案,您也可以在注销处理程序中使用以下语法:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.logout((logout) -> logout
.deleteCookies("JSESSIONID")
);
return http.build();
}

遗憾的是,这无法保证在所有servlet容器中都能正常工作,因此您需要在您的环境中进行测试。

备注

如果你的应用程序运行在代理服务器之后,你也可以通过配置代理服务器来移除会话cookie。例如,使用Apache HTTPD的mod_headers模块,以下指令通过在注销请求的响应中使JSESSIONID cookie过期来删除它(假设应用程序部署在/tutorial路径下):

<LocationMatch "/tutorial/logout">
Header always set Set-Cookie "JSESSIONID=;Path=/tutorial;Expires=Thu, 01 Jan 1970 00:00:00 GMT"
</LocationMatch>

更多关于清除站点数据注销部分的详细信息。

理解会话固定攻击防护

会话固定攻击是一种潜在风险,恶意攻击者可能通过访问网站创建一个会话,然后诱使另一个用户使用同一会话登录(例如,通过发送包含会话标识符作为参数的链接)。Spring Security 通过在用户登录时创建新会话或更改会话 ID来自动防范此类攻击。

配置会话固定保护

您可以通过以下三种推荐选项来控制会话固定保护策略:

  • changeSessionId - 不创建新会话,而是使用Servlet容器提供的会话固定保护机制(HttpServletRequest#changeSessionId())。此选项仅适用于Servlet 3.1(Java EE 7)及更高版本的容器,在旧版本容器中指定此选项将引发异常。该选项是Servlet 3.1及以上版本容器的默认行为。

  • newSession - 创建一个新的"干净"会话,不复制现有会话数据(Spring Security相关属性仍会被复制)。

  • migrateSession - 创建新会话并将所有现有会话属性复制到新会话中。这是Servlet 3.0或更早版本容器的默认行为。

您可以通过以下方式配置会话固定保护:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement((session) -> session
.sessionFixation((sessionFixation) -> sessionFixation
.newSession()
)
);
return http.build();
}

当会话固定保护发生时,会在应用上下文中发布一个 SessionFixationProtectionEvent。如果你使用 changeSessionId,这种保护还会导致任何 jakarta.servlet.http.HttpSessionIdListener 被通知,因此如果你的代码同时监听这两种事件,请谨慎处理。

你也可以将会话固定保护设置为 none 来禁用它,但这并不推荐,因为这会使得你的应用程序容易受到攻击。

使用 SecurityContextHolderStrategy

考虑以下代码块:

UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(), loginRequest.getPassword());
Authentication authentication = this.authenticationManager.authenticate(token);
// ...
SecurityContext context = SecurityContextHolder.createEmptyContext(); 1
context.setAuthentication(authentication); 2
SecurityContextHolder.setContext(context); 3
  1. 通过静态访问 SecurityContextHolder 创建一个空的 SecurityContext 实例。

  2. SecurityContext 实例中设置 Authentication 对象。

  3. SecurityContext 实例静态地设置到 SecurityContextHolder 中。

虽然上述代码运行良好,但它可能会产生一些不理想的效果:当组件通过 SecurityContextHolder 静态访问 SecurityContext 时,如果存在多个想要指定 SecurityContextHolderStrategy 的应用上下文,就可能产生竞态条件。这是因为在 SecurityContextHolder 中,每个类加载器只有一个策略,而不是每个应用上下文一个。

为了解决这个问题,组件可以从应用上下文中装配 SecurityContextHolderStrategy。默认情况下,它们仍会从 SecurityContextHolder 中查找策略。

这些改动主要是内部的,但它们为应用程序提供了通过自动装配 SecurityContextHolderStrategy 来替代静态访问 SecurityContext 的机会。为此,您应将代码修改如下:

public class SomeClass {

private final SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();

public void someMethod() {
UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
loginRequest.getUsername(), loginRequest.getPassword());
Authentication authentication = this.authenticationManager.authenticate(token);
// ...
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext(); 1
context.setAuthentication(authentication); 2
this.securityContextHolderStrategy.setContext(context); 3
}

}
  1. 使用配置的 SecurityContextHolderStrategy 创建一个空的 SecurityContext 实例。

  2. SecurityContext 实例中设置 Authentication 对象。

  3. SecurityContextHolderStrategy 中设置 SecurityContext 实例。

强制立即创建会话

在某些情况下,提前创建会话可能很有价值。这可以通过使用 ForceEagerSessionCreationFilter 来实现,该过滤器可通过以下方式配置:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
);
return http.build();
}

后续阅读建议


1. 通过认证后执行重定向的机制(例如表单登录)进行的认证将不会被 SessionManagementFilter 检测到,因为在认证请求期间不会调用该过滤器。在这些情况下,会话管理功能必须单独处理。