跳到主要内容

身份验证持久性和会话管理

QWen Max 中英对照 Session Management Authentication Persistence and Session Management

一旦你有了一个能够验证请求的应用程序,重要的是要考虑如何在未来的请求中持久化和恢复由此产生的验证。

这在默认情况下是自动完成的,因此不需要额外的代码,不过了解 HttpSecurity 中的 requireExplicitSave 的含义是很重要的。

如果你愿意,可以阅读更多关于requireExplicitSave的作用为什么它很重要。否则,在大多数情况下,你已经完成了本节的内容。

但在你离开之前,请考虑以下这些用例是否适用于你的应用程序:

了解会话管理的组件

会话管理支持由一些协同工作的组件组成,以提供该功能。这些组件包括,the SecurityContextHolderFilterthe SecurityContextPersistenceFilterthe 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

迁移 away from 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();
}
java
备注

上述配置将 SecurityContextRepository 设置在 SecurityContextHolderFilter参与的 认证过滤器上,如 UsernamePasswordAuthenticationFilter。若要同时在无状态过滤器中设置它,请参阅如何为无状态认证自定义 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
}
java
  • SecurityContextRepository 添加到控制器

  • 注入 HttpServletRequestHttpServletResponse 以便能够保存 SecurityContext

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

  • 调用 AuthenticationManager#authenticate 来认证用户

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

  • SecurityContextRepository 中保存 SecurityContext

就这样。如果你不确定上面示例中的 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();
}
java

上述配置是配置 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();
}
java

上述内容也适用于其他认证机制,比如 Bearer Token 认证

了解 Require Explicit Save

在 Spring Security 5 中,默认行为是 SecurityContext 会使用 SecurityContextRepository 自动保存到 SecurityContextPersistenceFilter。保存必须在 HttpServletResponse 被提交之前和 SecurityContextPersistenceFilter 之前完成。不幸的是,当在请求完成之前(即在 HttpServletResponse 提交之前)自动持久化 SecurityContext 时,可能会让用户感到意外。同时,跟踪状态以确定是否需要保存也很复杂,有时会导致不必要的写入 SecurityContextRepository(即 HttpSession)。

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

它是如何工作的

总之,当 requireExplicitSavetrue 时,Spring Security 会设置 the SecurityContextHolderFilter,而不是 the SecurityContextPersistenceFilter

配置并发会话控制

如果您希望限制单个用户登录您的应用程序的能力,Spring Security 本身就支持这一点,只需进行以下简单的添加。首先,您需要在配置中添加以下监听器,以使 Spring Security 能够了解会话生命周期事件:

// 代码示例
java
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
java

然后将以下几行添加到您的安全配置中:

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

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

使用 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());
}

}
java

你可以尝试使用 Maximum Sessions 示例

通常,你可能希望防止第二次登录,在这种情况下,你可以使用:

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

第二次登录将被拒绝。所谓“拒绝”,是指如果使用基于表单的登录,用户将被重定向到 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());
}

}
java

如果你使用自定义的身份验证过滤器进行基于表单的登录,则必须显式配置并发会话控制支持。你可以尝试使用 Maximum Sessions Prevent Login 示例

检测超时

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

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

请注意,如果你使用此机制来检测会话超时,那么当用户注销后又重新登录而没有关闭浏览器时,它可能会错误地报告错误。这是因为当你使会话失效时,会话 cookie 并不会被清除,并且即使用户已经注销,该 cookie 仍会被重新提交。如果遇到这种情况,你可能需要配置注销以清除会话 cookie

自定义无效会话策略

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

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

你可以在登出时显式删除 JSESSIONID cookie,例如通过在登出处理器中使用 Clear-Site-Data 头

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

这具有与容器无关的优势,并且将适用于任何支持 Clear-Site-Data 头部的容器。

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

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

不幸的是,这不能保证在每个 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>
xml

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

了解会话固定攻击防护

会话固定攻击是一种潜在风险,恶意攻击者可以通过访问网站创建一个会话,然后说服另一个用户使用相同的会话登录(例如,通过发送包含会话标识符作为参数的链接)。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();
}
java

当会话固定保护发生时,会在应用程序上下文中发布一个 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
java
  1. 通过静态访问 SecurityContextHolder 创建一个空的 SecurityContext 实例。

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

  3. 通过静态访问 SecurityContextHolder 设置 SecurityContext 实例。

虽然上面的代码可以正常工作,但它可能会产生一些不希望出现的效果:当组件通过 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
}

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

接下来阅读什么


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