会话什么时候真过期:客户端和服务端是两个时钟|洹拓科技

一、界面退出了,接口还在续命
一个常见做法是把配置里那项浏览器侧的会话有效期改短,然后认为会话就变安全了。这套做法只成立一半:那一项控制的只是浏览器里这枚标识的存活时间,包括关掉浏览器之后它还在不在;它不会去删除服务端的会话文件,也不会删除数据库里的会话记录。在一套常用的 PHP 框架里,默认用的是语言自带的原生会话存储,会话的生命周期由“客户端标识的过期时间”与“服务端会话数据的存活时间”共同决定,两者缺一不可。真正决定服务端什么时候销毁会话数据的,是那个以秒计的回收参数——它定义的是自最后访问起、多久之后这条会话可以被垃圾回收。所以只去改那一项有效期,既不充分也不安全。这篇文章想把两件一直被混着说的事拆开:一件是“看起来过期了”,另一件是“服务端已经销毁了”。
两端各管各的事,这是全篇的起点:
- 客户端那一端决定还发不发送这枚标识。它一过期,浏览器不再带着它走,于是每个页面都呈现出“没登录”的样子。
- 服务端那一端决定收到这枚标识之后认不认。只要服务端记录还在、回收没有真的跑起来,这枚标识被再次递到接口面前,服务端照样认。
只改前者的结果,就是“界面上像退出了、接口还在续命”。用户看到的是退出,用户看不到的那一侧,会话数据安安静静躺在服务端存储里,等着被回收,或者根本不会被回收。这层错配之所以隐蔽,是因为最容易观察到的现象——界面变成未登录——恰好是唯一被那项配置控制的东西;真正承担风险的那部分,从来没有出现在任何一屏界面上。要谈会话安全,得从这块看不见的地方开始谈。
二、服务端那三种回收方式,哪一台真在跑
“配了回收参数”不等于“回收在发生”。服务端清掉过期会话数据的触发方式有三种,各自有各自“配了却没生效”的表现。
| 回收方式 | 怎么被触发 | 配了却没生效时的表现 | 最容易被误判成什么 |
|---|---|---|---|
| 请求里按概率顺带回收 | 一部分请求顺带执行一段清理 | 业务低谷时段几乎不跑,过期记录长期堆着 | 以为只要有流量就一直在清 |
| 交给存储方按存活时间淘汰 | 由存储侧自己淘汰 | 存储侧没配淘汰,就等于永不过期 | 以为框架里那个秒数会自动生效 |
| 独立定时任务清理 | 定期扫描并删除 | 任务停掉之后持续堆积,且无人报警 | 以为曾经跑过就一直没事 |
第一种最容易骗人:流量密集时它看起来一直在工作,一到业务低谷就几乎不执行,于是“已过期但仍有效”的记录会一直积累,直到下一次高峰被顺带扫到之前都还能用。第二种更干脆——一旦会话存储从原生文件换成数据库或者内存服务,清理就由存储方的策略决定,框架里那个以秒计的回收参数不再自动生效,一切都看存储那端有没有真的配淘汰。第三种的问题不在机制而在值守:定时任务停掉通常不会在任何界面报错,只会让目录或者表悄悄变大,等到发现时,那批本该被清掉的会话数据已经存活了很久。评审时不要问“回收参数配了没有”,要问“清理动作到底有没有真的发生过”——服务端存储里那一批本该消失的过期记录,才是这件事的证据,而不是配置文件里那行数字。
三、最后访问要不要续命,必须写死
那个以秒计的回收参数是按“自最后访问起”算的。这一句话里藏着两种完全不同的语义:
- 滑动过期:每次访问都把计时起点往后推,只要用户持续操作,会话就可以一直不被销毁;此时这个时长的含义是“闲多久才过期”。
- 绝对过期:不管访问多少次,都从创建那一刻起算总时长;此时这个时长的含义是“最多活多久”。
两者混用最坏的地方,不是行为不一样,而是同一份策略在不同存储上会算出不一样的结果——一处按最后一次访问的时间算,另一处按创建时间算,同一个用户在两端拿到两个不同的到期时刻,而没有任何一处会报错。所以策略里要把“最后访问要不要续命”这一条写死。如果两种语义业务上都需要,那就同时给出两个数,并把“哪个先到算哪个”写明,不要让读配置的人靠猜。站内《智能体读邮箱的四维授权与撤销三类残留》处理的是撤销之后残留没清干净的问题;本篇处理的是另一头——根本没有撤销动作参与,只是过期时刻算错了。
四、两端时长对齐,并写清谁说了算
对齐不是把两个数填成一样就完事,得先定一条规则:以更短的一端为准,还是各管一段。
- 以更短一端为准:过期时刻取两端里更早的那个。逻辑简单,但要保证服务端那端真的会在这一刻销毁——否则客户端过期只是不再发送标识,服务端的记录照旧留着。
- 各管一段:客户端那端管标识还留在浏览器里多久,服务端那端管记录还留在存储里多久。这条更贴近真实实现,但必须在文档里写清楚“客户端过期不等于会话结束”,不然运维会按前一种理解办事,安全评审按后一种口径提要求。
无论选哪种,两端时长之间的大小关系都要明确写下来。服务端存活比客户端有效期长,就会出现“标识已经不再发送、服务端还认”的那段时间;反过来,就会出现“浏览器里还带着标识、服务端已经销毁”的那段时间,用户被无预警踢出。两种都不算错,怕的是没人知道自己选了哪一种。这一条也要落进交付文档:两端各是多少、以哪端为准、差额时段的行为是什么,三句写完才算交代清楚。
五、能落地的两步验证:证明它真的过期了
前面所有话都要靠这一步变成可确认的事实。下面是失效验证,不是任何针对他人会话的操作,用的都是自己这一份会话:
- 先建立一份属于当前测试者的有效会话,记下配置上宣称的过期时长。按宣称时长再加上等待,然后仍然带着这枚标识访问一次受保护接口,看它是被拒绝,还是被悄悄续期。如果被悄悄续期,基本能断定是滑动语义配错了,或者概率式回收根本没跑起来。
- 再去服务端存储里查那一条对应的会话记录:文件形态查目录里对应的那个文件,数据库形态查表里对应的那一行,内存服务形态查那个键还在不在。接口拒了不等于记录没了;记录真的消失,才叫服务端销毁。
两步都过,“会话多久过期”这句话才算有了证据。只跑第一步会得到假阳性——接口不认,可能只是路由层的判定,跟会话有没有被销毁无关;只跑第二步会得到另一种假阳性——记录被清了,但如果存储侧淘汰和框架回收的口径不同,中间那段时间接口照样能认。这也是站内《系统权限收回后端侧能力四层》和《单条对话上锁的密钥来源与副本范围与恢复语义》都替代不了的一步:那两篇分别处理权限收回和单对象加密,本篇处理的是“失效时刻到底落在哪一端”。
六、三份存储,三种清退义务
会话数据落在文件、数据库和内存服务三处,可读性和清退义务完全不同,而且都和日志留存、脱敏要求纠缠在一起。
| 存储位置 | 可读性 | 清退方式 | 与留存的冲突点 |
|---|---|---|---|
| 文件 | 目录里能直接看到 | 靠回收逻辑或清理脚本删 | 备份里会长期留着旧会话 |
| 数据库 | 可查询、可统计 | 靠删除任务或存储淘汰 | 与业务数据同表时删除易误伤 |
| 内存服务 | 通常只能按键访问 | 靠存储侧淘汰策略 | 淘汰没配就等于不过期 |
文件形态最容易漏,因为会话目录常常被一起备份:在线那份删干净了,备份里还留着一整批旧会话。数据库形态的好处是能直接查出“已过期但仍在”到底有多少行,代价是删除任务的边界必须跟业务数据划清,否则清退会误伤。内存服务形态的清退最依赖存储方那一条淘汰策略,框架里那个秒数在这里基本说不上话。另一头是脱敏与留存:会话内容即便只为了排障,也不该把完整标识原样写进日志;日志自己的留存周期又要和会话清退周期一起讲明白,别让“日志还留着”变成“会话数据换了个形态被留下了”。
交付动作最后收敛成三行,写进部署说明与验收表:
- 会话多久过期:给出客户端与服务端两个时长,并注明用的是滑动还是绝对语义。
- 由哪一端决定:写清以更短一端为准,还是两端各管一段,差额时段行为是什么。
- 怎么验证:把上面那两步固化成验收步骤,写明由谁执行、结果记在哪一份记录里。
洹拓把这三行当成会话类交付的固定栏位。评审里“我们配了过期时间”这一句回答不了任何问题——两端是不是同一个到期时刻、服务端回收到底跑没跑、能不能用一次真实等待复现失效,这三问都得有答案。把会话过期只当成一个浏览器侧设置,安全评审就永远停在“界面上像退出了”那一层,而风险恰恰停在看不见的服务端存储里。