fix(security): stop operator identity from reaching user endpoints; guard path rules

Continues the HTTP-semantics work. Fixing 401/403 exposed a worse defect on the
same axis: who is allowed to reach which endpoint.

Root cause chain:
- PlatformPrincipal has two implementations (OperatorPrincipal / UserPrincipal),
  chosen by JwtAuthFilter from the token type.
- `@AuthenticationPrincipal UserPrincipal` against an OperatorPrincipal is a type
  mismatch, and Spring Security returns null (errorOnInvalidType defaults to
  false) instead of failing.
- 244 uses of that annotation repo-wide; only 48 null-check. The rest NPE -> 500
  when reached with an operator identity.
- The fallback rule was `.authenticated()`, which an operator token satisfies, so
  such requests did reach user-facing controllers.

Adding ~196 null checks would be mechanical, easy to miss, and does nothing to
constrain future code. The mismatch is between identity type and endpoint, so it
is fixed once, at the authentication layer: the fallback becomes
`.hasRole("USER")` (operator -> ROLE_OPERATOR, user -> ROLE_USER, per JwtAuthFilter).
Operator requests to user endpoints now get 403 before any controller runs.

Trap worth recording: operator endpoints are not all under /operator/ —
/counseling/services/operator/queue, /content/operator/articles,
/activities/operator/checkin/scan, /service-appointments/operator/list and others
sit deeper inside business paths. The first attempt covered them with
`requestMatchers("/**/operator/**")`, which fails at runtime:

  PatternParseException: No more pattern data allowed after {*...} or ** pattern element

Spring's PathPattern permits `**` only as the final element. Worse, the symptom is
a 500 from the whole filter chain (Tomcat HTML error page, not our JSON), and E2E
went from 37 passed to 33 failed — an error far removed from its cause. Now the
nine prefixes are listed explicitly, each ending in /**.

Because explicit enumeration can be forgotten as endpoints are added, and because
the failure mode is "operators locked out of their own admin module" (invisible to
user-side tests), SecurityPathCoverageTest parses the OPERATOR rule prefixes out of
SecurityConfig source, walks every *Controller.java, joins class-level and
method-level mapping paths, and asserts every path containing /operator/ is
covered (/auth/operator/** excluded by design). Two things that would silently
neuter it, both handled: it must parse only the .hasRole("OPERATOR") chain — pulling
in the permitAll or fallback rules would mark everything covered — and the test
working directory is the repo root, not the Maven module root.

Verification: negatively validated by removing /content/operator/** -> exactly one
test turns red listing the six affected ContentController endpoints; restored.
E2E 40 passed (37 + 3 identity-isolation cases). Backend regression 664 / 0
failures / 0 errors / 3 skipped.
This commit is contained in:
WorkBuddy
2026-09-18 15:05:41 +08:00
parent 3a4e03a9f9
commit cbb4f64fdc
4 changed files with 360 additions and 12 deletions
@@ -71,12 +71,54 @@ public class SecurityConfig {
.requestMatchers("/app/config").permitAll()
.requestMatchers("/activities").permitAll()
.requestMatchers("/auth/**").authenticated()
// 操作台接口需要认证
.requestMatchers("/operator/**").hasRole("OPERATOR")
// 管理接口需要认证
.requestMatchers("/admin/**").hasRole("OPERATOR")
// 其他所有用户接口需要认证(覆盖 /api/v1/** 中未匹配的)
.requestMatchers("/**").authenticated()
/**
* 运营端接口要求 OPERATOR 角色。
*
* <p>除了顶层的 {@code /operator/**},还必须逐个列出「嵌在业务路径下」的
* 运营端点,例如 {@code /counseling/services/operator/queue}、
* {@code /content/operator/articles}、
* {@code /activities/operator/checkin/scan}、
* {@code /service-appointments/operator/list}。
* 漏掉任何一个,该端点都会掉进下面的 ROLE_USER 规则,
* 导致运营人员被自己的平台拒绝(管理端对应模块直接不可用)。
*
* <p><b>不要试图用 {@code /**}{@code /operator/**} 一行覆盖</b>:
* Spring 的 PathPattern 语法不允许 {@code **} 出现在中间
* (只有末尾才允许),写了会在运行时抛
* {@code PatternParseException: No more pattern data allowed after ** pattern element},
* 且表现为整个过滤器链 500 —— 已实测踩过。
*
* <p>这些前缀由 {@code SecurityPathCoverageTest} 机械校验:
* 源码中任何含 operator 的端点路径都必须被这里的规则覆盖。
*/
.requestMatchers(
"/operator/**",
"/admin/**",
"/counseling/services/operator/**",
"/content/operator/**",
"/risk/operator/**",
"/activities/operator/**",
"/service-appointments/operator/**",
"/service-programs/operator/**",
"/referrals/operator/**")
.hasRole("OPERATOR")
/**
* 其余接口必须持用户身份(ROLE_USER),不再只是"已认证"。
*
* <p>此前这里是 {@code authenticated()}:运营 token 也算"已认证",
* 于是能用运营账号去打用户端端点。而
* {@code @AuthenticationPrincipal UserPrincipal} 遇到 OperatorPrincipal
* 属类型不匹配,Spring Security 默认返回 <b>null</b>
* ({@code errorOnInvalidType=false})——
* 全仓 244 处使用该注解,仅 48 处做了 null 检查,
* 其余端点会直接 NPE → 500。
*
* <p>与其补上百处 null 判断(机械、易漏、且无法约束将来新增的代码),
* 不如在认证层按身份类型挡住:运营身份访问用户端一律 403,
* 请求根本进不到 Controller。身份与角色的对应关系见 JwtAuthFilter:
* 运营 → ROLE_OPERATOR + 权限;用户 → ROLE_USER。
*/
.requestMatchers("/**").hasRole("USER")
// 其他所有请求放行(避免 denyAll 导致未知路径也被拦截)
.anyRequest().permitAll()
)