认证持久性和会话(Session)管理

本站(springdoc.cn)中的内容来源于 spring.io ,原始版权归属于 spring.io。由 springdoc.cn 进行翻译,整理。可供个人学习、研究,未经许可,不得进行任何转载、商用或与之相关的行为。 商标声明:Spring 是 Pivotal Software, Inc. 在美国以及其他国家的商标。

一旦你有了一个 认证请求 的应用程序,重要的是要考虑如何在未来的请求中持久化和恢复所产生的认证。

这在默认情况下是自动完成的,所以不需要额外的代码,尽管知道 requireExplicitSaveHttpSecurity 中的含义很重要。

如果你喜欢,你可以 阅读更多关于 requireExplicitSave 的作用为什么它很重要 的信息。否则,在大多数情况下,你已经完成了本节的内容。

但在你离开之前,考虑一下这些用例中是否有适合你的应用:

了解会话管理的组件

会话管理支持由几个组件组成,它们一起工作以提供该功能。这些组件是: SecurityContextHolderFilterSecurityContextPersistenceFilterSessionManagementFilter

在 Spring Security 6 中,SecurityContextPersistenceFilterSessionManagementFilter 默认是不设置的。除此之外,任何应用程序只能设置 SecurityContextHolderFilterSecurityContextPersistenceFilter,而不能同时设置。

SessionManagementFilter

SessionManagementFilterSecurityContextRepository 的内容与 SecurityContextHolder 的当前内容进行对照,以确定用户在当前请求中是否已被认证,通常是通过非交互式认证机制,如预认证或 remember-me [1]

如果 repository 包含一个 security context,那么 filter 什么也不做。如果不包含,而 thread-local 的 SecurityContext 包含一个(非匿名的)Authentication 对象,那么 filter 就会认为他们已经被栈中的前一个 filter 认证了。然后,它将调用配置的 SessionAuthenticationStrategy

如果用户当前没有被认证,filter 将检查是否有无效的会话(invalid session)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

如果你试图使用这些方法中的任何一个,就会产生一个异常。

自定义认证(Authentication)的存储位置

默认情况下,Spring Security 在 HTTP 会话中为你存储 security context。然而,这里有几个原因,你可能想自定义:

  • 你可能想在 HttpSessionSecurityContextRepository 实例上调用单个 setter

  • 你可能想在缓存或数据库中存储 security context,以实现横向扩展。

首先,你需要创建一个 SecurityContextRepository 的实现,或者使用一个现有的实现,如 HttpSessionSecurityContextRepository,然后你可以在 HttpSecurity 中设置它。

Customizing the 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 和参与认证的 filter 上设置了 SecurityContextRepository,如 UsernamePasswordAuthenticationFilter。要在无状态 filter 中也设置,请看 如何为无状态认证定制 SecurityContextRepository

如果你使用一个自定义的认证机制,你可能想 自己存储 Authentication

手动存储 Authentication

例如,在某些情况下,你可能要手动验证用户,而不是依靠 Spring Security filter。你可以使用自定义 filter 或 Spring MVC controller 端点来做到这一点。如果你想在请求之间保存认证,例如在 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 添加到 controller 中。
2 注入 HttpServletRequestHttpServletResponse,以便能够保存 SecurityContext
3 使用提供的凭证创建一个未经认证的 UsernamePasswordAuthenticationToken
4 调用 AuthenticationManager#authenticate 来验证用户。
5 创建一个 SecurityContext,并在其中设置 Authentication
6 SecurityContextRepository 中保存 SecurityContext

就这样了。如果你不确定上述例子中的 securityContextHolderStrategy 是什么,你可以在 使用 SecurityContextStrategy 部分 阅读更多信息。

正确地清除 Authentication

如果你正在使用 Spring Security 的 注销支持,那么它为你处理了很多东西,包括清除和保存 context。但是,假设你需要手动将用户从你的应用程序中注销。在这种情况下,你需要确保你 正确地清除和保存 context

配置无状态认证(Authentication)的持久化

有时不需要创建和维护一个 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

Store HTTP Basic authentication in the 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 中,默认行为是使用 SecurityContextPersistenceFilterSecurityContext 自动保存到 SecurityContextRepository 中。保存必须在 HttpServletResponse 被提交之前和 SecurityContextPersistenceFilter 之前进行。不幸的是,当 SecurityContext 的自动持久化在请求完成之前(即在提交 HttpServletResponse 之前)完成时,会让用户感到惊讶。跟踪状态以确定是否需要保存也很复杂,有时会导致对 SecurityContextRepository(即 HttpSession)进行不必要的写入。

由于这些原因,SecurityContextPersistenceFilter 已被弃用,而被 SecurityContextHolderFilter 所取代。在Spring Security 6中,默认行为是 SecurityContextHolderFilter 只从 SecurityContextRepository 中读取 SecurityContext 并将其填充到 SecurityContextHolder 中。现在,如果用户希望 SecurityContext 在不同请求之间持续存在,他们必须明确地将 SecurityContextSecurityContextRepository 一起保存。这消除了歧义,并通过仅在必要时要求写入 SecurityContextRepository(即 HttpSession)来提高性能。

它是如何工作的

总之,当 requireExplicitSavetrue 时,Spring Security 设置了 SecurityContextHolderFilter 而不是 SecurityContextPersistenceFilter

配置并发会话控制

如果你希望对单个用户登录你的应用程序的能力进行限制,Spring Security 支持开箱即用,只需添加以下简单内容。首先,你需要在你的配置中添加以下 listener,以保持 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>

然后在你的 security 配置中添加以下几行:

  • 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 sample 来尝试。

同样常见的是,你希望防止第二次登录,在这种情况下,你可以使用:

  • 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。如果第二次认证是通过其他非交互式机制进行的,比如 "记住我",一个 "unauthorized"(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());
    }

}

如果你使用自定义的 authentication filter 来实现基于表单的登录,那么你必须明确配置并发会话控制支持。你可以使用 Maximum Sessions Prevent Login sample 来试试。

检测超时

会话会自行过期,不需要做任何事情来确保 security context 被删除。也就是说,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>

请注意,如果你使用这种机制来检测会话超时,如果用户注销后没有关闭浏览器又重新登录,它可能会错误地报告一个错误。这是因为当你使会话失效时,session cookie 没有被清除,即使用户已经注销,也会重新提交。如果你的情况是这样,你可能想 配置注销来清除 session 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>

你可以在注销时明确地删除 JESSIONID cookie,例如通过使用 logout handler 中的 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 的容器一起工作。

作为一种替代方法,你也可以在 logout handler 中使用以下语法:

  • 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 容器中都能工作,所以你需要在你的环境中进行测试。

如果你在代理服务器后面运行你的应用程序,你也可以通过配置代理服务器来删除 session 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>

更多详情请见 清除网站数据注销部分

了解会话固定攻击(Session Fixation Attack)保护

会话固定 攻击是一种潜在的风险,恶意攻击者有可能通过访问网站创建一个会话,然后说服另一个用户用同一个会话登录(例如,通过向他们发送一个包含会话标识符作为参数的链接)。Spring Security 通过创建一个新的会话或在用户登录时改变 session ID 来自动防止这种情况。

配置 Session Fixation 保护

你可以通过在三个推荐选项中选择来控制会话固定保护的策略:

  • 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 被发布在 application context 中。如果你使用 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 时,当有多个 application context 想要指定 SecurityContextHolderStrategy 时,这会产生竞赛条件。这是因为在 SecurityContextHolder 中,每个 classloader 有一个策略,而不是每个 application context 有一个。

为了解决这个问题,组件可以从 application context 中注入 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 检测到,因为 filter 不会在认证请求中被调用。在这些情况下,会话管理功能必须单独处理。