身份验证持久性和会话管理
一旦您拥有一个正在 对请求进行身份验证 的应用程序,重要的是要考虑如何持久化和恢复该身份验证结果,以便在将来的请求中使用。
默认情况下,这会自动完成,因此不需要额外的代码,但了解 HttpSecurity
中的 requireExplicitSave
的含义很重要。
如果您愿意,可以阅读有关 requireExplicitSave 的更多信息 或 了解其重要性。在大多数情况下,您已经完成了本节的内容。
但在您离开之前,请考虑以下用例是否适合您的应用程序
-
我想 了解会话管理的组件
-
我想 自己存储身份验证,而不是由 Spring Security 代劳
-
我正在手动存储身份验证,并且我想 将其删除
-
我正在使用
SessionManagementFilter
,并且需要 有关如何摆脱它的指导 -
我想将身份验证存储在 会话之外的其他地方
-
我正在使用 无状态身份验证,但我 仍然希望将其存储在会话中
-
我正在使用
SessionCreationPolicy.NEVER
,但是应用程序仍然在创建会话。
了解会话管理的组件
会话管理支持由几个协同工作的组件组成,以提供功能。这些组件是SecurityContextHolderFilter
、SecurityContextPersistenceFilter
和SessionManagementFilter
。
在 Spring Security 6 中, |
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 中的一些方法将不会有任何效果。
方法 | 替代方案 |
---|---|
|
在身份验证机制中配置一个 |
|
在身份验证机制中配置一个 |
|
在身份验证机制中配置一个 |
如果尝试使用任何这些方法,将抛出异常。
自定义身份验证存储位置
默认情况下,Spring Security 会将安全上下文存储在 HTTP 会话中。但是,以下是一些可能需要自定义该行为的原因:
-
您可能希望在
HttpSessionSecurityContextRepository
实例上调用单个 setter -
您可能希望将安全上下文存储在缓存或数据库中,以实现水平扩展
首先,您需要创建一个 SecurityContextRepository
的实现,或者使用现有的实现,例如 HttpSessionSecurityContextRepository
,然后您可以在 HttpSecurity
中设置它。
SecurityContextRepository
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
SecurityContextRepository repo = new MyCustomSecurityContextRepository();
http
// ...
.securityContext((context) -> context
.securityContextRepository(repo)
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
val repo = MyCustomSecurityContextRepository()
http {
// ...
securityContext {
securityContextRepository = repo
}
}
return http.build()
}
<http security-context-repository-ref="repo">
<!-- ... -->
</http>
<bean name="repo" class="com.example.MyCustomSecurityContextRepository" />
上述配置在 |
如果您使用的是自定义身份验证机制,您可能希望 手动存储 Authentication
。
手动存储 Authentication
在某些情况下,例如,您可能需要手动验证用户,而不是依赖 Spring Security 过滤器。您可以使用自定义过滤器或 Spring MVC 控制器 端点来执行此操作。如果您想在请求之间保存身份验证,例如在 HttpSession
中,您必须这样做
-
Java
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
}
1 | 将 SecurityContextRepository 添加到控制器 |
2 | 注入 HttpServletRequest 和 HttpServletResponse 以便能够保存 SecurityContext |
3 | 使用提供的凭据创建未经身份验证的 UsernamePasswordAuthenticationToken |
4 | 调用 AuthenticationManager#authenticate 来验证用户 |
5 | 创建一个 SecurityContext 并将 Authentication 设置在其中 |
6 | 将 SecurityContext 保存到 SecurityContextRepository 中 |
就是这样。如果您不确定上面示例中的 securityContextHolderStrategy
是什么,您可以在 使用 SecurityContextStrategy
部分 中了解更多信息。
正确清除身份验证
如果您使用的是 Spring Security 的 注销支持,那么它会为您处理很多事情,包括清除和保存上下文。但是,假设您需要手动将用户注销您的应用程序。在这种情况下,您需要确保您 正确清除和保存上下文。
配置无状态身份验证的持久性
有时不需要创建和维护 HttpSession
,例如,为了在请求之间持久化身份验证。一些身份验证机制,如 HTTP Basic 是无状态的,因此,在每次请求时都会重新验证用户。
如果您不希望创建会话,您可以使用 SessionCreationPolicy.STATELESS
,如下所示
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
// ...
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
}
return http.build()
}
<http create-session="stateless">
<!-- ... -->
</http>
上面的配置是 配置 SecurityContextRepository
以使用 NullSecurityContextRepository
,并且还 防止请求保存在会话中。
如果您使用的是 SessionCreationPolicy.NEVER
,您可能会注意到应用程序仍在创建 HttpSession
。在大多数情况下,这是因为 请求保存在会话中,以便在身份验证成功后重新请求经过身份验证的资源。为了避免这种情况,请参考 如何防止请求被保存 部分。
将无状态身份验证存储在会话中
如果出于某种原因,您正在使用无状态身份验证机制,但仍希望将身份验证存储在会话中,则可以使用 HttpSessionSecurityContextRepository
而不是 NullSecurityContextRepository
。
对于 HTTP Basic,您可以添加 一个 ObjectPostProcessor
来更改 BasicAuthenticationFilter
使用的 SecurityContextRepository
HttpSession
中-
Java
@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 身份验证。
了解显式保存要求
在 Spring Security 5 中,默认行为是 SecurityContext
会自动保存到 SecurityContextRepository
中,使用 SecurityContextPersistenceFilter
。保存必须在 HttpServletResponse
提交之前和 SecurityContextPersistenceFilter
之前完成。不幸的是,SecurityContext
的自动持久化可能会在请求完成之前(即在提交 HttpServletResponse
之前)完成时让用户感到意外。它也很难跟踪状态以确定是否需要保存,从而导致在某些情况下对 SecurityContextRepository
(即 HttpSession
)进行不必要的写入。
出于这些原因,SecurityContextPersistenceFilter
已被弃用,将被 SecurityContextHolderFilter
取代。在 Spring Security 6 中,默认行为是 SecurityContextHolderFilter
将只从 SecurityContextRepository
中读取 SecurityContext
并将其填充到 SecurityContextHolder
中。现在,用户必须显式地将 SecurityContext
保存到 SecurityContextRepository
中,如果他们希望 SecurityContext
在请求之间持久化。这消除了歧义并通过仅在必要时写入 SecurityContextRepository
(即 HttpSession
)来提高性能。
工作原理
总之,当 requireExplicitSave
为 true
时,Spring Security 会设置 SecurityContextHolderFilter
而不是 SecurityContextPersistenceFilter
配置并发会话控制
如果您希望对单个用户登录应用程序的能力进行限制,Spring Security 默认支持以下简单的添加。首先,您需要将以下监听器添加到您的配置中,以使 Spring Security 更新有关会话生命周期事件的信息
-
Java
-
Kotlin
-
web.xml
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
@Bean
open fun httpSessionEventPublisher(): HttpSessionEventPublisher {
return HttpSessionEventPublisher()
}
<listener>
<listener-class>
org.springframework.security.web.session.HttpSessionEventPublisher
</listener-class>
</listener>
然后将以下行添加到您的安全配置中
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement(session -> session
.maximumSessions(1)
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
sessionManagement {
sessionConcurrency {
maximumSessions = 1
}
}
}
return http.build()
}
<http>
...
<session-management>
<concurrency-control max-sessions="1" />
</session-management>
</http>
这将阻止用户多次登录 - 第二次登录将导致第一次登录失效。
使用 Spring Boot,您可以通过以下方式测试上述配置场景
-
Java
@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
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement(session -> session
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
sessionManagement {
sessionConcurrency {
maximumSessions = 1
maxSessionsPreventsLogin = true
}
}
}
return http.build()
}
<http>
<session-management>
<concurrency-control max-sessions="1" error-if-maximum-exceeded="true" />
</session-management>
</http>
然后第二次登录将被拒绝。所谓“拒绝”,是指如果使用基于表单的登录,用户将被发送到 authentication-failure-url
。如果第二次身份验证通过其他非交互式机制(例如“记住我”)进行,则会向客户端发送“未授权”(401)错误。如果您想使用错误页面,则可以将属性 session-authentication-error-url
添加到 session-management
元素中。
使用 Spring Boot,您可以通过以下方式测试上述配置
-
Java
@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());
}
}
如果您使用的是自定义身份验证过滤器进行基于表单的登录,那么您必须显式配置并发会话控制支持。您可以使用 最大会话阻止登录示例 进行尝试。
检测超时
会话会自行过期,无需采取任何措施来确保安全上下文被移除。也就是说,Spring Security 可以检测到会话何时过期,并采取您指示的特定操作。例如,当用户使用已过期的会话发出请求时,您可能希望将其重定向到特定端点。这可以通过 `HttpSecurity` 中的 `invalidSessionUrl` 来实现。
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement(session -> session
.invalidSessionUrl("/invalidSession")
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
sessionManagement {
invalidSessionUrl = "/invalidSession"
}
}
return http.build()
}
<http>
...
<session-management invalid-session-url="/invalidSession" />
</http>
请注意,如果您使用此机制来检测会话超时,如果用户注销然后在不关闭浏览器的情况下重新登录,则可能会错误地报告错误。这是因为当您使会话失效时,会话 Cookie 不会被清除,即使用户已注销,它也会被重新提交。如果是这种情况,您可能需要 配置注销以清除会话 Cookie。
自定义无效会话策略
`invalidSessionUrl` 是使用 `SimpleRedirectInvalidSessionStrategy` 实现 设置 `InvalidSessionStrategy` 的便捷方法。如果您想自定义行为,您可以实现 `InvalidSessionStrategy` 接口,并使用 `invalidSessionStrategy` 方法进行配置。
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement(session -> session
.invalidSessionStrategy(new MyCustomInvalidSessionStrategy())
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
sessionManagement {
invalidSessionStrategy = MyCustomInvalidSessionStrategy()
}
}
return http.build()
}
<http>
...
<session-management invalid-session-strategy-ref="myCustomInvalidSessionStrategy" />
<bean name="myCustomInvalidSessionStrategy" class="com.example.MyCustomInvalidSessionStrategy" />
</http>
注销时清除会话 Cookie
您可以在注销时显式删除 JSESSIONID Cookie,例如,在注销处理程序中使用 `Clear-Site-Data` 标头
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.logout((logout) -> logout
.addLogoutHandler(new HeaderWriterLogoutHandler(new ClearSiteDataHeaderWriter(COOKIES)))
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
logout {
addLogoutHandler(HeaderWriterLogoutHandler(ClearSiteDataHeaderWriter(COOKIES)))
}
}
return http.build()
}
<http>
<logout success-handler-ref="clearSiteDataHandler" />
<b:bean id="clearSiteDataHandler" class="org.springframework.security.web.authentication.logout.HeaderWriterLogoutHandler">
<b:constructor-arg>
<b:bean class="org.springframework.security.web.header.writers.ClearSiteDataHeaderWriter">
<b:constructor-arg>
<b:list>
<b:value>COOKIES</b:value>
</b:list>
</b:constructor-arg>
</b:bean>
</b:constructor-arg>
</b:bean>
</http>
这样做的好处是与容器无关,并且适用于支持 `Clear-Site-Data` 标头的任何容器。
作为替代方案,您也可以在注销处理程序中使用以下语法
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.logout(logout -> logout
.deleteCookies("JSESSIONID")
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
logout {
deleteCookies("JSESSIONID")
}
}
return http.build()
}
<http>
<logout delete-cookies="JSESSIONID" />
</http>
不幸的是,这不能保证适用于所有 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 或更旧容器中的默认设置。
您可以通过以下方式配置会话固定保护
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement((session) -> session
.sessionFixation((sessionFixation) -> sessionFixation
.newSession()
)
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
sessionManagement {
sessionFixation {
newSession()
}
}
}
return http.build()
}
<http>
<session-management session-fixation-protection="newSession" />
</http>
当发生会话固定保护时,它会导致在应用程序上下文中发布 SessionFixationProtectionEvent
。如果您使用 changeSessionId
,此保护也会导致任何 jakarta.servlet.http.HttpSessionIdListener
被通知,因此如果您代码同时监听这两个事件,请谨慎操作。
您也可以将会话固定保护设置为 none
来禁用它,但不建议这样做,因为它会使您的应用程序容易受到攻击。
使用 SecurityContextHolderStrategy
考虑以下代码块
-
Java
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)
-
通过静态访问
SecurityContextHolder
创建一个空的SecurityContext
实例。 -
在
SecurityContext
实例中设置Authentication
对象。 -
在
SecurityContextHolder
中静态设置SecurityContext
实例。
虽然上面的代码可以正常工作,但它可能会产生一些不良影响:当组件通过 SecurityContextHolder
静态访问 SecurityContext
时,当有多个应用程序上下文想要指定 SecurityContextHolderStrategy
时,这可能会导致竞争条件。这是因为在 SecurityContextHolder
中,每个类加载器只有一个策略,而不是每个应用程序上下文只有一个策略。
为了解决这个问题,组件可以从应用程序上下文中连接 SecurityContextHolderStrategy
。默认情况下,它们仍然会从 SecurityContextHolder
中查找策略。
这些更改主要是内部的,但它们为应用程序提供了自动连接 SecurityContextHolderStrategy
而不是静态访问 SecurityContext
的机会。为此,您应该将代码更改为以下内容
-
Java
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)
}
}
-
使用配置的
SecurityContextHolderStrategy
创建一个空的SecurityContext
实例。 -
在
SecurityContext
实例中设置Authentication
对象。 -
在
SecurityContextHolderStrategy
中设置SecurityContext
实例。
强制提前创建会话
有时,提前创建会话可能很有价值。这可以通过使用 ForceEagerSessionCreationFilter
来完成,它可以使用以下方法进行配置
-
Java
-
Kotlin
-
XML
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
);
return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.ALWAYS
}
}
return http.build()
}
<http create-session="ALWAYS">
</http>
接下来阅读什么
-
使用 Spring Session 的集群会话
SessionManagementFilter
检测到,因为过滤器不会在身份验证请求期间被调用。在这种情况下,会话管理功能必须单独处理。