U29:真实认证、会话与浏览器防护实验
本实验只在 127.0.0.1 的随机端口启动自己打包的应用。Python 标准库 HTTP 客户端、独立 CookieJar、真实 Tomcat 和 Spring Security 过滤链贯通认证;没有 MockMvc、@WithMockUser、模拟认证、数据库、浏览器或外部账号。它承担 #66 的实验部分;课程正文、账本、题目、索引与桌面验收由主任务交付。
依赖与公开入口
- JDK 21,本次 BellSoft Liberica 21.0.11+11 / macOS arm64;Maven 3.9.9;Python 3.9+(本次3.9.6,仅标准库)。需 JDK 自带的
keytool。 - 独立 Maven 工程,Boot 4.0.9 BOM 管理 Framework 7.0.10、Security 7.0.8。Tomcat 实际版本及所有打包依赖记入 evidence。版本来源与维护口径见新增工程兼容基线,不混入旧实验版本。
spring-tx仅补足框架InMemoryTokenRepositoryImpl使用的DataIntegrityViolationException类型;没有 JDBC、数据源、事务数据库或远程服务。- 首次 Maven 依赖下载需要网络;HTTP 验证禁止代理,只访问自己启动的 loopback 地址。无需安装 Python 包。
在仓库根执行:
JAVA_HOME=$(/usr/libexec/java_home -v 21) python3 labs/security-browser/run.py非 macOS 显式指定安装好的 JDK 21:
JAVA_HOME=/absolute/path/to/jdk-21 python3 labs/security-browser/run.py入口检查 Java 版本,实际以该 JAVA_HOME/bin/java 启动 Maven/应用。它过滤可能改变实验的 Spring/server/logging/JVM 注入环境变量;不改变用户全局设置。Maven 下载/打包/依赖树每条命令最长 180 秒,应用启动最长 35 秒,HTTP 请求最长 6 秒,停止等待最多 10 秒后仅定向终止自己的进程组。正常全套运行约几十秒,首次依赖下载可能更久。
仅复现单组(仍先实际构建 jar,不写全套验收文件):
JAVA_HOME=$(/usr/libexec/java_home -v 21) python3 labs/security-browser/run.py --case browser-session
JAVA_HOME=$(/usr/libexec/java_home -v 21) python3 labs/security-browser/run.py --case sensitive
JAVA_HOME=$(/usr/libexec/java_home -v 21) python3 labs/security-browser/run.py --case expire-old
JAVA_HOME=$(/usr/libexec/java_home -v 21) python3 labs/security-browser/run.py --case defenses其余 --case:chains、password-upgrade、concurrency、remember-me、stateless-csrf、lifetimes。--label 只给证据命名,不改变断言。red→green 开发记录见 tdd-cycles.json,每轮记录真实退出码、失败原因、命令、源码哈希和独立日志目录。最初出现的错误分派与凭据擦除问题也保留在开发历史中,没有把编译或启动失败冒充行为 red。
教学 API 与明确作用域
所有用户仅存在于该 Java 进程的 SyntheticCredentials 中;共同的合成密码为 lab-password。reader、legacy、session-user、remember-user 为 USER,operator 为 ADMIN。legacy 初始 {noop} 只用于演示旧密码升级;其他用户初始 bcrypt。成功认证由 DaoAuthenticationProvider 调用 UserDetailsPasswordService 存入 DelegatingPasswordEncoder 的 bcrypt;错误密码不升级。凭据源返回独立的 UserDetails,避免 ProviderManager 擦除认证结果凭据时擦掉源数据。
| 链 / 顺序 | 端点与输入 | 可观察结果 |
|---|---|---|
API / @Order(1)、/api/** | GET /api/open | 200,匿名身份;X-Lab-Chain: api |
| API | GET /api/whoami,Basic reader/合成密码 | 200;不提供/错误密码为 401,带 Basic challenge |
| API | GET /api/admin/report | reader 403;operator 200 |
| API | GET /api/csrf | 200,JSON {token,headerName,parameterName};单独的 HttpOnly XSRF-TOKEN cookie,路径 /api |
| API | POST /api/change,Basic + 上述 cookie + JSON 指定的 header/token | 缺失/错误 token 403;有效 200;仅 CSRF 不含身份 401 |
表单 / @Order(2)、无 matcher 的兜底 | GET /browser/prelogin、/browser/csrf | 匿名 200;前者建立带学习标记的会话,后者返回默认 session CSRF token |
| 表单 | POST /login,username、password、_csrf,可选 remember-me=on | 正确 302 到 /browser/whoami;错误凭据 401;会话数已满 409;缺失/错误 CSRF 403 |
| 表单 | GET /browser/whoami、/unlisted | 已认证 200;匿名 302 到 /login;证明兜底覆盖新增路由 |
| 表单 | GET /browser/admin/report | reader 403;权限不因登录或 CORS 放宽 |
| 表单 | GET /browser/sensitive | fullyAuthenticated():密码登录 200;remember-me 身份 302 到登录;密码重新认证后 200 |
| 表单 | POST /browser/change,有效 _csrf | 200;缺失/错误/登录前的旧 token 403 |
| 表单 | POST /logout,有效 _csrf | 302;当前会话失效,清理身份与 JSESSIONID/remember-me cookie,移除该用户的持久令牌记录 |
| 表单 | GET /logout | 200 确认页,不执行注销;受保护 POST 缺失/错误 CSRF 仍为 403 |
| 表单 | POST /browser/session/expire-soon,已认证 + 有效 _csrf | 200,只把本次调用者会话的 maxInactiveInterval 改为 1 秒;过 2 秒再请求证明实际容器过期 |
| 仅实验观测面 | GET /browser/credentials/legacy | 合成账号的 {encoding,upgrades},不返回密码或哈希 |
API 显式 STATELESS、NullSecurityContextRepository、NullRequestCache,不读写浏览器认证会话,也不因为 CSRF 创建 JSESSIONID。Basic 每次请求重新认证;CSRF cookie 独立保存防伪材料。保留默认 XOR token handler:JSON 的掩码 token 送到它提供的 header,而不是把原始 cookie 值硬当作 header。runner 在仅 CSRF、无身份的独立负例前重新 GET /api/csrf,使用该次返回的 token/cookie 配对;这不是 Basic 成功后轮换 CSRF 的证明。7.0.8 BasicAuthenticationFilter 不调用 CsrfAuthenticationStrategy。表单登录则会清除登录前的 CSRF token,已有旧 token 拒绝断言。没有通过关闭 CSRF 使测试通过。
认证失败/拒绝处理器直接写 401/403,API 的 401 保留 Basic challenge,避免 sendError 再分派 /error 时被表单兜底改成 302。不同 HTTP 状态是本实验显式配置的响应契约,不宣称是所有默认应用的返回值。
实际执行的状态、反例与恢复
全套入口先构建并核对 jar 中的精确依赖,然后顺序启动四个独立 Java 进程;每个进程只绑定 loopback :0,完成后回收。每个结果均来自运行时请求而非打印预期值:
- 默认 HTTP 进程:两链 first-match 与兜底;正常/错误 Basic、匿名和角色 403;真实表单登录失败后无身份。登录前先创建 session,登录后 ID 必须不同、标记必须保留,复制的旧 ID 必须无法认证。登录前旧 CSRF token 必须 403。注销后当前/复制的会话 ID 均不能恢复身份。
- 并发防止新登录:普通用户 cap=1,
lab.prevent-new-login=true为实验默认,第二客户端登录 409,第一个仍 200;HttpSessionEventPublisher发布销毁事件,SessionRegistryImpl释放名额,第二客户端注销后可重登。Security 自身的maxSessionsPreventsLogin默认值是 false,本实验没有把显式 true 称为框架默认。 - remember-me:未勾选不发 cookie;勾选后失去 JSESSIONID 仍通过真正的
RememberMeAuthenticationToken恢复身份,持久令牌自动轮换;注销后浏览器 cookie 清除,复制当前 remember cookie 再放回新客户端也被仓库撤销检查拒绝。损坏 cookie 不产生身份。fullyAuthenticated()要求重新认证。 - 会话数量与注销边界:丢 JSESSIONID 不等于删除服务端会话,原会话仍占名额。为演示自动登录,专用
remember-user明确 cap=2,其余用户 cap=1;runner 保留原会话客户端,证明注销自动登录会话后原会话仍 200,再显式注销原会话。普通注销不是账号全部会话强制下线。 - 上下文清理:Tomcat max/min worker 均为 1;Basic 成功后,下一个
/api/open必须在同名线程观察到anonymousUser,受保护请求无 Basic 为 401。复制浏览器的 JSESSIONID 到 API 同样 401。 - CORS/CSRF/角色分工:两链对
https://classroom.example的无认证预检 200,https://attacker.example为 403,预检不建立会话。允许 Origin 的实际请求仍需认证;Basic + 允许 Origin 但无 CSRF 为 403;reader 访问 ADMIN 为 403。攻击 Origin 的实际请求也为 403。允许列表只含 GET/POST/OPTIONS 及明确请求头,allowCredentials=true、maxAge=600 秒,不设通配 Origin。 - 安全头与 firewall:两链保留默认 nosniff、DENY、X-XSS-Protection=0、禁缓存;显式 CSP 为
default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; style-src 'self' 'unsafe-inline',inline style 仅为框架默认登录页保留,不能代替脚本/XSS治理。HTTP 和伪造 X-Forwarded-Proto 都无 HSTS。分号、编码分号、双斜杠请求均 400,且有真正StrictHttpFirewall拒绝处理器的X-Lab-Rejected-By标记;随后正常路径恢复 200。 - 独立 HTTPS 进程:本轮生成临时自签证书/PKCS12,Python 只信任该证书并校验 127.0.0.1 SAN。实际 TLS 握手后两链响应含
Strict-Transport-Security: max-age=31536000 ; includeSubDomains。不是伪造 secure flag、Mock request 或只调用 header writer。纯 HTTP 不发 HSTS;forward-headers-strategy=none不信任任意代理头。 - 独立到期进程:
--lab.remember-seconds=2;自己的 session 改 1 秒,等待 2 秒后旧会话/复制 ID 拒绝,新客户端重新登录成功,证明到期事件释放 registry 名额。remember cookie 等待 3 秒后手工放回新客户端仍拒绝;同用户仍有效的交互会话保持 200,证明两种有效期独立。正常会话配置为 5 分钟,正常 remember token 为 300 秒。 - 独立驱逐旧会话进程:
--lab.prevent-new-login=false,新登录成功,旧会话在下一次请求经ConcurrentSessionFilter触发自定义过期处理器 401 +X-Lab-Session-State: expired;再访问为匿名 302。旧会话并非在第二次登录时同步终止正在执行的请求。新会话注销后可再次登录,恢复路径实跑。
证据、失败定位与资源恢复
全套返回 0 才表示本次全部断言通过;失败为非 0,打印失败断言与该轮 LOGS 目录。命令写好、局部 case 通过或历史通过都不能替代全套验收。
- 固定交付:evidence/u29-2026-10-26.json。每次全套运行(包括失败)更新该文件;单 case 仅写本轮目录,不能伪装全套成功。
- 每轮独有目录:
~/.cache/software-engineering-study/java-depth-impl/u29/run-<随机值>/,包含run-result.json、JDK/Maven/打包/依赖树输出、各进程日志。没有覆盖其他运行目录。 - JSON 包含输入命令、时间、退出状态、源文件逐个 SHA-256(包括 README、run.py、POM、Java 和 properties,排除构建产物及 evidence 递归)、jar SHA、打包依赖、每个 HTTP 输入字段名/合成身份类别、实际状态/选定响应头/无敏感字段的 JSON、断言名称、各进程退出码和日志 SHA。
- 不持久化密码值、密码哈希、Authorization、完整 Cookie/Set-Cookie、session ID、CSRF/remember token 或 TLS 私钥。运行时 client 内存需要这些材料,证据只记录字段名和判断结果。服务端日志不记录合成密码,并由 runner 检查。TLS 密码只经子进程环境传入,不进入命令行;临时证书、私钥与 keystore 在 finally 中删除。
- 所有应用进程在 finally 中终止、等待并回收;Maven 超时也仅清理自己启动的进程组。正常 Java SIGTERM 退出常见为 143,不等于验收失败;总入口退出 0、每个进程 reaped 和断言才是判断依据。保留日志与本实验 target 便于追查,不碰用户服务、浏览器、其他实验或全局 Maven/JDK 配置。
常见失败:JDK 不为 21 时先改这次命令的 JAVA_HOME;Maven 下载失败查看 package.log/dependency-tree.log,网络恢复后重跑;启动失败看对应 *-server.log;401/403/409 不符先看 run-result 中断言、链标记和请求条件,不能临时关闭 CSRF/CORS/firewall。TLS 失败核对 JDK keytool 和证书生成日志,不用关闭证书验证“修复”。实验无需清空全局 cookie 或浏览器记录:重跑会新建 JVM、全新合成凭据/令牌仓库及全新 CookieJar。要删除历史日志,仅删除自己选定的具体 run-<随机值> 目录。
若需手工观察,可先在根目录以 JDK21 执行 mvn -f labs/security-browser/pom.xml package,再 java -jar labs/security-browser/target/security-browser-1.jar,从 U29BOUND <端口> 读取本次随机端口。curl -i http://127.0.0.1:<端口>/api/whoami 为 401;增加 Basic 合成账号 reader/上述合成密码后为 200。手工 POST 仍需先取所属链的 CSRF token 并保存对应 cookie;runner 的 Client/csrf/login/logout 给出完整可复现输入顺序。结束只停止这个手工 JVM。
证明边界与固定版本来源
本机真实 Servlet/HTTP/TLS 证明固定版本、配置、合成输入下的控制流;不等于浏览器的 SameSite/SOP/CSP 实际执行、生产漏洞审计、负载容量、反向代理可信配置或跨实例一致性。本实验不启动 Chrome,不清理用户浏览器记录。单工作线程用于确定性证明上下文清理,不是生产线程池建议。
凭据源、会话与持久令牌仓库都只在一个 JVM 的内存中。PersistentTokenBasedRememberMeServices 是真实框架服务,但 InMemoryTokenRepositoryImpl 不提供数据库持久化/跨节点共享;重启丢失全部状态和随机 remember key。注销撤销证明的是当前进程仓库中该用户的 remember token,不证明数据库耐久性,也不使其他活跃 session 自动失效。持久 remember-me 的验证不依赖密码哈希;密码升级或改密不会自动撤销这些令牌,需要业务显式移除仓库记录。cookie-based CSRF 亦不证明抵御任意 cookie 注入/XSS;仍需正确域、TLS、代理边界和应用输入治理。对象/租户/方法授权、OAuth2/OIDC、分布式 session、企业 IdP、生产密钥管理属于后续单元,不借本实验宣称覆盖。
实现入口:SecurityConfig.java、SyntheticCredentials.java、LabEndpoints.java。