前置芝士
了解Session
Session就是会话(会话管理,Session Management),是指服务器用来跟踪用户状态的一种机制。
因为 HTTP 协议是无状态(Stateless)的。这意味着服务器处理完你的一个请求后,就会立刻“忘记”你是谁。
一旦你有了一个 认证请求 的应用程序,重要的是要考虑如何在未来的请求中持久化和恢复所产生的认证。
Spring Security 虽然会在默认情况下是自动完成的,所以不需要额外的代码。但是,如果没有 Session,你每点击一个网页都需要重新登录。
那么它是如何工作的呢?当你登录成功后,服务器会在内存(或数据库)中开辟一块空间存储你的信息,并生成一个唯一的 Session ID。
服务器通过响应头将这个 Session ID 发给浏览器,浏览器通常将其存入 Cookie 中,这就是使用 Session-Cookie 方案进行身份验证。后续你发送请求时,浏览器会自动带上这个 Cookie,服务器识别 ID 后就知道:“哦,又是那个已经登录过的二狗”。
而引入 Session 这个机制,就肯定会带来一些新的问题,Spring Security 就对此有一套会话管理机制,包括并发控制与会话固定攻击防护
因为会话管理是认证授权后的重要防线。攻击者常通过会话劫持与会话固定突破系统边界,而业务系统则面临并发滥用带来的资源风险。
什么是Cookie
Cookies
是某些网站为了辨别用户身份而储存在用户本地终端上的数据(通常经过加密)。
Cookie 和 Session
都是用来跟踪浏览器用户身份的会话方式,但是两者的应用场景不太一样。
所以说,Cookie 存放在客户端,一般用于保存用户信息
下面是 Cookie 的一些应用案例:
- 我们在
Cookie中保存已经登录过的用户信息,下次访问网站的时候页面可以自动帮你登录的一些基本信息给填了。除此之外,Cookie还能保存用户首选项,主题和其他设置信息。 - 使用
Cookie保存SessionId或者Token,向后端发送请求的时候带上Cookie,这样后端就能取到Session或者Token了。这样就能记录用户当前的状态了,因为 HTTP 协议是无状态的。 Cookie还可以用来记录和分析用户行为。举个简单的例子你在网上购物的时候,因为 HTTP 协议是没有状态的,如果服务器想要获取你在某个页面的停留状态或者看了哪些商品,一种常用的实现方式就是将这些信息存放在Cookie
使用 Cookie 直接使用 Spring 提供的组件就可以,例如我们可以设置
Cookie 返回给客户
1 |
|
使用 Spring 框架提供的 @CookieValue 注解获取特定的
cookie 的值
1 |
|
Session
的主要作用就是通过服务端记录用户的状态。
典型的场景是购物车,当你要添加商品到购物车的时候,系统不知道是哪个用户操作的,因为
HTTP 协议是无状态的。服务端给特定的用户创建特定的 Session
之后就可以标识这个用户并且跟踪这个用户了。
Cookie 数据保存在客户端(浏览器端),Session
数据保存在服务器端。相对来说 Session 安全性更高。如果使用
Cookie 的一些敏感信息不要写入 Cookie
中,最好能将 Cookie
信息加密然后使用到的时候再去服务器端解密。
HTTP 的无状态性与 Cookie
HTTP 是无状态协议这个没啥好说的,每次请求/响应之间是相互独立的
理解 Cookie 是理解 Session 的前提
Cookie 是服务器发送给浏览器的一小段数据(通常为 key=value 形式),浏览器会将其保存,并在后续请求中自动附带发送回服务器。
Cookie 的属性在前面说过一些
HttpOnly,防止 JavaScript 读取 Cookie(防御 XSS 攻击的关键)。Secure: 强制 Cookie 仅在 HTTPS 连接中传输。SameSite: 限制第三方 Cookie 传输(防御 CSRF 攻击的关键)。
那么 Cookie 的更多属性如下
| 属性 | 说明 |
|---|---|
Name=Value |
必需,键值对 |
Domain |
指定哪些域名可以接收该 Cookie(如 .example.com
表示所有子域可用) |
Path |
指定哪些路径下的请求会携带该 Cookie(如 /app) |
Expires / Max-Age |
过期时间,决定是会话 Cookie(关闭浏览器即失效)还是持久 Cookie |
Secure |
仅通过 HTTPS 传输(防止明文泄露) |
HttpOnly |
禁止 JavaScript 访问(防 XSS 攻击) |
SameSite |
控制跨站请求是否携带 Cookie(防
CSRF)可选值:Strict、Lax、None |
那么 Cookie 与会话是什么关系呢?
用户登录后,服务器创建一个 Session
对象,一般存储在服务端内存,少数的存在 Redis 等,并生成一个唯一
ID(如 JSESSIONID)。
该 ID 通过 Set-Cookie: JSESSIONID=abc123 设置在 Cookie
中并返回给浏览器。
浏览器后续请求自动带上
Cookie: JSESSIONID=abc123这段Cookie信息,服务器据此识别用户。
但是实际上,现代前后端分离更多关注的是 Token,Cookie + Session 的方式是 Spring Security 传统的实现
Spring Security 同时支持两种模式,但会话管理主要围绕前者。
如何使用 Session-Cookie 方案进行身份验证?
很多时候我们都是通过 SessionID
来实现特定的用户,SessionID 一般会选择存放在服务器内存或者
Redis 中,有这样的步骤:
- 用户成功的登陆了系统,然后返回给客户端具有
SessionID的Cookie - 当用户向后端发起请求的时候,服务端会把存有
SessionID的 Cookie 带上,这样后端就知道你的身份状态了。
- 用户向服务器发送用户名、密码、验证码用于登陆系统。
- 服务器验证通过后,会为这个用户创建一个专属的 Session
对象,可以理解为服务器上的一块内存,存放该用户的状态数据,如购物车、登录信息等存储起来,并给这个
Session 分配一个唯一的
SessionID。 - 服务器通过 HTTP 响应头中的
Set-Cookie指令,把这个SessionID发送给用户的浏览器。 - 浏览器接收到
SessionID后,会将其以 Cookie 的形式保存在本地。当用户保持登录状态时,每次向该服务器发请求,浏览器都会自动带上这个存有SessionID的 Cookie。 - 服务器收到请求后,从 Cookie 中拿出
SessionID,就能找到之前保存的那个 Session 对象,从而知道这是哪个用户以及他之前的状态了。
注意,使用 Session 的时候需要注意下面几个点:
- 客户端 Cookie 支持:依赖 Session 的核心功能要确保用户浏览器开启了 Cookie。
- Session 过期管理:合理设置 Session 的过期时间,平衡安全性和用户体验。
- Session ID 安全:为包含
SessionID的 Cookie 设置HttpOnly标志可以防止客户端脚本(如 JavaScript)窃取,设置 Secure 标志可以保证SessionID只在 HTTPS 连接下传输,增加安全性。
那么,这么一说,是不是 Session 的使用就依赖 Cookie 了,如果没有 Cookie 的话 Session 还能用吗?
其实肯定能用,只不过一般是通过 Cookie 来保存
SessionID ,这样的方案安全且简单,假如你使用了
Cookie 保存 SessionID 的方案的话,
如果客户端禁用了 Cookie,那么 Session
就无法正常工作。
你完全可选择其他的方法,例如加密装到响应体,装到URL中都可以,只不过这些方案不如装在 Cookie 中。
了解会话管理的组件
会话管理支持由几个组件组成,它们一起工作以提供该功能。这些组件是:
SecurityContextHolderFilter、
SecurityContextPersistenceFilter 和
SessionManagementFilter
但是在 Spring Security 6
中,SecurityContextPersistenceFilter 和
SessionManagementFilter
默认是不设置的。除此之外,任何应用程序只能设置
SecurityContextHolderFilter
或SecurityContextPersistenceFilter,而不能同时设置。
在 Spring Security 5 中,默认配置依靠
SessionManagementFilter 来检测用户是否刚刚认证,并调用
SessionAuthenticationStrategy。这样做的问题是,这意味着在一个典型的设置中,必须为每个请求读取
HttpSession。
所以说,后来,在 Spring Security 6 中,默认情况下不使用
SessionManagementFilter
使用 Spring Session 完成网站登录
回忆之前的登录方案
之前的登录方案,我们是通过进行用户名密码比对来进行认证,认证成功后,后端生成
JWT
令牌,令牌通过签名算法加密后返回给前端,前端将令牌存储在localStorage、sessionStorage或cookie中,然后后续请求中,前端在
HTTP 请求头中携带令牌
之后,后续服务端收到请求之后,会对前端发起请求中携带的 Token
进行解析和验证,验证通过后就会创建Authentication对象并设置到SecurityContextHolder,后续的权限校验会基于这个认证的信息
JWT 是 Token 的一种具体实现,Token 只是对令牌这个技术实现的一个叫法,传统 Session 认证中,服务端返回的
JSESSIONID,本质也是一种 Token,OAuth2.0 中的access_token、refresh_token,也是 Token
而之前,对于Token,我们都使用 JWT 的方式来实现,JWT(JSON Web Token)本质是一种紧凑的、自包含的令牌格式,JWT 替代了之前传统的服务端会话,也就是 Session,来实现无状态认证
因为对于传统 Session 认证的问题其实很明显
- 服务端需要存储用户会话信息,如 SessionId 和用户信息的映射,一旦涉及到分布式,就需要共享和维护,复杂度会大大增加
- 服务端需要维护会话状态
而 JWT 是认证成功后,后端将用户核心身份信息直接编码到 JWT 中,通过签名保证不被篡改,服务端无需存储任何会话数据,仅需在接收到 JWT 时验证签名和有效性即可,天然适合分布式和微服务
那么完整的流程就是这样
flowchart TD
A[前端提交用户名密码] --> B[后端Spring Security验证账号密码]
B -->|验证成功| C[后端根据用户信息生成JWT(Payload存用户身份,签名防篡改)]
C --> D[后端返回JWT给前端]
D --> E[前端存储JWT(localStorage/cookie)]
E --> F[前端后续请求携带JWT到请求头]
F --> G[后端拦截请求,提取JWT]
G --> H[后端验证JWT:签名有效性+过期时间]
H -->|验证通过| I[解码JWT的Payload,获取用户身份信息]
I --> J[创建Authentication对象,存入SecurityContextHolder]
J --> K[基于Authentication做权限校验,处理业务请求]
H -->|验证失败| L[返回401/403错误]
改造使用 Session 完成授权认证
首先,对于流程上,使用起来没有太大变化,因为我们只是把 Token 的实现从 JWT 换回了 Session
重新写个例子吧
会话固定攻击
什么是会话固定攻击
会话固定攻击是指:攻击者为一个受害者“预设”好了一个已知的 Session ID,并诱导受害者在这个 ID 下完成登录。
因为通常情况下,我们认为登录后 Session 才是安全的。但在该攻击中,Session ID 是在登录前产生的。如果服务器在用户登录后不更新这个 ID,攻击者就可以凭借这个已知的 ID 直接接管受害者的登录状态。
什么意思?我们还是拿 Steam 常见骗术举例来讲
一般我们打着打着csgo,买完了个饰品,转头收到骗子伪装成的买家,声称 “交易失败需重新验证”,发送带固定 Session ID 的钓鱼链接,这就是典型的会话固定攻击
骗子通过话术诱导你去登录那个骗子搭建的和 Steam
登录页一模一样的钓鱼网站,并在后台生成一个固定 Session
ID,sid=hack123456789,这个 ID 只有骗子知道。
骗子伪装成 “Steam 客服”“交易平台官方” 或被盗的好友,发消息说 “你的账号存在异常登录风险,需紧急验证”,并附上钓鱼链接
你点开链接,看到熟悉的 Steam
登录界面,输入账号、密码,甚至按提示提交了 Steam Guard 2FA
验证码,这些信息会被骗子记录,同时钓鱼网站会让你的访问 “绑定” 到预设的
sid=hack123456789 上。
钓鱼网站拿到你的凭证后,会用这个固定 Session ID 去请求 Steam 真实服务器完成登录。我们假设 Steam 在此环节没有强制更新 Session ID(正常 Steam 会更新,但钓鱼场景中骗子会利用用户对 “登录态” 的信任,或通过伪造会话绕过检测),该 Session ID 就会保持有效。
骗子直接用 sid=hack123456789 发起请求,此时 Steam
会认为这是你本人的合法会话,骗子就能登录你的账号,转移库存饰品、消费钱包余额
Spring Security如何防御会话固定攻击
首先,回到我们上面看到的 Spring Seucirty 涉及到的一些核心组件,救赎之道,就在其中
在防御会话固定攻击的链路中,这两个类的角色如下:
SecurityContextHolderFilter(安全上下文加载器): 它的职责非常简单:在请求开始时,从存储(通常是 HttpSession)中读取安全上下文(SecurityContext),并把它放进SecurityContextHolder供后续使用。SessionManagementFilter(执行者/防御者): 这是防御的核心。它通过代码逻辑检测用户是否刚刚完成登录。如果是,它会调用具体的策略(SessionAuthenticationStrategy)来处理会话,防止旧的 Session ID 继续被使用。
观察SessionManagementFilter 源码
在 doFilter 方法中,有这样一段逻辑:
1 | // 1. 检查 SecurityContextRepository 中是否已经存了这个 context |
它是如何防御的,首先,如果 containsContext 返回
false,说明在当前请求开始时,Session
里还没有认证信息。如果此时 isAuthenticated 返回
true,说明在当前请求的处理过程中(比如刚才经过了
UsernamePasswordAuthenticationFilter),用户已经成功登录了。
这就是防御会话固定攻击的最佳时机,用户刚证明了身份,但是貌似接下来几乎没做上什么事情,几乎没来得及使用那个可能被黑客预设的 Session ID 开展业务。
其中,执行会话认证策略在接口
SessionAuthenticationStrategy 中的
onAuthentication方法中的一个实现,不同情况下,调用的不太一样,更多在这个位置,调用的是CompositeSessionAuthenticationStrategy
这个方法有很多实现,但是大多数情况下,防御逻辑是接近的
那么,这个部分的逻辑大概就是,备份把当前 Session
中的所有属性,然后调用
session.invalidate(),让那个可能被攻击者掌握的 Session ID
彻底失效,接着要求 Servlet 容器生成一个全新的 Session
ID,将备份的数据移入新 Session。
更换了存放 Session 的容器,但是其中的内容没变,这样,即使攻击者手里拿着之前的 ID 疯狂刷新页面,由于旧 ID 已经失效,他会被拦截在登录门槛外;而真正的用户则持有着全新的 ID 安全地继续操作。
而SecurityContextHolderFilter
则是为了提高效率而设计的。
1 | Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request); |
它使用了 延迟加载(Deferred)
机制。这意味着只有在真正需要检查用户权限时,才会去 Session
中读取数据。这种设计与 SessionManagementFilter
配合,确保了在复杂的并发请求下,安全上下文的读取和保存是高效且线程安全的。
这就引出了会话并发控制
如何配置 Spring Session 会话固定防护
在 Spring Security 7 中,其实会话固定防护的配置已经高度抽象化。由于 Spring Security 7 继续深度采用 Lambda DSL 配置方式,其配置通常与 Spring Session 结合使用,以支持分布式环境。
首先是普通的配置,使用 Spring Security自己内部关于会话固定防护的内容,只需要
如果你希望在项目中使用 Spring Session Redis,首先需要确保依赖到位:
1 | <dependencies> |
然后除了 redis 自己的一些配置,还需添加一项重要配置
1 | spring.session.store-type=redis |
我们其实还需要修改 SecurityConfig 中的过滤器链来配置一些内容
1 |
|
当你配置了 spring-session-data-redis 后,Spring 会使用
SessionRepositoryFilter 替换原生的 HttpSession。
其中,sessionFixation().changeSessionId()只是为了防护会话固定攻击,和
Session
存储位置无关,所以,显示开启这个其实无论什么方式都推荐配置的,而那些并发的控制,就是分布式环境下,需
Spring Session Redis 才能生效
会话并发控制
如何控制会话并发
当同一账号在多个地方登录后,可能带来数据冲突、授权混乱甚至资源浪费。
会话并发控制(Session Concurrency Control)是指限制同一个用户账号在同一时间允许存在的最大会话数量。例如,你可以限制一个账号只能在一个终端登录,或者最多允许三台设备同时在线。
Spring Security
通过并发会话控制,可限制用户的最大在线会话数,并在超过限制时选择“踢出旧会话”或“拒绝新登录”两种策略
一般情况下,我们推荐安全性高的项目,会话并发控制在一个,就是同时情况下,一个位置登录了,立刻踢出上一个
控制会话并发的配置很简单
默认情况下,Spring Security
允许用户拥有任意数量的并发会话。要限制并发会话的数量,可以使用
maximumSessions DSL
方法,也就是在SecurityConfig的过滤器链中添加对应的内容
1 |
|
但是,为什么我上面一直在说 Session
Redis,因为在实际的生产项目中,如果你有多个服务器实例,默认的
SessionRegistryImpl是基于内存的,此时会失效。因为 A
服务器不知道 B 服务器上有多少个“zhl”在线,集成 Spring Session
Redis的情况下,SessionRegistry
的实现会自动替换为支持 Redis 的版本,所有的登录计数都会在 Redis
中统一管理,从而实现全局的并发控制。
会话并发控制涉及的核心组件
当你配置了 .maximumSessions(n)
时,系统底层会动用很多组件
其中,涉及到的核心组件有:
ConcurrentSessionControlAuthenticationStrategy:之前提到SessionManagementFilter源码时提到的核心策略之一。它的职责是在认证成功的瞬间,判断该用户是否允许创建新会话。SessionRegistry:它是整个并发管理的核心,维护着一个“用户 -> 会话列表”的映射关系,默认在内存维护。默认实现是SessionRegistryImpl。Redis 有它自己的实现。ConcurrentSessionFilter:这是一个位于过滤器链高层的 Filter。对于每一次请求,它都会检查当前 Session 是否被标记为“过期”,如果之前的登录挤掉了当前这个会话,该 Filter 会发现状态异常,直接销毁当前 Session 并重定向到登录页或报错。RegisterSessionServerAuthenticationSuccessHandler:RegisterSessionAuthenticationStrategy:当用户成功登录后,它负责将这个新的会话信息注册到SessionRegistry中,确保“记账本”是最新的。HttpSessionEventPublisher:这个监听器是并发控制生效的前提。当 Servlet 容器销毁一个会话时,它会发送事件。该组件捕获事件并通知SessionRegistry移除该会话,否则用户的“登录名额”会一直被占用无法释放。







