Spring Security: Authentication Persistence and Session Management

321 阅读7分钟

认证持久化和 Session 管理。

一旦你的应用在进行请求认证,后续的认证结果在后续的请求中被怎样持久化和存储是很重要的。

理解 Session Management’s Components

Session 管理支持是由几个组件同时提供支持来组合完成的。这些组件是:SecurityContextHolderFilterSecurityContextPersistenceFilterSessionManagementFilter

在 Spring Security 6 中, SecurityContextPersistenceFilterSessionManagementFilter 没有被默认设置。另外,SecurityContextHolderFilterSecurityContextPersistenceFilter 只能设置一个,不能两个同时设置。

SessionManagementFilter

SessionManagementFilter 根据 SecurityContextHolder 的当前内容检查 SecurityContextRepository 的内容,以确定用户在当前请求中是否已通过身份验证,通常通过非交互式身份验证机制(如pre-authentication 或者 remember-me)进行身份验证。如果仓库中包含一个安全上下文(SecurityContext),filter 什么都不会做。如果仓库中不包含,但是 thread-local 中的 SecurityContext 包含一个 Authentication 对象。filter 会假设他们已经被以前的 filter 认证过了,它会调用配置的 SessionAuthenticationStrategy

如果当前用户没有被认证,filter 会检查是否是请求了一个非法的 session ID(例如过期的 session ID)。如果有设置随后就会调用 InvalidSessionStrategy。通常的行为是重定向到一个固定 url。这一般是在一个标准的 SimpleRedirectInvalidSessionStrategy 实现中。

Customizing Where the Authentication Is Stored

定制认证的存储位置。

默认情况下, Spring Security 是把安全上下文存在 HTTP session 中的。但是在某些情况下,我们可能会希望能定制安全上下文的存储位置:

  • 比如我们希望能单独调用 HttpSessionSecurityContextRepository 的 setter 方法。
  • 比如我们希望把安全上下文存在缓存或者数据库中,方便水平扩展。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    SecurityContextRepository repo = new MyCustomSecurityContextRepository();
    http
        // ...
        .securityContext((context) -> context
            .securityContextRepository(repo)
        );
    return http.build();
}

上面的配置是把 SecurityContextRepository 设置到 SecurityContextHolderFilter 中参与到认证的 filter 中。

如果我们使用定制的认证机制,我们会希望自己存储 Authentication ,下一节展示如何自己进行存储管理。

Storing the Authentication manually

手动存储 Authentication

在某些情况下,比如我们想要手动认证,而不是依赖 Spring Security 的 filter 进行认证。我们可以定制 filter 或者 提供一个 controller 的 endpoint 来实现。比如我们想要保存请求之间的认证信息。

private SecurityContextRepository securityContextRepository =
        new HttpSessionSecurityContextRepository();

@PostMapping("/login")
public void login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) {
    UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(
        loginRequest.getUsername(), loginRequest.getPassword());
    Authentication authentication = authenticationManager.authenticate(token);
    SecurityContext context = securityContextHolderStrategy.createEmptyContext();
    context.setAuthentication(authentication);
    securityContextHolderStrategy.setContext(context);
    securityContextRepository.saveContext(context, request, response);
}

class LoginRequest {

    private String username;
    private String password;

    // getters and setters
}

上面的代码实现步骤:

  1. 添加一个 SecurityContextRepository 在 controller 中。
  2. 注入 HttpServletRequestHttpServletResponse 来保存 SecurityContext
  3. 使用提供的身份信息,创建未认证的 UsernamePasswordAuthenticationToken
  4. 调用 AuthenticationManager#authenticate 对用户进行认证。
  5. 创建 SecurityContext 并且把 Authentication 设置到 SecurityContext 中。
  6. SecurityContext 设置到 SecurityContextRepository 中。

Properly Clearing an Authentication

正确的清除认证。

如果我们使用的是 Spring Security 的登出支持,它会帮我们处理保存和清除认证上下文。但是假设我们是自己手动处理用户登出,我么那就需要正确的清除和保存登录上下文。

Configuring Persistence for Stateless Authentication

为无状态的认证配置持久化。

有时候没有必要创建和持久化 HttpSession, 比如持久化跨请求的认证。某些认证机制是无状态的,比如 HTTP Basic,因此每个请求都需要重新认证用户。

如果不想创建 session,可以像下面这样配置:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        // ...
        .sessionManagement((session) -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );
    return http.build();
}

上面的配置代码将 NullSecurityContextRepository 配置为 SecurityContextRepository,它会阻止将请求保存在会话中。

如果你使用了 SessionCreationPolicy.NEVER , 你可能会发现应用仍然会创建 HttpSession 。在大多数情况下,是因为请求被保存在会话中未被授权的资源在认证成功后重新请求。

Storing Stateless Authentication in the Session

将无状态的认证存储到 Session 中。

如果你在使用无状态认证机制,但是你仍然希望将认证存储到会话中,你可以将 NullSecurityContextRepository 替换为 HttpSessionSecurityContextRepository

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

Understanding Require Explicit Save

在 Spring Security 5 中,默认的行为是 SecurityContext 会被 SecurityContextPersistenceFilter 自动的保存到 SecurityContextRepository

在 Spring Security 6 中,默认的 SecurityContextPersistenceFilter 被替换成了 SecurityContextHolderFilterSecurityContextHolderFilter 只会从 SecurityContextRepository 中读取出 SecurityContext 并填充到 SecurityContextHolder 中。如果要持久化,需要将 requireExplicitSave 设置为 true, 此时 Spring Security 会设置用 SecurityContextPersistenceFilter 替换 SecurityContextHolderFilter

Configuring Concurrent Session Control

配置并发 session 控制。

如果你想限制单个用户登录到你的应用的能力。 Spring Security 通过下面简单的配置提供了开箱即用的支持。首先需要将以下 listener 添加到你的配置中,以保持 Spring Security 对会话生命周期事件的更新:

@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
    return new HttpSessionEventPublisher();
}

然后添加一下配置:

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

上面的配置会拒绝一个用户的多次登录,第二次登录会导致第一次登录无效。

你也可以添加配置来拒绝第二次登录。

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

上面的配置会导致第二次登录被拒绝,如果是基于表单的登录,用户会被重定向到一个被配置的 authentication-failure-url 。如果是非交互式的登录,会返回一个 401 错误。 如果你想使用错误页,可以给 session-management 元素设置一个 session-authentication-error-url 属性。

Detecting Timeouts

探测超时。

当 Session 过期后,我们不需要做任何操作来删除安全上下文。 当一个 session 过期时 Spring Security 可以探测到,并且采取你指定的动作。比如当用户用一个过期的 session 来请求时,你希望可以重定向到一个特定的 endpoint。这可以通过配置 invalidSessionUrl 来实现。

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

如果使用这种机制来探测过期的 session,如果用户在页面上登出,然后不关闭浏览器继续登入,可能会导致报错。这是因为 session 的 cookie 没有被清除,并且会在登录时被重新提交给应用。在这种情况下,应该要配置在登出时清除 session 和 cookie。

Customizing the Invalid Session Strategy

自定义无效会话策略。

invalidSessionUrl 是一种方便的方式用 SimpleRedirectInvalidSessionStrategy 设置 InvalidSessionStrategy。如果想定制行为,可以实现 InvalidSessionStrategy 接口,并且用配置使用它。

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

Clearing Session Cookies on Logout

登出时清除 session cookie。

你可以在登出时明确的删除 JSESSIONID ,使用 Clear-Site-Data header :

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

也可以用以下的方式:

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

这种方式并不能保证对每一种 servlet 容器都生效,所以需要你在每一个环境中进行测试。

如果你的应用跑在代理之后,也可以通过配置代理服务来删除 session cookie。

Understanding Session Fixation Attack Protection

固定会话攻击保护。

固定会话保护有三种推荐策略可选:

  • changeSessionId 不创建新的 session,使用 Servlet 容器提供的会话固定保护。
  • newSession 创建一个新的干净的 session,不从已经存在的 session 中复制数据。
  • migrateSession 创建一个新的 session,并复制已有的 session 的属性到新的 session 中。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
    http
        .sessionManagement((session) - session
            .sessionFixation((sessionFixation) -> sessionFixation
                .newSession()
            )
        );
    return http.build();
}

当一个固定会话攻击发生时,会有一个 SessionFixationProtectionEvent 事件发布到应用容器中。如果使用的是 changeSessionIdjakarta.servlet.http.HttpSessionIdListener 也会被通知到。

固定会话攻击保护也可以被禁用掉,但是不推荐这样做。

Using SecurityContextHolderStrategy

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 时,使用 this.securityContextHolderStrategy,而不是使用 SecurityContextHolder。
        // SecurityContext context = SecurityContextHolder.createEmptyContext();
        SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
        context.setAuthentication(authentication);
        this.securityContextHolderStrategy.setContext(context);
    }

}