diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java index b145e6e..145cce9 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/SecurityConfig.java @@ -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 角色。 + * + *

除了顶层的 {@code /operator/**},还必须逐个列出「嵌在业务路径下」的 + * 运营端点,例如 {@code /counseling/services/operator/queue}、 + * {@code /content/operator/articles}、 + * {@code /activities/operator/checkin/scan}、 + * {@code /service-appointments/operator/list}。 + * 漏掉任何一个,该端点都会掉进下面的 ROLE_USER 规则, + * 导致运营人员被自己的平台拒绝(管理端对应模块直接不可用)。 + * + *

不要试图用 {@code /**}{@code /operator/**} 一行覆盖: + * Spring 的 PathPattern 语法不允许 {@code **} 出现在中间 + * (只有末尾才允许),写了会在运行时抛 + * {@code PatternParseException: No more pattern data allowed after ** pattern element}, + * 且表现为整个过滤器链 500 —— 已实测踩过。 + * + *

这些前缀由 {@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),不再只是"已认证"。 + * + *

此前这里是 {@code authenticated()}:运营 token 也算"已认证", + * 于是能用运营账号去打用户端端点。而 + * {@code @AuthenticationPrincipal UserPrincipal} 遇到 OperatorPrincipal + * 属类型不匹配,Spring Security 默认返回 null + * ({@code errorOnInvalidType=false})—— + * 全仓 244 处使用该注解,仅 48 处做了 null 检查, + * 其余端点会直接 NPE → 500。 + * + *

与其补上百处 null 判断(机械、易漏、且无法约束将来新增的代码), + * 不如在认证层按身份类型挡住:运营身份访问用户端一律 403, + * 请求根本进不到 Controller。身份与角色的对应关系见 JwtAuthFilter: + * 运营 → ROLE_OPERATOR + 权限;用户 → ROLE_USER。 + */ + .requestMatchers("/**").hasRole("USER") // 其他所有请求放行(避免 denyAll 导致未知路径也被拦截) .anyRequest().permitAll() ) diff --git a/backend/src/test/java/com/danyang/match/platform/shared/config/SecurityPathCoverageTest.java b/backend/src/test/java/com/danyang/match/platform/shared/config/SecurityPathCoverageTest.java new file mode 100644 index 0000000..a3056cb --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/shared/config/SecurityPathCoverageTest.java @@ -0,0 +1,169 @@ +package com.danyang.match.platform.shared.config; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 机械校验:源码里每个「运营端点」都必须被 SecurityConfig 的 OPERATOR 规则覆盖。 + * + *

为什么需要:运营端点并不都在 {@code /operator/} 下。部分业务控制器把运营端点 + * 嵌在深层路径里({@code /counseling/services/operator/queue}、 + * {@code /content/operator/articles}、{@code /service-appointments/operator/list} …)。 + * 一旦某个这类端点没被显式纳入 OPERATOR 规则,它会掉进兜底的 ROLE_USER 规则, + * 结果是运营人员被自己的平台拒绝——管理端对应模块直接不可用, + * 而这在只跑用户侧测试时完全看不出来。 + * + *

不能靠 {@code /**}{@code /operator/**} 一行通配解决:PathPattern 不允许 + * {@code **} 出现在中间,写了会抛 PatternParseException 并把整个过滤器链打成 500。 + * 所以只能显式枚举 —— 那就必须有人盯着别漏,这个测试就是那个人。 + */ +class SecurityPathCoverageTest { + + private static final Path SRC_ROOT = resolveSourceRoot(); + private static final Path SECURITY_CONFIG = SRC_ROOT.resolve( + "com/danyang/match/platform/shared/config/SecurityConfig.java"); + + /** + * 定位源码根目录。 + * + *

测试的工作目录在本环境下是仓库根而非 Maven 模块根 + * (同项目 `MigrationValidationTest` 也以 `backend/target/...` 形式引用产物), + * 但不同执行方式下可能不同。两种候选都试一遍,比写死一个更稳。 + */ + private static Path resolveSourceRoot() { + for (String candidate : new String[]{"src/main/java", "backend/src/main/java"}) { + Path path = Path.of(candidate); + if (Files.isDirectory(path)) { + return path; + } + } + throw new IllegalStateException( + "无法定位源码根目录(尝试过 src/main/java 与 backend/src/main/java);当前工作目录=" + + Path.of("").toAbsolutePath()); + } + + @Test + @DisplayName("源码中所有运营端点路径都被 SecurityConfig 的 OPERATOR 规则覆盖") + void everyOperatorEndpointIsGuardedByOperatorRule() throws IOException { + Set prefixes = operatorRulePrefixes(); + // 健全性检查:确认解析器真的取到了 OPERATOR 规则(而非 permitAll 之类)。 + // 若这条挂了,说明下面是"零覆盖也算全通过"的假绿,必须先修解析逻辑。 + assertTrue(prefixes.contains("/operator/**"), + "未能从 SecurityConfig 解析出运营规则前缀,测试本身已失效。实际解析结果:" + prefixes); + + List unguarded = new ArrayList<>(); + try (Stream files = Files.walk(SRC_ROOT)) { + for (Path file : files.filter(p -> p.toString().endsWith("Controller.java")).toList()) { + String src = Files.readString(file); + String classPath = firstMappingPath(src); + for (String methodPath : methodMappingPaths(src)) { + String full = join(classPath, methodPath); + if (isOperatorPath(full) && !isCovered(full, prefixes)) { + unguarded.add(file.getFileName() + " -> " + full); + } + } + } + } + + assertTrue(unguarded.isEmpty(), + "以下运营端点未被 SecurityConfig 的 OPERATOR 规则覆盖," + + "运营账号访问会被 403(管理端对应功能不可用)。" + + "请在 SecurityConfig 的 OPERATOR requestMatchers 中补上其前缀:\n" + + String.join("\n", unguarded)); + } + + @Test + @DisplayName("兜底规则必须是 ROLE_USER,不能退回 authenticated()") + void fallbackRuleRequiresUserRole() throws IOException { + String cfg = Files.readString(SECURITY_CONFIG); + /* + * 若兜底退回 authenticated(),运营 token 也算"已认证", + * 于是能打进用户端端点;而 @AuthenticationPrincipal UserPrincipal + * 遇 OperatorPrincipal 会返回 null → 未做 null 检查的端点 NPE → 500。 + * 全仓 244 处使用该注解、仅 48 处做了检查 —— 所以这条兜底不能松。 + */ + assertTrue(cfg.contains("hasRole(\"USER\")"), + "兜底规则必须是 hasRole(\"USER\");退回 authenticated() 会让运营身份打进用户端并触发 NPE → 500"); + } + + // ---------------------------------------------------------------- helpers + + /** + * 从 SecurityConfig 源码解析出 **OPERATOR 规则**的路径前缀(去掉结尾的 **)。 + * + *

只取与 {@code .hasRole("OPERATOR")} 同一条链的 {@code requestMatchers}。 + * 这一点必须严格:若把 permitAll 或兜底的 {@code /**} 也收进来, + * 任何路径都会被判为"已覆盖",本测试就变成永远通过的摆设。 + */ + private static Set operatorRulePrefixes() throws IOException { + String cfg = Files.readString(SECURITY_CONFIG); + Set prefixes = new LinkedHashSet<>(); + Matcher blocks = Pattern.compile( + "requestMatchers\\(([^)]*)\\)\\s*\\.hasRole\\(\"OPERATOR\"\\)", Pattern.DOTALL).matcher(cfg); + while (blocks.find()) { + Matcher literals = Pattern.compile("\"([^\"]+)\"").matcher(blocks.group(1)); + while (literals.find()) { + prefixes.add(literals.group(1)); + } + } + return prefixes; + } + + private static String firstMappingPath(String src) { + Matcher m = Pattern.compile("@RequestMapping\\(\\s*\"([^\"]*)\"").matcher(src); + return m.find() ? m.group(1) : ""; + } + + private static List methodMappingPaths(String src) { + List paths = new ArrayList<>(); + Matcher m = Pattern.compile("@(?:Get|Post|Put|Patch|Delete)Mapping\\(\\s*\"([^\"]*)\"") + .matcher(src); + while (m.find()) { + paths.add(m.group(1)); + } + return paths; + } + + private static String join(String classPath, String methodPath) { + String base = classPath == null ? "" : classPath; + String tail = methodPath == null ? "" : methodPath; + String joined = (base + tail).replaceAll("/{2,}", "/"); + return joined.isEmpty() ? "/" : joined; + } + + private static boolean isOperatorPath(String path) { + // /auth/operator/** 是运营账号的登录/刷新端点,按设计属于公开或已认证接口, + // 由更靠前的 /auth 规则管辖,不应要求 OPERATOR 角色(否则登录本身就进不去)。 + if (path.startsWith("/auth/")) { + return false; + } + return path.contains("/operator/") || path.startsWith("/operator") || path.equals("/operator"); + } + + private static boolean isCovered(String fullPath, Set prefixes) { + for (String prefix : prefixes) { + if (prefix.endsWith("/**")) { + String base = prefix.substring(0, prefix.length() - 3); + if (fullPath.equals(base) || fullPath.startsWith(base + "/")) { + return true; + } + } else if (fullPath.equals(prefix)) { + return true; + } + } + return false; + } +} diff --git a/docs/HTTP语义检修报告-2026-09-18.md b/docs/HTTP语义检修报告-2026-09-18.md index d9963cb..88b0ca2 100644 --- a/docs/HTTP语义检修报告-2026-09-18.md +++ b/docs/HTTP语义检修报告-2026-09-18.md @@ -139,11 +139,11 @@ config.setAllowCredentials(true); // 允许携带凭证 ### 3. 后端全量回归 ``` -Tests run: 662, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS +Tests run: 664, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS ``` -基线 656(Phase 23B 后),净 +6 为新增的 -`ApiResponseCodeHttpStatusTest`(6 条映射语义守卫)。 +基线 656(Phase 23B 后),净 +8:`ApiResponseCodeHttpStatusTest`(6 条映射语义守卫) ++ `SecurityPathCoverageTest`(2 条路径覆盖守卫,见第七节)。 3 个 skip 均有显式原因,无一为空壳。 首个回归轮次为 **662 / 3 failures**,三处失败均已判定为「测试断言了旧的错误行为」 @@ -185,13 +185,103 @@ Tests run: 662, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS --- -## 七、结论 +## 七、补充:身份类型与路径规则的收敛(同日续做) + +修完 401/403 之后,顺着同一条线索继续查,发现一个**与错误响应质量直接相关**的更严重缺陷。 + +### 1. 根因链条 + +`PlatformPrincipal` 有两个实现:`OperatorPrincipal`(运营 token)与 `UserPrincipal`(用户 token), +由 `JwtAuthFilter` 按 token 类型选择。 + +而 `@AuthenticationPrincipal UserPrincipal` 遇到 `OperatorPrincipal` 时属**类型不匹配**, +Spring Security 默认返回 **null**(`errorOnInvalidType=false`)。 + +后果:全仓 **244 处**使用该注解,**仅 48 处**做了 null 检查。 +其余端点在运营身份访问时会直接 NPE → **500**。 + +这不是理论风险——`SecurityConfig` 当时的兜底规则是 `authenticated()`, +而运营 token 完全满足"已认证",请求可以一路打进用户端 Controller。 + +### 2. 为什么不补 null 检查 + +补上百处 `if (principal == null)` 是机械劳动,还会遗漏、也无法约束将来新增的代码。 +根因是**身份类型与端点不匹配**,应该在认证层解决,而不是在每个端点重复防御。 + +### 3. 修法:兜底规则从「已认证」收紧为「用户身份」 + +```java +.requestMatchers("/**").hasRole("USER") // 原为 .authenticated() +``` + +运营身份访问用户端 → 403,请求根本进不到 Controller,196 处潜在 NPE 一次性消解。 +角色对应关系由 `JwtAuthFilter` 保证:运营 → `ROLE_OPERATOR`,用户 → `ROLE_USER`。 + +### 4. ⚠️ 踩坑:`/**/operator/**` 是非法模式 + +运营端点并非都在 `/operator/` 下——`/counseling/services/operator/queue`、 +`/content/operator/articles`、`/activities/operator/checkin/scan`、 +`/service-appointments/operator/list` 等嵌在业务路径深处。 + +第一版试图用 `/**/operator/**` 一行通配,结果: + +``` +PatternParseException: No more pattern data allowed after {*...} or ** pattern element +``` + +**Spring 的 PathPattern 不允许 `**` 出现在中间**,只允许在末尾。 +更糟的是它的表现:整个过滤器链直接 **500**(Tomcat HTML 错误页,不是我们的 JSON), +E2E 从 37 passed 变成 **33 failed**。 + +改为显式枚举 9 个前缀(全部符合 PathPattern 语法): + +``` +/operator/** /admin/** +/counseling/services/operator/** /content/operator/** /risk/operator/** +/activities/operator/** /service-appointments/operator/** +/service-programs/operator/** /referrals/operator/** +``` + +### 5. 机械守卫:`SecurityPathCoverageTest` + +显式枚举的代价是"将来新增会漏",而漏掉的表现是 +**运营人员被自己的平台拒绝(管理端对应模块不可用)**——只跑用户侧测试根本看不出来。 + +因此新增守卫,**从 `SecurityConfig` 源码解析出 OPERATOR 规则前缀**, +再扫描全部 `*Controller.java`,把「类级路径 + 方法级路径」拼成完整路径, +校验每个含 `/operator/` 的端点都被覆盖(`/auth/operator/**` 按设计排除)。 + +两个必须注意的细节: + +- **只能解析 `.hasRole("OPERATOR")` 那条链的规则。** 若把 permitAll 或兜底的 `/**` 也收进来, + 任何路径都会被判为"已覆盖",守卫就退化成永远通过的摆设。 +- **工作目录是仓库根而非模块根**(同项目 `MigrationValidationTest` 也用 `backend/target/...` 形式), + 故源码根需同时尝试两种候选路径。 + +**负向验证**:摘掉 `/content/operator/**` 前缀后,恰好 1 个用例变红, +并精确列出受影响的 6 个 `ContentController` 端点;恢复后全绿。 + +### 6. 验证 + +- E2E **40 passed**(原 37 + 3 条身份隔离用例:运营打用户端 403、 + 用户打运营端 403、嵌套运营端点仍要求 OPERATOR)。 +- 后端全量回归 **664 / 0 failures / 0 errors / 3 skipped**(本轮起点 662,净 +2 为新守卫), + `SecurityPathCoverageTest` 已并入常规回归。 +- 首个 E2E 轮次因非法路径模式导致 **33 failed**(过滤器链 500); + 修正为显式前缀后全部恢复,佐证了"`/**/operator/**` 不可用"这一结论。 + +--- + +## 八、结论 本轮把「失败必须携带真实 HTTP 语义」从约定变成了机制: - 状态码的**唯一来源**是 `ApiResponseCode.httpStatus`,新增业务码不声明就编译不过; - 105 处 Controller 直返**零改动**即获得正确状态; - 未认证与无权限在 HTTP 层分离,小程序终于能触发重新登录; -- CORS 默认从「放行任意来源」改为「默认拒绝」。 +- CORS 默认从「放行任意来源」改为「默认拒绝」; +- 兜底访问规则从「已认证」收紧为「用户身份」,196 处潜在 NPE → 500 在认证层消解, + 并有机械守卫防止将来新增运营端点时漏配规则。 -顺带修掉了一个当时就会伤害用户的缺陷:token 过期不再让用户卡死。 +顺带修掉了两个当时就会伤害真实用户的缺陷: +**token 过期后用户卡死无法重登录**,以及**运营身份可打进用户端触发 500**。 diff --git a/tests/e2e/http_semantics_test.py b/tests/e2e/http_semantics_test.py index c3c676d..fa7de2f 100644 --- a/tests/e2e/http_semantics_test.py +++ b/tests/e2e/http_semantics_test.py @@ -86,3 +86,50 @@ def test_not_found_is_used_to_avoid_existence_probing(api, user_token): assert resp.status_code == 404, ( f"非本人 Match 应回 404(避免存在性探测),实际 {resp.status_code}" ) + + +def test_operator_identity_cannot_reach_user_endpoints(api, admin_token): + """ + 运营身份访问用户端端点必须 403,且请求不得进入 Controller。 + + 原因:`@AuthenticationPrincipal UserPrincipal` 遇到 OperatorPrincipal 属类型不匹配, + Spring Security 默认返回 null(errorOnInvalidType=false)。全仓 244 处使用该注解、 + 仅 48 处做了 null 检查,其余端点会 NPE → 500。 + + 与其补上百处 null 判断,不如在认证层按身份挡住。 + 若这里返回 500(而非 403),说明拦截规则失效、请求又进到了 Controller。 + """ + for path in ("/notifications", "/me"): + resp = api.call("GET", path, token=admin_token) + assert resp.status_code == 403, ( + f"运营 token 访问用户端 {path} 应回 403,实际 {resp.status_code}:{resp.text[:300]}" + ) + body = resp.json() + assert body.get("code") == "FORBIDDEN", f"{path} 业务码应为 FORBIDDEN:{body}" + + +def test_user_identity_cannot_reach_operator_endpoints(api, user_token): + """反向:用户身份打运营端点同样 403(不得因改动而放开)。""" + resp = api.call("GET", "/operator/banners", token=user_token) + assert resp.status_code == 403, ( + f"用户 token 访问运营端应回 403,实际 {resp.status_code}:{resp.text[:300]}" + ) + + +def test_nested_operator_endpoints_still_require_operator_role(api, user_token): + """ + 嵌套在业务路径下的运营端点也必须挡住。 + + `/counseling/services/operator/queue`、`/content/operator/articles`、 + `/activities/operator/checkin/scan` 这类端点根路径不是 /operator/, + 若拦截规则只写 `/operator/**` 就会漏掉它们。 + """ + for path in ( + "/counseling/services/operator/queue", + "/content/operator/articles", + "/service-appointments/operator/list", + ): + resp = api.call("GET", path, token=user_token) + assert resp.status_code == 403, ( + f"用户 token 访问嵌套运营端点 {path} 应回 403,实际 {resp.status_code}" + )