身份验证持久性和会话管理
一旦你有了一个能够验证请求的应用程序,重要的是要考虑如何在未来的请求中持久化和恢复由此产生的验证。
这在默认情况下是自动完成的,因此不需要额外的代码,不过了解 HttpSecurity
中的 requireExplicitSave
的含义是很重要的。
如果你愿意,可以阅读更多关于requireExplicitSave的作用或为什么它很重要。否则,在大多数情况下,你已经完成了本节的内容。
但在你离开之前,请考虑以下这些用例是否适用于你的应用程序:
-
我想自己存储认证信息,而不是让 Spring Security 为我做这件事
-
我正在手动存储认证信息,并且我想移除它
-
我正在使用 SessionManagementFilter,并且我需要关于如何不再使用它的指导
-
我正在使用无状态认证,但我仍然希望将其存储在会话中
-
我正在使用
SessionCreationPolicy.NEVER
,但应用程序仍在创建会话。
了解会话管理的组件
会话管理支持由一些协同工作的组件组成,以提供该功能。这些组件包括,the SecurityContextHolderFilter、the SecurityContextPersistenceFilter 和 the SessionManagementFilter。
在 Spring Security 6 中,默认情况下未设置 SecurityContextPersistenceFilter
和 SessionManagementFilter
。此外,任何应用程序应该只设置 SecurityContextHolderFilter
或 SecurityContextPersistenceFilter
,绝不能同时设置两者。
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
中设置它。
- 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" />
上述配置将 SecurityContextRepository
设置在 SecurityContextHolderFilter
和 参与的 认证过滤器上,如 UsernamePasswordAuthenticationFilter
。若要同时在无状态过滤器中设置它,请参阅如何为无状态认证自定义 SecurityContextRepository。
如果你使用的是自定义的身份验证机制,你可能想要自己存储身份验证信息。
手动存储 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
}
将
SecurityContextRepository
添加到控制器注入
HttpServletRequest
和HttpServletResponse
以便能够保存SecurityContext
使用提供的凭证创建一个未认证的
UsernamePasswordAuthenticationToken
调用
AuthenticationManager#authenticate
来认证用户创建一个
SecurityContext
并在其中设置Authentication
在
SecurityContextRepository
中保存SecurityContext
就这样。如果你不确定上面示例中的 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
,并且还阻止请求被保存在会话中。
在会话中存储无状态认证
如果由于某些原因,你正在使用无状态的身份验证机制,但仍然希望将身份验证存储在会话中,你可以使用 HttpSessionSecurityContextRepository
而不是 NullSecurityContextRepository
。
对于HTTP Basic,你可以添加一个 ObjectPostProcessor,该处理器会更改 BasicAuthenticationFilter
使用的 SecurityContextRepository
:
- 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 认证。
了解 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
)来提高性能。
它是如何工作的
总之,当 requireExplicitSave
为 true
时,Spring Security 会设置 the SecurityContextHolderFilter,而不是 the 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());
}
}
你可以尝试使用 Maximum Sessions 示例。
通常,你可能希望防止第二次登录,在这种情况下,你可以使用:
- 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-management
元素添加 session-authentication-error-url
属性。
使用 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());
}
}
如果你使用自定义的身份验证过滤器进行基于表单的登录,则必须显式配置并发会话控制支持。你可以尝试使用 Maximum Sessions Prevent Login 示例。
检测超时
会话会自行过期,无需执行任何操作来确保安全上下文被移除。也就是说,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 仍会被重新提交。如果遇到这种情况,你可能需要配置注销以清除会话 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 实现集群会话
1. 通过执行认证后重定向的机制(如表单登录)进行的认证将不会被 SessionManagementFilter
检测到,因为在认证请求期间该过滤器不会被调用。在这些情况下,会话管理功能必须单独处理。