看到 active 就是已经登录了吗

采购审批页在两次 HTTP 请求之间要识别用户。浏览器带回一枚会话 Cookie,服务器据此查找 HttpSession,页面显示“仍在会话中”。可是当前工程的演示端点只往 Session 写一个随机 labNonce:没有核验用户名、口令、角色,也没有把身份关联到租户。把 active 当作“能批准该租户的采购申请”,等于让一段随机状态替代认证与对象权限。第 24–26 篇完成认证之前,Session 在本篇只是一种 Web 生命周期实验,不是安全机制已经上线的证据。

工程的 LabSessionServlet.java 映射 /servlet/lab-session,代码试图用 JAVAEE_DEMO_MODE=true 和回环地址限制入口;部署的 server.xml 只监听 127.0.0.1。Servlet 在拒绝路径调用 sendError 后又依赖 response.isCommitted() 决定是否返回,尚无关闭演示模式时“不建 Session、只返回 404”的负例;不能单凭源码把它当成经过证明的访问控制。最小脚本 scenarios/08-session.sh 在开启演示开关的隔离实例运行成功,但没有验证 Cookie 安全标志、固定攻击与并发写入。

Jakarta Servlet 6.1 §7 把会话跟踪、创建、失效和并发访问拆开。浏览器通常经 Cookie 回送不透明的会话标识;Session 对象与其中属性驻留在服务器侧(单实例的本地会话或服务器额外配置的持久/复制策略)。标识不应包含用户密码或明文审批信息,服务器也不应因为某个请求自报了租户头就接受该身份。getSession(false) 查询已有关联会话而不主动创建,getSession(true) 可在没有会话时创建。当前 GET 返回 inactive 或 active,并设置 Cache-Control: no-store;若把查询写成无条件 getSession(),匿名扫描 GET 也可能创建会话,测试中“不传 Cookie 的请求保持 inactive”就不成立。

LabSessionServlet 的 POST action=start 如果发现已存在会话就调用 request.changeSessionId(),再取或创建 Session、放入新的随机 labNonce,返回 204。身份从未认证升级为已认证时,应更换会话标识,防止预先泄露的 ID 继续代表新身份。当前 start 不验证身份,仅靠动作参数即可调用;因此虽然对已有会话换了标识,并没有防住真正的登录态固定攻击。是否从头就存在会话、旧 ID 是否被拒绝、Cookie 的 Secure/HttpOnly/SameSite 策略和代理后的 HTTPS 标记,均需在真实登录流程及服务器配置中分别核对。新建会话时没有“旧 ID”可旋转,不应凭一次新建返回 204 声称测试了 ID 轮换。

POST action=end 获取现有会话并 invalidate();没有会话也返回 204。用旧 Cookie 再 GET,正常设计应不能恢复之前的 Session,但当前脚本只检查持有的 Cookie jar 再读到 inactive,没有保留原始 ID 并尝试重放,也没证明其它设备或服务器节点同步失效。会话失效不撤销已提交的审批,也不回滚数据库事务或删除浏览器中残留的 Cookie;一次注销还可能与正在执行的审批重叠。应把注销后的新请求与注销前已通过校验、尚未提交的请求分开制定规则,而不是把 invalidate() 理解为时光倒流。

还有一处被单实例手工测试掩盖的风险:Session ID 是查找关联状态的键,不是“这个请求属于某个租户”的证明。即使把 labNonce 改成用户名,也须在身份登录成功后由服务器写入,后续请求要把会话与主体、授权版本和租户对象访问规则重新关联。用户的权限在会话有效期间可能被撤销;若业务只在登录时把角色复制进 Session 而不定义撤销刷新策略,旧权限会继续对审批生效。回环实验不涉及帐号生命周期,因而不提供角色变更后的可见性证据。对每笔批准,还应将“谁批准、审批哪条申请、读到什么版本”记在业务审计记录,而不是寄望 Session 自身能够恢复历史。

同一个 Session 的并发请求会竞争

Servlet 6.1 §7.7.1 明确会话属性的并发访问由应用妥善处理。两个标签页同时取到同一 Session,若都读 approvalCount=0、各自写回 1,结果可能是 1 而非 2;换成线程安全 Map,也不自动使“读取→检查→写入”成为不可分割的业务事务。采购状态、已处理幂等键和订单数量不应该只保存在 Session;真正守住“每申请至多一单”的仍是数据库约束与事务。Session 用于少量临时 Web 上下文时,也要避免把可变的非线程安全对象直接共享给多请求。

本例只把随机字符串放进 labNonce,没有修改计数器或保存审批状态,不能从 /lab-session 连续访问成功推出会话属性线程安全。并发实验至少要在同一 Cookie 下发两条受控请求,记录每次访问开始、结束和 Session 关联;对不同 Cookie 做对照以排除意外共享。增加可变属性实验时才可断言并发更新的终态;当前没有此受控脚本,应保持 NOT_RUN。把会话值设成 AtomicInteger 也只解决单实例进程内的一类竞争,不解决服务器重启、请求同时路由多个实例、事务回滚或帐号级权限变化。跨节点同步是否存在取决于实际容器与配置,Servlet 规范不承诺在未配置集群时自动复制所有会话对象。

会话竞态不止计数丢失。设请求 A 从 Session 读出允许的部门列表,请求 B 同时注销并 invalidate();A 若已经开始业务写入,B 的失效未必让 A 的当前 Java 栈立刻抛错。若业务要求“注销之后尚未提交的审批也不得生效”,就要在业务事务提交前再次核对持久的授权状态,甚至定义令牌版本和撤销序列,而不只是让浏览器删 Cookie。若需求只要求“注销后新请求不得进入”,则要明确 A 已进入的请求属于例外,并向用户说明结果查询方式。验收需要一条显式屏障:A 读权限后停住,B 注销并确认旧会话失效,放行 A 后读取数据库;只有这样才能把讨论从时间上的猜测变成可检验的合同。目前既无受保护审批入口也无屏障,因此仍 NOT_RUN。

超时、重启和安全Cookie不是一个开关

容器根据配置与活动时间使 Session 超时(Servlet 6.1 §7.5);测试应区分主动 invalidate()、空闲超时、服务器重启和新请求未带 Cookie。需要保留 Cookie jar、最后访问时刻、超时配置以及重启前后对应请求的结果。当前脚本执行的只是“无 Session→start→有 Session→end→无 Session”,既没有等待或控制超时,也没有双节点环境。一次重启后仍 active 可能是会话持久化配置的结果;重启后 inactive 也不能证明用户名凭证已撤销。不要为达到观测结果修改共享服务器的生产级会话策略;在隔离配置副本上实施并保存配置差异。

Cookie 的 Domain、Path、Secure、HttpOnly、SameSite、生命周期与反向代理的 TLS 终止应分别审视。HttpOnly 能降低脚本读取 Cookie 的风险,不会阻止浏览器自动发送 Cookie;Secure 只限制在安全传输上的发送,不验证 POST 的业务意图。Session 作为浏览器携带的身份凭据,在支持真实登录和写操作时还需 CSRF 防护、服务端身份校验及对象授权;尤其审批页面会调用状态变更,不能仅靠视图状态或 SameSite 单独解决跨站请求问题。当前教学脚本运行在 HTTP loopback,不应由此推断部署到 HTTPS 代理后的 Cookie 属性。安全配置及平台身份测试归后续章节,本章只建立检查点。

跨实例测试也要确定路由策略。两个节点各有本地内存 Session 时,同一浏览器 Cookie 经负载均衡转发到另一个节点,可能被视为没有会话;配置了会话复制/外部会话存储后,属性类型、更新频率和并发冲突又会产生新的约束。所谓“粘性会话”只能提高一类请求命中旧节点的概率,不是节点故障时保持身份或审批事务一致的承诺。实验应在相同 Cookie 下分别固定节点、切换节点并重启节点,保存节点标识与 Set-Cookie;不能用一个本机 Open Liberty 进程的 active 推出集群 Session 已部署。

可运行脚本与未执行的分支

独立实验必须先部署当前 WAR,打开隔离实例的 JAVAEE_DEMO_MODE=true,确保 JAVAEE_PORT 与回环监听一致;不要把该开关在公网实例或生产账号上开启。在工程目录执行:

1
JAVAEE_PORT=9085 bash scenarios/08-session.sh

脚本通过 curl -b/-c 携带临时 Cookie jar,检查 GET inactive、POST start 204、GET active、POST end 204、再次 GET inactive。writing-plans/javaee-enterprise/verification/20261004T-a-batch-recheck/ 的修复后 scenario-08-session-after-fix.stdout.txt 打印了这段序列的 PASS,退出码 0,构建与部署 WAR 的摘要一致;它没有保留逐步 HTTP 头、脱敏 Cookie 或 S1/S2 对照。最小会话序列:PASS;旧会话 ID 重放、并发 Session 写入、超时、重启、多实例及真实登录:NOT_RUN。 完整 Set-Cookie 与请求 Cookie 取证时须脱敏会话 ID,不能把可重用令牌原文上传博客。

失败路径可以先只改变一个变量:重复使用 start 之前保存的旧 ID,请求不应因为旧 ID 获取新安全级别;然后把 action 改为未知值,预计当前 Servlet 抛出 400,且不应新建 Session。旧 ID 对照需要在已有会话且完成了 changeSessionId() 时捕获前后标识,空 Session 起步的现有脚本不足以做这个断言。若准备服务端会话属性竞态实验,需新增可控屏障、完成后重新读取服务器端属性;没有屏障的两条快速 curl 即使返回 200,也不能证实并发读写安全。记录条目见 08实验说明。

浏览器场景的负例也必须区分“没有 Cookie”和“持有失效 Cookie”。脚本的第一个 GET 没有现存 jar,所以返回 inactive;注销后再次 GET 可能携带旧 Cookie,但服务端已失效关联会话,仍返回 inactive。要证明两者路径不同,需要同时保存注销前后经过脱敏的 Cookie 标识、服务器接收的请求头和是否又产生新的 Set-Cookie。由于当前 doGet() 使用 getSession(false),它本身不为缺失会话创建新 Session;但反向代理或其他过滤器可以改写 Cookie,因而只看返回正文仍不足以推导浏览器中残留哪些 Cookie。后续若把 GET 改成会话续期接口,要明确刷新超时的时机,不要让健康检查和爬虫无意续期。

会话用作登录态时还需定义“过期之后的写请求”怎么处理。服务器可因超时拒绝一个新的审批 POST,但若数据库写入已经在失效之前开始,HTTP 会话失效本身不回滚该笔事务;只有用例内部明确检查并标记回滚才会影响数据库终态。若调用者在超时后重新登录并重放提交,仍须凭业务 ID 与操作 ID 判断第一次是否成功。测试不应只断言重放返回登录页,还应按申请 ID 查询状态、订单与审计记录;否则 302 重定向会掩盖已写入的订单。当前没有认证跳转或稳定操作键,所以这种“过期后重试”属于未来验收,不能以 Session 生命周期脚本的 0 退出充当证据。

两道练习与答案

练习一: 测试先用匿名请求拿到 Session ID=S1,再 POST start,随后携带 S1 访问审批资源。当前工程能否断言这个请求一定被拒绝;怎样设计一个真正检验固定攻击防护的实验?

答: 当前 start 调用 changeSessionId() 但没有认证,也没有受保护的审批资源,不能给出“审批资源拒绝 S1”的实测结论。接入真实登录后应先创建匿名会话、保存 S1;认证成功取得 S2,断言 S1≠S2。分别用 S1、S2 在独立 Cookie jar 下访问受保护端点,前者不得以新身份通过,后者仅在授权范围内通过;再验证注销后 S2 失效并留存脱敏响应与后端会话状态。第 24–26 篇前保持 NOT_RUN。

练习二: 两个使用同一 Cookie 的请求同时读取 Session 属性 draftCount=4,都加一并写回。最终为 5 还是 6?如果换成数据库中同租户已审批的订单数,可否通过给 Session 加锁守住唯一性?

答: 非原子读改写可能丢掉一次增加而得到 5;实际交错需可控实验确认,不能预写日志。将订单数从 Session 移到数据库仍需事务、唯一约束及并发决策;Session 锁只能协调受其约束且位于同一节点的请求,绕过锁的写入、重启及其他节点不会自动参与。正确验收要用两个连接竞争同一申请、独立连接查最终订单数和状态,不以 Cookie 关联成功代替数据库证明。

本章的“会话有效”只是 getSession(false) 是否找到可用对象这一布尔值。若后来要承载审批身份,测试输出至少要同时区分 Session 存在、主体已认证、角色仍有效以及申请归属四个条件;任何一个条件失败都不应继续写数据库。这样拆分后,旧 Cookie 重放和跨租户对象访问才不会被合并成一项“返回 inactive”的模糊断言。

边界与官方资料

Session 提供请求间关联,不保证已认证、数据库幂等、跨实例持久或并发串行。本例端点仅供本机隔离教学,正式身份实现之前不能对外开放;演示属性值与 JWT、容器主体均不是一回事。远端 GitLab master 的源码路径发布前仍需与工作树同步核验。