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

一旦您的应用实现了请求身份认证,那么考虑如何持久化并恢复由此产生的身份认证状态以供后续请求使用就显得非常重要。

默认情况下,这是自动完成的,因此不需要额外的代码,但了解 HttpSecurity 中的 requireExplicitSave 意味着什么仍然很重要。

如果您愿意,您可以阅读有关 requireExplicitSave 功能的更多信息了解其重要性。否则,在大多数情况下,您已经完成了本节的学习。

但在离开之前,请考虑以下用例是否适用于您的应用

了解会话管理的组件

会话管理支持由几个协同工作的组件组成,以提供功能。这些组件是SecurityContextHolderFilterSecurityContextPersistenceFilterSessionManagementFilter

在 Spring Security 6 中,默认情况下不设置 SecurityContextPersistenceFilterSessionManagementFilter。此外,任何应用都应仅设置 SecurityContextHolderFilterSecurityContextPersistenceFilter,不能同时设置两者。

SessionManagementFilter

SessionManagementFilter 会对照 SecurityContextHolder 的当前内容检查 SecurityContextRepository 的内容,以确定用户在当前请求中是否已通过身份认证,这通常是通过非交互式身份认证机制实现的,例如预认证或 Remember Me [1]。如果 repository 包含安全上下文,则过滤器不做任何操作。如果 repository 不包含安全上下文,并且线程局部的 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 实例上调用单个 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" />

上述配置将 SecurityContextRepository 设置到 SecurityContextHolderFilter参与式身份认证过滤器中,例如 UsernamePasswordAuthenticationFilter。要在无状态过滤器中也进行设置,请参见如何为无状态身份认证自定义 SecurityContextRepository

如果您使用自定义身份认证机制,您可能希望自己存储 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 注入 HttpServletRequestHttpServletResponse 以便能够保存 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 中存储 HTTP Basic 身份认证信息
  • 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();
}

上述内容也适用于其他身份认证机制,例如持有者令牌身份认证

理解 requireExplicitSave

在 Spring Security 5 中,默认行为是使用SecurityContextPersistenceFilterSecurityContext自动保存到SecurityContextRepository中。保存必须在提交 HttpServletResponse 之前和 SecurityContextPersistenceFilter 之前进行。不幸的是,自动持久化 SecurityContext 在请求完成之前(即恰好在提交 HttpServletResponse 之前)完成时,可能会让用户感到意外。跟踪状态以确定是否需要保存也很复杂,有时会导致对 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 了解会话生命周期事件

  • 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。如果第二次认证通过其他非交互式机制发生,例如“remember-me”,则会向客户端发送“未经授权”(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 也会被重新提交。如果是这种情况,您可能希望配置注销时清除会话 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>

您可以在注销时显式删除 JSESSIONID Cookie,例如在注销处理程序中使用Clear-Site-Data header

  • 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 header 的容器。

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

  • 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>

有关Clear Site Data注销部分的更多详细信息。

理解会话固定攻击防护

会话固定攻击是一种潜在风险,恶意攻击者可以通过访问站点来创建一个会话,然后说服另一个用户使用相同的会话登录(例如,通过发送一个包含会话标识符作为参数的链接给他们)。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)
  1. 通过静态访问 SecurityContextHolder 创建一个空的 SecurityContext 实例。

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

  3. 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)
    }

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

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

  3. 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>

接下来阅读什么


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