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

一旦您拥有一个正在 对请求进行身份验证 的应用程序,重要的是要考虑如何持久化和恢复该身份验证结果,以便在将来的请求中使用。

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

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

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

了解会话管理的组件

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

在 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 实例上调用单个 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" />

上述配置在 SecurityContextHolderFilter参与身份验证过滤器(如 UsernamePasswordAuthenticationFilter)上设置了 SecurityContextRepository。要将其也设置在无状态过滤器中,请参阅 如何为无状态身份验证自定义 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

将 HTTP Basic 身份验证存储在 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)来提高性能。

工作原理

总之,当 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。如果第二次身份验证通过其他非交互式机制(例如“记住我”)进行,则会向客户端发送“未授权”(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>

您可以在注销时显式删除 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)
  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 检测到,因为过滤器不会在身份验证请求期间被调用。在这种情况下,会话管理功能必须单独处理。