forked from baskbuddy/zhangshun
30 changed files with 1419 additions and 31 deletions
@ -0,0 +1,49 @@ |
|||||
|
# 统一手机号注册与首次身份选择设计 |
||||
|
|
||||
|
## 范围与基线 |
||||
|
|
||||
|
本设计面向后端实施、独立复验和前端联调。采用一次性身份选择,不实现双身份切换、管理员公开注册、历史账号转身份或线上部署。 |
||||
|
开工分支 master,HEAD 8c9429108cbd5d85e5245f6c404b2172bf6dd7f3。暂存区空;已有 Phase B、验证码 P0、岗位等未提交变更及 290 个历史未跟踪条目须保留。 |
||||
|
|
||||
|
## 权威库存 |
||||
|
|
||||
|
- AuthService.mockLogin 对新手机号显式创建 STUDENT;密码登录只登录既有账号。 |
||||
|
- User 主角色为 VARCHAR(30),user_roles 为关联表,无数据库枚举限制。最新迁移 V46,无新增字段需求。 |
||||
|
- JwtAuthenticationFilter 只建立已认证主体,业务角色由 Service 判定;存在仅要求认证的业务接口,需要入口白名单。 |
||||
|
- AuthSessionService.createSession/revokeAllUserSessions 使用 REQUIRED;刷新持锁顺序 users → auth_tokens,身份选择沿用该顺序。 |
||||
|
- AuthTokenRepository 的 modifying 操作 flush 并清空 persistence context;必须先 flush 角色变化,再撤销/创建会话。 |
||||
|
- 企业创建资料约束复用 CompanyRegistrationRequest.CompanyDetails;企业注册开关只接受字面量 true。 |
||||
|
- 现有 /auth/company/register 保留原契约,不新增第二套角色或企业认证状态。 |
||||
|
|
||||
|
## 状态机和兼容 |
||||
|
|
||||
|
新手机号通过 login 用途短信验证 → UNASSIGNED 主角色、空业务 roles → 一次选择 STUDENT 或 COMPANY。 |
||||
|
COMPANY 选择同时创建 verified=false 的企业资料,后续沿用 Phase A 入驻与 Phase B 招聘认证门禁。 |
||||
|
旧三种角色不迁移。实体/数据库 STUDENT 默认值暂保留用于既有内部创建点兼容,统一注册路径显式赋值,不依赖默认授权。 |
||||
|
未知/矛盾主角色和 roles 组合不得通过身份选择;UNASSIGNED 即使被误写业务 roles 也应被公共角色 guard 拒绝。 |
||||
|
|
||||
|
## 接口 |
||||
|
|
||||
|
- /auth/login、/auth/password/login 和 /auth/me 增加 identitySelectionRequired(主角色派生),保留其他字段和成功码。 |
||||
|
- POST /auth/identity/select:identity 仅 STUDENT/COMPANY;COMPANY 必须提供 company,STUDENT 禁止提供 company;身份来自当前会话,客户端不能指定 userId/role/verified。 |
||||
|
- 首次选择返回强类型响应:userId、role、identitySelectionRequired=false、companyId、onboardingStatus、新 Token 对及 expiresIn。 |
||||
|
- 有效会话重复相同身份不修改资料或审计,返回状态和 reauthenticationRequired=true,不再次颁发凭证。失效旧会话返回 401,重新登录可恢复。 |
||||
|
- 已选其他身份 409;企业开关关闭 503;未选择身份访问受限业务 403;非法请求 400。 |
||||
|
|
||||
|
## 白名单 |
||||
|
|
||||
|
待选身份仅允许 GET /auth/me、POST /auth/identity/select、POST /auth/logout、POST /auth/password/set/change,及既有匿名认证入口 /auth/login、/auth/password/login、/auth/password/reset、/auth/refresh、/auth/sms/send、/auth/company/register。 |
||||
|
公开只读保持允许:GET /、/health(/子路径)、Swagger/OpenAPI、岗位公开列表/搜索/筛选/对比/评价、企业公开主页/岗位、岗位推荐分类字典。业务端口 actuator 路径保留原 404。 |
||||
|
其余已登录业务统一拒绝(含资料、认证、投递、候选人、Offer、面试、消息、测评、通知、导出和管理员)。方法与路径同时匹配,不用 /auth/** 放行。 |
||||
|
白名单检查由 IdentityAccessFilter 置于 JWT 验证之后的安全过滤链,在 MVC 路径/请求格式匹配之前执行,读取数据库当前身份;未知接口默认拒绝。通过禁用容器自动注册避免重复执行。公共角色 guard 同时拒绝 UNASSIGNED,避免服务调用用矛盾 roles 绕过。 |
||||
|
|
||||
|
## 原子事务与竞争 |
||||
|
|
||||
|
users 行锁 → 重新验证当前会话与账号注销状态 → 校验身份/资料/开关 → 更新角色并 flush → 建企业并 flush → 撤销全部旧会话 → 创建新会话 → 必需审计。任一失败回滚全部数据库更改,不返回 Token。 |
||||
|
验证码错误计数独立事务及 Redis 限流不纳入该回滚范围,不改变既有 P0。 |
||||
|
并发不同选择只允许一种提交,使用行锁后重新验证会话,防止认证过滤器校验与事务执行间的竞争。与刷新采用同一锁顺序;选择之前的会话在提交后失效。 |
||||
|
|
||||
|
## 验收 |
||||
|
|
||||
|
先保存生产修改前的失败证据,覆盖统一登录、身份选择、角色组合、企业开关、会话撤销、事务回滚和真实 MySQL 竞争。旧角色测试保留;仅更新明确依赖新手机号默认 STUDENT 的契约断言,不放宽授权断言。 |
||||
|
H2 定向、认证关联、真实 MySQL、全量 test/clean verify、静态与运行时 OpenAPI 对齐后进入独立复验。真实短信未接通时明确受控 Mock,不冒充短信送达。 |
||||
@ -0,0 +1,159 @@ |
|||||
|
# 统一手机号账号注册与首次身份选择实施报告 |
||||
|
|
||||
|
## 当前状态 |
||||
|
|
||||
|
统一手机号账号注册与首次身份选择本地实施完成,待独立代码复验。 |
||||
|
最终全量验证与独立 JVM 最新 JAR 黑盒均通过。没有暂存、提交、推送或部署。 |
||||
|
|
||||
|
## 范围与基线 |
||||
|
|
||||
|
项目为职来后端(项目目录 `zhilai`)。分支 master;开工 HEAD 为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`。 |
||||
|
开工已存在 Phase B、验证码 P0、岗位等未提交改动,以及 290 个历史未跟踪条目。本功能叠加于工作区,不能把全部 diff 当成本轮改动。 |
||||
|
最新迁移 V46;角色字段是 VARCHAR,无需新增迁移。不实现双身份切换、历史账号转角色、公开管理员注册或 Phase C。 |
||||
|
|
||||
|
## 权威库存 |
||||
|
|
||||
|
原 `AuthService.mockLogin` 为新手机号自动创建 STUDENT;密码登录不负责注册。 |
||||
|
`users.role` 与 `user_roles` 都被生产代码读取;JWT 认证以服务端有效会话为准,业务角色由 Service 校验。 |
||||
|
`AuthSessionService` 的会话创建/撤销参与调用方事务;刷新按 users → auth_tokens 顺序加锁。 |
||||
|
撤销 Repository modifying 操作会清空 persistence context,身份更新必须先 flush。 |
||||
|
企业资料复用既有 `CompanyDetails` 的 Validation 约束,开关复用只接受字面量 true 的企业注册配置。 |
||||
|
|
||||
|
## 新注册与旧账号兼容 |
||||
|
|
||||
|
新手机号仍通过 `POST /auth/login` 验证 login 用途短信,创建 UNASSIGNED 主角色和空业务 roles。 |
||||
|
登录、密码登录和 `/auth/me` 都新增派生字段 `identitySelectionRequired`,成功业务码仍为 200;旧字段不删除。 |
||||
|
既有 STUDENT、COMPANY、ADMIN 不重置、不迁移。实体和数据库的 STUDENT 默认值保留以兼容其他明确的内部创建点,统一注册路径显式覆盖为 UNASSIGNED。 |
||||
|
原 `/auth/company/register` 保留为兼容入口,并不强迫旧客户端立即改造。 |
||||
|
|
||||
|
## 首次身份选择接口 |
||||
|
|
||||
|
`POST /auth/identity/select` 必须使用有效 Access Token;身份和 sessionId 取自当前已验证会话。 |
||||
|
|
||||
|
```json |
||||
|
{"identity":"STUDENT"} |
||||
|
``` |
||||
|
|
||||
|
```json |
||||
|
{"identity":"COMPANY","company":{"name":"示例企业","industry":"互联网"}} |
||||
|
``` |
||||
|
|
||||
|
identity 仅允许 STUDENT/COMPANY;COMPANY 必须提供 company,STUDENT 禁止提供 company。 |
||||
|
不接受客户端 userId、role、verified 或其他未知字段;嵌套企业资料也不允许非法授权字段。 |
||||
|
首次成功返回新 Token 对、userId、role、identitySelectionRequired=false;企业额外返回 companyId 和 UNVERIFIED。 |
||||
|
同身份重复请求只返回当前状态、reauthenticationRequired=true,不重复发 Token、修改资料或追加审计。 |
||||
|
原会话失效后重复使用旧 Token 返回 401;客户端应采用新凭证,丢失响应时通过原登录方式重新登录。 |
||||
|
已经选择另一种身份的账号返回 409,不能用该接口切换身份。 |
||||
|
|
||||
|
## 待选身份访问门禁 |
||||
|
|
||||
|
`IdentityAccessFilter` 只作用于 JWT 过滤器建立的已验证会话主体,在 JWT 之后、MVC 路由和参数解析之前执行。 |
||||
|
它读取数据库当前身份,使用精确的 HTTP 方法/路径白名单,未知业务入口默认拒绝。 |
||||
|
白名单包含身份查询/选择、登出、密码维护和原匿名认证入口,以及既有公开只读页面。 |
||||
|
企业资料、认证申请、测评、消息、投递、候选人、岗位写操作和管理员接口不对 UNASSIGNED 放行。 |
||||
|
容器自动注册被禁用,避免过滤器在 JWT 之前执行或重复执行。 |
||||
|
公共 `UserRoleGuard.requireRole` 额外拒绝 UNASSIGNED,即使 user_roles 被误写成业务角色也不能通过该 guard。 |
||||
|
旧 Controller 接线探针使用的非 JWT 测试主体不新增解析调用;生产仍只有 JWT 认证方式,未启用 Basic/Form 登录。 |
||||
|
|
||||
|
## 事务与锁顺序 |
||||
|
|
||||
|
1. Service 层再次验证 DTO。 |
||||
|
2. 锁定 users,重新核验 active、当前会话和注销状态。 |
||||
|
3. 确认主角色/roles 无矛盾、没有孤立企业资料;校验企业开关。 |
||||
|
4. 更新 users.role 与唯一业务 role,saveAndFlush。 |
||||
|
5. COMPANY 创建唯一 verified=false 企业资料并 flush。 |
||||
|
6. 撤销全部旧会话,创建带新角色的新会话。 |
||||
|
7. 同事务执行 MANDATORY 必需审计,固定事件 IDENTITY_SELECTED,明细只含结果。 |
||||
|
|
||||
|
任何异常回滚角色、企业和 Token 更改;不返回未提交的凭证。锁顺序与刷新一致,不增加跨请求缓存。 |
||||
|
注销 PENDING/COMPLETED 拒绝选择,取消后的 NONE 可继续;禁用账号在锁内再次拒绝。 |
||||
|
原验证码原子消费、独立错误计数事务和 Redis 限流不被修改,也不虚称这些 Redis 状态会自动回滚。 |
||||
|
|
||||
|
## 错误响应 |
||||
|
|
||||
|
新增唯一数字码:10401 身份待选/403,10402 身份冲突/409,10403 身份选择依赖不可用/503。 |
||||
|
非法 JSON/字段/参数为原 400;匿名、失效会话和非法 Token 为原 401;企业注册开关关闭沿用原 503。 |
||||
|
没有修改任何历史错误码数字;历史 9103 重码事实仍保留。 |
||||
|
历史映射快照资源未改,只在契约测试中单独登记三个新增映射和相应数量。 |
||||
|
|
||||
|
## 修复前证据 |
||||
|
|
||||
|
生产修改前新增 6 个 HTTP 测试:6 run / 6 failures / 0 errors / 0 skipped。 |
||||
|
明确复现:新账号实际为 STUDENT;待选业务请求实际 200;身份选择路径不存在。 |
||||
|
失败证据在忽略目录 `docs/acceptance/unified-identity/`,不会进入提交。 |
||||
|
|
||||
|
## 实施中发现与修正 |
||||
|
|
||||
|
- Jackson 全局忽略未知字段,初版 userId 注入请求被接受;改为新 DTO/企业嵌套 DTO 安全拒绝未知字段。 |
||||
|
- 初版 MVC 拦截器晚于 multipart/Content-Type 路由匹配;全端点测试定位到格式不符请求先出错,改为安全过滤链门禁。 |
||||
|
- 新过滤器初版增加历史 Controller 接线探针的 Accessor 调用;限定为 JWT 已验证会话主体后保留旧精确调用次数断言。 |
||||
|
- MySQL 回滚测试初版在事务代理外 stub MANDATORY 审计,导致测试装配失败;改为对代理内部 spy 注入故障,实际调用仍经过真实事务代理。 |
||||
|
- Repository 会将故障转换为 DataAccessException,回滚测试明确验证安全业务码 10403,不要求错误穿透为原异常类型。 |
||||
|
- 密码 HTTP 测试初版遗漏密码模块开关和 mock Redis 原子脚本返回值;补齐测试前置。真实 Redis 证据由最新 JAR 验收单独提供。 |
||||
|
- 新增接口使旧 OpenAPI 数量断言失败;只将数量改为 183/221,保留原简历安全字段和接口断言。 |
||||
|
- MySQL Service 并发只产生一份身份,但首次最新 JAR HTTP 并发出现 200/500:失败请求在锁内抛出 InvalidTokenException,新 Controller 未转换。补确定性测试先复现 1 run / 1 failure(expected 401 but was 500),再复用原认证安全响应返回 401;没有改变锁、角色或数据断言。 |
||||
|
- JAR 验收脚本初版把 `/auth/sms/send` 的查询参数放进 JSON body,实测缺少 phone 返回 400;按现有 RequestParam 契约修正脚本,未修改生产短信接口。Maven exec 参数需整体引号;RabbitMQ 改用本机已缓存的官方 3.13 management 镜像,未通过反复网络拉取干扰其他服务。 |
||||
|
|
||||
|
## 精确本轮文件范围 |
||||
|
|
||||
|
新增生产文件(4): |
||||
|
|
||||
|
- `auth/identity/IdentitySelectionRequest.java` |
||||
|
- `auth/identity/IdentitySelectionResponse.java` |
||||
|
- `auth/identity/IdentitySelectionService.java` |
||||
|
- `auth/identity/IdentityAccessFilter.java` |
||||
|
|
||||
|
修改生产文件(9):`AuthService`、`User`、`PasswordAuthenticationService`、`UserRoleGuard`、`ErrorCode`、`ErrorHttpStatusRegistry`、`CompanyRegistrationRequest`、`SecurityConfig`、`AuthController`。 |
||||
|
其中异常码文件已有 Phase B 改动,本轮只叠加 10401–10403;其他原有 P0/B/岗位文件不被本轮覆盖。 |
||||
|
|
||||
|
新增测试文件(4):`IdentityPolicyTest`、`UnifiedRegistrationIntegrationTest`、`IdentitySelectionMySqlTest`、`UnifiedIdentityJarAcceptanceProbe`。最后一项为手工最新 JAR 验收 main,不冒充 JUnit 数量。 |
||||
|
修改既有测试(8):`AuthServiceTest`、`CompanyControllerMvcTest`、`CompanyOnboardingControllerTest`、`GlobalExceptionHandlerStatusContractTest`、`ErrorHttpStatusRegistryTest`、`GlobalExceptionHandlerMappingParityTest`、`R01BIndependentHttpMappingParityTest`、`CompanyResumeDownloadIntegrationTest`。 |
||||
|
修改仅涉及新账号默认身份、新依赖装配、新枚举映射计数和新增接口计数;原角色、归属、隐私及历史映射断言保留。 |
||||
|
|
||||
|
新增设计与本报告;修改 `openapi-rc.json` 与 `verify_openapi.py`。没有修改迁移、依赖、短信服务商、Jenkins 或线上配置。 |
||||
|
|
||||
|
## 测试及真实基础设施 |
||||
|
|
||||
|
最终定向命令 `mvn -B -Dtest=UnifiedRegistrationIntegrationTest,IdentityPolicyTest,IdentitySelectionMySqlTest test`:55 / F=0 / E=0 / S=0。 |
||||
|
其中策略测试 27、HTTP 集成 13、真实 MySQL 15;HTTP 库存测试独立列出公开与账号操作清单,不调用生产白名单生成期望,196 个受限 operation 全部拒绝。 |
||||
|
首次全量 test 曾有 7 个失败(旧接口计数 + 旧接线精确次数),修正测试计数与过滤器主体边界后 `mvn test` 为 2952 / F=0 / E=0 / S=0。 |
||||
|
之后新增失效会话 HTTP 反证并修复 500,最终 `mvn -B clean verify`:2953 / F=0 / E=0 / S=0 / BUILD SUCCESS,耗时 13:13。 |
||||
|
独立读取 284 份 Surefire XML 汇总同为 2953 / 0 / 0 / 0。相对采用的 2889 基线,新增 55 个 JUnit 执行项,既有枚举参数化测试增加 9 项,数量自洽。 |
||||
|
真实 MySQL 15 项及既有 Testcontainers 测试均实际执行;没有 Docker 不可用静默跳过。 |
||||
|
MySQL/Testcontainers 不设置静默跳过 Docker 的开关。 |
||||
|
并发使用 CountDownLatch,不用 sleep 调节竞争。 |
||||
|
最新 JAR 验收使用隔离 MySQL 8.4、Redis 7.4、RabbitMQ 3;所有夹具仅在隔离库,不使用真实手机号或线上密钥。 |
||||
|
短信 sender 使用受控 Mock,数据库获取测试验证码;不是实际运营商投递,也没有为前端增加 mockCode 旁路。 |
||||
|
|
||||
|
最新 JAR 三中间件黑盒场景包括:企业开关关闭安全拒绝;UNASSIGNED 空业务 roles;13 张表逐字段快照零写;求职者/企业身份选择;密码登录身份标记;全部旧 Access/Refresh 失效;同身份重试只读;跨身份冲突;未认证企业候选人访问被拒;真实 HTTP 双请求只一个 200、另一个 401;四个 RabbitMQ 队列消息数、ready、unacked 均为 0;精确删除三账号夹具及验证码,审计 append-only。 |
||||
|
首次以 Maven exec:java 同 JVM 运行时,场景已产生 66 checks / 0 failures,但 Testcontainers Ryuk 守护线程在插件清理阶段被中断,导致外层 Maven 返回非零;保留该失败事实,不写成 BUILD SUCCESS。 |
||||
|
最终改为独立 `java -cp` JVM 执行同一验收 main:再次 66 checks / 0 failures,进程正常退出 0,三容器按生命周期关闭,无插件线程清理异常。 |
||||
|
最终结果在被忽略的 `target/identity-probe-result.json`,日志在 `docs/acceptance/unified-identity/jar-probe-standalone.log`;不新增未跟踪 JSON 验收产物。 |
||||
|
被验收的最终 JAR SHA-256:`77d0125ba1d20007966be9fa7a7b3f82ef09fb5628b83bb5190262d318720cc9`。最终全量打包后仅重编译手工验收 main 的输出路径调整,没有再修改生产代码。 |
||||
|
|
||||
|
## OpenAPI 与前端切换 |
||||
|
|
||||
|
新增一个 path/operation,静态验证为 183 paths / 221 operations,`verify_openapi.py` All checks passed。 |
||||
|
最新 JAR `/v3/api-docs` 的完整 path/method operation 集合与静态文件完全一致,不只比较数量。 |
||||
|
前端登录成功后查看 identitySelectionRequired:true 进入身份选择页;false 根据旧 role 进入原工作台。 |
||||
|
首次选择成功必须同时替换 Access/Refresh Token,不能继续使用旧 Token。 |
||||
|
COMPANY 仍进入原 UNVERIFIED 入驻流程;不能直接进入已认证招聘能力。刷新后如需身份展示,继续查询 `/auth/me`。 |
||||
|
新账号已不自动成为求职者,因此部署必须与前端身份选择页协调,不能宣称旧前端零改动兼容。 |
||||
|
|
||||
|
## 敏感信息分类 |
||||
|
|
||||
|
本轮新增源码、测试与两份文档扫描:完整 JWT、字面 BCrypt 摘要、邮箱、PRIVATE KEY、AWS key、本机用户绝对路径均为 0 命中。 |
||||
|
手机号命中 22 行、15 个唯一值,全部来自独占的 `199000094xx/199000095xx/199000096xx` 假号段夹具;没有使用用户提供的真实手机号发送短信。 |
||||
|
手工验收 main 中的测试密码、JWT 测试密钥和 Testcontainers 口令为显式 test-only 常量,只在隔离库/容器使用,不是生产凭据。 |
||||
|
成功 JAR 日志通过自动断言确认不含三条测试手机号、测试明文密码、对应 BCrypt 摘要及指定签发 Token;拒绝响应不含异常类、SQL 连接串或存储字段。 |
||||
|
失败的 MockMvc 证据可能包含已失效的本地测试 Token,日志由既有 `**/*.log` 规则忽略,不进入提交;不把这类失败证据宣称为“所有日志零 Token”。 |
||||
|
历史启动日志中的本机目录与 Spring generated security password 警告仍属于既有环境输出,本轮未顺手重构该功能,也不声称这些观察项已消失。 |
||||
|
|
||||
|
## 边界与结束检查 |
||||
|
|
||||
|
最终分支 master,HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,暂存区空,`git diff --check` 为 0。 |
||||
|
V1–V46 与 pom.xml 无改动、无新迁移;WebMvcConfig 内容与 HEAD 一致,没有保留初版拦截器配置。 |
||||
|
历史 290 个未跟踪条目未删除,当前 300 = 历史 290 + 本轮 10 个新增文件。原有未提交 Phase B/P0/岗位改动保留,不把它们记成本轮产出。 |
||||
|
最新 JAR 两次启动按各自 Process 对象精确停止;最终无本轮 Java 残留,8080/8081 空闲。验收容器自动关闭,Docker 守护进程可保持运行。 |
||||
|
三账号及验证码夹具精确清理,清理过程中不删除审计行;审计随隔离容器自然生命周期结束,不触及线上审计数据。四个消息队列在关闭前 messages/ready/unacked 均为 0。 |
||||
|
未进入 Phase C;本轮不暂存、不提交、不推送、不部署。上线开关和已有生产账号由后续独立验收与发布检查点另行处理。 |
||||
@ -0,0 +1,76 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.servlet.http.HttpServletResponse; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.security.authentication.InsufficientAuthenticationException; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.util.AntPathMatcher; |
||||
|
import org.springframework.web.filter.OncePerRequestFilter; |
||||
|
import jakarta.servlet.FilterChain; |
||||
|
import jakarta.servlet.ServletException; |
||||
|
import java.io.IOException; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
/** 已认证但未选择身份的账号:默认拒绝业务,只允许精确的方法/路径白名单。 */ |
||||
|
@Component |
||||
|
@RequiredArgsConstructor |
||||
|
public class IdentityAccessFilter extends OncePerRequestFilter { |
||||
|
private static final Set<String> AUTH_POST = Set.of("/auth/identity/select", "/auth/logout", |
||||
|
"/auth/password/set", "/auth/password/change", "/auth/login", "/auth/password/login", |
||||
|
"/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register"); |
||||
|
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs", |
||||
|
"/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews", |
||||
|
"/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs", |
||||
|
"/job-recommendation-taxonomies", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**"); |
||||
|
private final UserRepository users; |
||||
|
private final CurrentUserAccessor currentUser; |
||||
|
private final ObjectMapper mapper; |
||||
|
private final RequestIdAccessor requestIds; |
||||
|
private final AntPathMatcher paths = new AntPathMatcher(); |
||||
|
|
||||
|
@Override |
||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) |
||||
|
throws ServletException, IOException { |
||||
|
// 当前生产认证仅由 JwtAuthenticationFilter 建立;只使用其已校验的会话主体。
|
||||
|
// 匿名请求继续交给既有 Security 入口,测试注入的非 JWT 主体不新增解析行为。
|
||||
|
var authentication=org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if(authentication==null || !(authentication.getDetails() instanceof com.mydreamjob.auth.SessionAuthenticationDetails)) { |
||||
|
chain.doFilter(request,response); return; |
||||
|
} |
||||
|
Long id; |
||||
|
try { id = currentUser.requireUserId(); } |
||||
|
catch (InsufficientAuthenticationException e) { chain.doFilter(request,response); return; } |
||||
|
User user; |
||||
|
try { user = users.findById(id).orElse(null); } |
||||
|
catch (org.springframework.dao.DataAccessException e) { |
||||
|
reject(request,response,503,ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE); return; |
||||
|
} |
||||
|
if (user == null || user.getRole() != User.Role.UNASSIGNED) {chain.doFilter(request,response); return;} |
||||
|
String uri = request.getRequestURI().substring(request.getContextPath().length()); |
||||
|
if (allowed(request.getMethod(), uri)) {chain.doFilter(request,response); return;} |
||||
|
reject(request,response,403,ErrorCode.IDENTITY_SELECTION_REQUIRED); |
||||
|
} |
||||
|
|
||||
|
private void reject(HttpServletRequest request, HttpServletResponse response, int status, ErrorCode error) throws IOException { |
||||
|
response.setStatus(status); |
||||
|
response.setContentType("application/json;charset=UTF-8"); |
||||
|
mapper.writeValue(response.getWriter(), ApiResponse.error(error.getCode(),error.getMessage(), requestIds.get(request))); |
||||
|
} |
||||
|
|
||||
|
boolean allowed(String method, String uri) { |
||||
|
if ("OPTIONS".equals(method) || paths.match("/actuator/**", uri)) return true; |
||||
|
if ("POST".equals(method)) return AUTH_POST.contains(uri); |
||||
|
if (!"GET".equals(method) && !"HEAD".equals(method)) return false; |
||||
|
// /companies/* 是公开企业 ID 路径,不能误把保留字 me 当作公开主页。
|
||||
|
if ("/companies/me".equals(uri) || uri.startsWith("/companies/me/")) return false; |
||||
|
return "/auth/me".equals(uri) || PUBLIC_GET.stream().anyMatch(pattern -> paths.match(pattern, uri)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails; |
||||
|
import jakarta.validation.Valid; |
||||
|
import jakarta.validation.constraints.AssertTrue; |
||||
|
import jakarta.validation.constraints.NotNull; |
||||
|
import lombok.Getter; |
||||
|
import lombok.Setter; |
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
|
||||
|
@Getter |
||||
|
@Setter |
||||
|
public class IdentitySelectionRequest { |
||||
|
public enum Identity { STUDENT, COMPANY } |
||||
|
|
||||
|
@NotNull |
||||
|
private Identity identity; |
||||
|
@Valid |
||||
|
private CompanyDetails company; |
||||
|
|
||||
|
@com.fasterxml.jackson.annotation.JsonAnySetter |
||||
|
public void rejectUnknown(String name, Object value) { |
||||
|
throw new IllegalArgumentException("request contains unsupported fields"); |
||||
|
} |
||||
|
|
||||
|
@AssertTrue(message = "company details are required only for company identity") |
||||
|
@Schema(hidden = true) |
||||
|
public boolean isCompanyShapeValid() { |
||||
|
return identity == null || (identity == Identity.COMPANY ? company != null : company == null); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonInclude; |
||||
|
|
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public record IdentitySelectionResponse(Long userId, String role, boolean identitySelectionRequired, |
||||
|
Long companyId, String onboardingStatus, String accessToken, String refreshToken, |
||||
|
Long expiresIn, boolean reauthenticationRequired) { } |
||||
@ -0,0 +1,104 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.*; |
||||
|
import com.mydreamjob.auth.company.CompanyRegistrationProperties; |
||||
|
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.company.CompanyOnboardingService; |
||||
|
import com.mydreamjob.deletion.DeletionStatus; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.deletion.AccountDeletionRepository; |
||||
|
import jakarta.validation.Validator; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.dao.DataAccessException; |
||||
|
import org.springframework.dao.DataIntegrityViolationException; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
import java.time.Clock; |
||||
|
import java.time.Instant; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.HashSet; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class IdentitySelectionService { |
||||
|
private final UserRepository users; |
||||
|
private final CompanyRepository companies; |
||||
|
private final AuthSessionService sessions; |
||||
|
private final AuditService audit; |
||||
|
private final AccountDeletionRepository deletions; |
||||
|
private final CompanyRegistrationProperties properties; |
||||
|
private final CompanyOnboardingService onboarding; |
||||
|
private final Validator validator; |
||||
|
private final Clock clock; |
||||
|
|
||||
|
@Transactional |
||||
|
public IdentitySelectionResponse select(Long userId, String sessionId, IdentitySelectionRequest request, |
||||
|
AuditContext context) { |
||||
|
if (request == null || !validator.validate(request).isEmpty()) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
try { |
||||
|
// 与刷新/密码变更相同:users -> auth_tokens;锁内重新确认会话,关闭 TOCTOU。
|
||||
|
User user = users.findByIdForUpdate(userId).orElseThrow(InvalidTokenException::new); |
||||
|
Instant now = clock.instant(); |
||||
|
if (!Boolean.TRUE.equals(user.getActive()) || !sessions.isSessionActive(userId, sessionId, now)) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
if (deletions.findByUserId(userId).filter(d -> d.getStatus() != DeletionStatus.NONE).isPresent()) { |
||||
|
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
||||
|
} |
||||
|
User.Role target = request.getIdentity() == IdentitySelectionRequest.Identity.STUDENT |
||||
|
? User.Role.STUDENT : User.Role.COMPANY; |
||||
|
if (user.getRole() != User.Role.UNASSIGNED) { |
||||
|
if (user.getRole() != target || user.getRoles() == null || !user.getRoles().equals(Set.of(target))) { |
||||
|
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
||||
|
} |
||||
|
Company company = target == User.Role.COMPANY ? companies.findByOwnerUserId(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT)) : null; |
||||
|
// 不重复更改资料/颁发会话/审计;客户端可使用已有凭证或重新登录。
|
||||
|
return response(user, company, null, true); |
||||
|
} |
||||
|
if (user.getRoles() == null || !user.getRoles().isEmpty() || companies.existsByOwnerUserId(userId)) { |
||||
|
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
||||
|
} |
||||
|
if (target == User.Role.COMPANY && !properties.isEnabled()) { |
||||
|
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED); |
||||
|
} |
||||
|
user.setRole(target); |
||||
|
user.setRoles(new HashSet<>(Set.of(target))); |
||||
|
users.saveAndFlush(user); |
||||
|
Company company = null; |
||||
|
if (target == User.Role.COMPANY) { |
||||
|
CompanyDetails d = request.getCompany(); |
||||
|
company = companies.saveAndFlush(Company.builder().ownerUserId(userId).name(d.getName()) |
||||
|
.logoUrl(d.getLogoUrl()).industry(d.getIndustry()).description(d.getDescription()) |
||||
|
.website(d.getWebsite()).address(d.getAddress()).verified(false).build()); |
||||
|
} |
||||
|
// modifying 会清理 persistence context;上面的角色与资料已 flush,锁仍持有。
|
||||
|
sessions.revokeAllUserSessions(userId, "IDENTITY_SELECTED", now); |
||||
|
AuthSessionService.SessionTokens tokens = sessions.createSession(user, now, context); |
||||
|
audit.logRequired(userId, "IDENTITY_SELECTED", "USER", userId, |
||||
|
"{\"result\":\"SUCCESS\"}", context, LocalDateTime.ofInstant(now, AuthSessionService.BUSINESS_ZONE)); |
||||
|
return response(user, company, tokens, false); |
||||
|
} catch (DataIntegrityViolationException e) { |
||||
|
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
||||
|
} catch (DataAccessException e) { |
||||
|
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private IdentitySelectionResponse response(User user, Company company, AuthSessionService.SessionTokens tokens, |
||||
|
boolean reauthenticate) { |
||||
|
return new IdentitySelectionResponse(user.getId(), user.getRole().name(), false, |
||||
|
company == null ? null : company.getId(), company == null ? null : |
||||
|
(tokens == null ? onboarding.get(user.getId()).onboardingStatus() : "UNVERIFIED"), |
||||
|
tokens == null ? null : tokens.accessToken(), tokens == null ? null : tokens.refreshToken(), |
||||
|
tokens == null ? null : AuthService.EXPIRES_IN_SECONDS, reauthenticate); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,49 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.security.UserRoleGuard; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import org.springframework.http.HttpStatus; |
||||
|
import java.util.Set; |
||||
|
import static org.assertj.core.api.Assertions.*; |
||||
|
|
||||
|
class IdentityPolicyTest { |
||||
|
@ParameterizedTest @ValueSource(strings={"/profile/me","/profile/me/deletion","/companies/me", |
||||
|
"/companies/me/onboarding","/companies/me/verification","/applications","/notifications", |
||||
|
"/conversations","/messages","/offers","/interviews","/abilities/types","/assessments/types", |
||||
|
"/company/candidates","/admin/users","/auth/identity/select/extra"}) |
||||
|
void businessGetIsDenied(String path) { |
||||
|
assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isFalse(); |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(strings={"/jobs","/jobs/search","/jobs/filters","/companies/42", |
||||
|
"/companies/42/jobs","/auth/me","/v3/api-docs","/health"}) |
||||
|
void publicGetRemainsAccessible(String path) { |
||||
|
assertThat(new IdentityAccessFilter(null,null,null,null).allowed("GET",path)).isTrue(); |
||||
|
} |
||||
|
@Test void exactPostAllowlistNeverAllowsBusinessOrAuthWildcard() { |
||||
|
var p=new IdentityAccessFilter(null,null,null,null); |
||||
|
assertThat(p.allowed("POST","/auth/identity/select")).isTrue(); |
||||
|
assertThat(p.allowed("POST","/auth/password/set")).isTrue(); |
||||
|
assertThat(p.allowed("POST","/auth/other")).isFalse(); |
||||
|
assertThat(p.allowed("POST","/jobs")).isFalse(); |
||||
|
assertThat(p.allowed("PUT","/auth/me")).isFalse(); |
||||
|
} |
||||
|
@Test void unassignedCannotUseContradictoryBusinessRoles() { |
||||
|
User u=User.builder().role(User.Role.UNASSIGNED).roles(Set.of(User.Role.ADMIN,User.Role.COMPANY,User.Role.STUDENT)).build(); |
||||
|
for(User.Role role:Set.of(User.Role.ADMIN,User.Role.COMPANY,User.Role.STUDENT)) { |
||||
|
assertThatThrownBy(() -> new UserRoleGuard(null).requireRole(u,role)).isInstanceOf(BusinessException.class) |
||||
|
.satisfies(e -> assertThat(((BusinessException)e).getErrorCodeInt()).isEqualTo(ErrorCode.IDENTITY_SELECTION_REQUIRED.getCode())); |
||||
|
} |
||||
|
} |
||||
|
@Test void newErrorMappingsAreExplicit() { |
||||
|
var r=new ErrorHttpStatusRegistry(); |
||||
|
assertThat(r.resolve(ErrorCode.IDENTITY_SELECTION_REQUIRED)).isEqualTo(HttpStatus.FORBIDDEN); |
||||
|
assertThat(r.resolve(ErrorCode.IDENTITY_SELECTION_CONFLICT)).isEqualTo(HttpStatus.CONFLICT); |
||||
|
assertThat(r.resolve(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE)).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,205 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.*; |
||||
|
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.boot.test.mock.mockito.SpyBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.context.DynamicPropertyRegistry; |
||||
|
import org.springframework.test.context.DynamicPropertySource; |
||||
|
import org.springframework.test.util.AopTestUtils; |
||||
|
import org.springframework.dao.DataAccessResourceFailureException; |
||||
|
import org.springframework.transaction.PlatformTransactionManager; |
||||
|
import org.springframework.transaction.support.TransactionTemplate; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import org.testcontainers.junit.jupiter.Container; |
||||
|
import org.testcontainers.junit.jupiter.Testcontainers; |
||||
|
import java.time.Instant; |
||||
|
import java.util.*; |
||||
|
import java.util.concurrent.*; |
||||
|
import static org.assertj.core.api.Assertions.*; |
||||
|
import static org.mockito.ArgumentMatchers.*; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
@Testcontainers(disabledWithoutDocker = false) |
||||
|
@SpringBootTest(properties = "app.company-registration.enabled=true") |
||||
|
@ActiveProfiles("mysql-it") |
||||
|
class IdentitySelectionMySqlTest { |
||||
|
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4") |
||||
|
.withDatabaseName("identity_selection").withUsername("identity_test").withPassword("identity-test-only"); |
||||
|
@DynamicPropertySource static void properties(DynamicPropertyRegistry r) { |
||||
|
r.add("spring.datasource.url", MYSQL::getJdbcUrl); |
||||
|
r.add("spring.datasource.username", MYSQL::getUsername); |
||||
|
r.add("spring.datasource.password", MYSQL::getPassword); |
||||
|
} |
||||
|
@Autowired IdentitySelectionService service; |
||||
|
@SpyBean UserRepository users; |
||||
|
@SpyBean CompanyRepository companies; |
||||
|
@SpyBean AuthSessionService sessions; |
||||
|
@SpyBean AuditService audit; |
||||
|
@Autowired PlatformTransactionManager transactions; |
||||
|
@Autowired JdbcTemplate jdbc; |
||||
|
@MockBean RedisTemplate<String,Object> redis; |
||||
|
private final List<Long> owned = new ArrayList<>(); |
||||
|
private final AuditContext context = AuditContext.builder().requestId("identity-mysql-test").build(); |
||||
|
|
||||
|
private record Fixture(Long id, AuthSessionService.SessionTokens tokens) { } |
||||
|
private Fixture fixture() { |
||||
|
return new TransactionTemplate(transactions).execute(tx -> { |
||||
|
String phone = "199000095"+String.format("%02d", owned.size()+1); |
||||
|
assertThat(users.findByPhone(phone)).isEmpty(); |
||||
|
User u = users.saveAndFlush(User.builder().phone(phone).role(User.Role.UNASSIGNED) |
||||
|
.roles(new HashSet<>()).active(true).build()); |
||||
|
owned.add(u.getId()); |
||||
|
return new Fixture(u.getId(), sessions.createSession(u, Instant.now(), context)); |
||||
|
}); |
||||
|
} |
||||
|
private IdentitySelectionRequest request(String identity) { |
||||
|
var r = new IdentitySelectionRequest(); |
||||
|
r.setIdentity(IdentitySelectionRequest.Identity.valueOf(identity)); |
||||
|
if ("COMPANY".equals(identity)) { |
||||
|
var c = new CompanyDetails(); c.setName("身份竞争测试企业"); r.setCompany(c); |
||||
|
} |
||||
|
return r; |
||||
|
} |
||||
|
@AfterEach void cleanup() { |
||||
|
reset(users, companies, sessions, audit); |
||||
|
for (Long id : owned) { |
||||
|
jdbc.update("delete from auth_tokens where user_id=?", id); |
||||
|
jdbc.update("delete from account_deletions where user_id=?", id); |
||||
|
jdbc.update("delete from companies where owner_user_id=?", id); |
||||
|
jdbc.update("delete from user_roles where user_id=?", id); |
||||
|
jdbc.update("delete from users where id=?", id); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from users where id=?",Long.class,id)).isZero(); |
||||
|
} |
||||
|
// 审计 append-only,容器销毁后自然结束,不删除审计行。
|
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest @ValueSource(strings={"STUDENT","COMPANY"}) |
||||
|
void successfulSelectionRevokesAllOldSessionsAndIssuesCurrentRole(String role) { |
||||
|
Fixture f = fixture(); |
||||
|
var response = service.select(f.id(), f.tokens().sessionId(), request(role), context); |
||||
|
assertThat(response.role()).isEqualTo(role); |
||||
|
assertThat(jdbc.queryForList("select role from user_roles where user_id=?",String.class,f.id())).containsExactly(role); |
||||
|
assertThat(jdbc.queryForObject("select role from users where id=?",String.class,f.id())).isEqualTo(role); |
||||
|
assertThat(sessions.isSessionActive(f.id(),f.tokens().sessionId(),Instant.now())).isFalse(); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from auth_tokens where user_id=? and revoked=0 and consumed_at is null",Long.class,f.id())).isEqualTo(1L); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest @ValueSource(strings={"role","company","revoke","session","audit"}) |
||||
|
void anyFailureRollsBackRoleCompanyOldRevocationAndNewTokens(String stage) { |
||||
|
Fixture f = fixture(); |
||||
|
Map<String,List<Map<String,Object>>> before = snapshot(f.id()); |
||||
|
RuntimeException fault = new IllegalStateException("injected identity test failure"); |
||||
|
switch(stage) { |
||||
|
case "role" -> doThrow(new DataAccessResourceFailureException("injected repository failure")).when(users).saveAndFlush(any(User.class)); |
||||
|
case "company" -> doThrow(new DataAccessResourceFailureException("injected repository failure")).when(companies).saveAndFlush(any()); |
||||
|
case "revoke" -> doAnswer(call -> {call.callRealMethod(); throw fault;}).when(sessions) |
||||
|
.revokeAllUserSessions(eq(f.id()),eq("IDENTITY_SELECTED"),any()); |
||||
|
case "session" -> doThrow(fault).when(sessions).createSession(any(),any(),any()); |
||||
|
// 只对真实事务代理内部的 spy 注入故障;装配阶段不调用 MANDATORY 代理。
|
||||
|
case "audit" -> doThrow(fault).when((AuditService) AopTestUtils.getUltimateTargetObject(audit)) |
||||
|
.logRequired(eq(f.id()),eq("IDENTITY_SELECTED"),any(),any(),any(),any(),any()); |
||||
|
} |
||||
|
Throwable failure = catchThrowable(() -> service.select(f.id(),f.tokens().sessionId(),request("COMPANY"),context)); |
||||
|
if (stage.equals("role") || stage.equals("company")) { |
||||
|
assertThat(failure).isInstanceOf(BusinessException.class); |
||||
|
assertThat(((BusinessException) failure).getErrorCodeInt()).isEqualTo(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE.getCode()); |
||||
|
assertThat(failure).hasMessage(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE.getMessage()); |
||||
|
} else { |
||||
|
assertThat(failure).isSameAs(fault); |
||||
|
} |
||||
|
reset(users,companies,sessions,audit); |
||||
|
assertThat(snapshot(f.id())).isEqualTo(before); |
||||
|
assertThat(sessions.isSessionActive(f.id(),f.tokens().sessionId(),Instant.now())).isTrue(); |
||||
|
} |
||||
|
private Map<String,List<Map<String,Object>>> snapshot(Long id) { |
||||
|
Map<String,List<Map<String,Object>>> rows = new LinkedHashMap<>(); |
||||
|
rows.put("users",jdbc.queryForList("select * from users where id=? order by id",id)); |
||||
|
rows.put("roles",jdbc.queryForList("select * from user_roles where user_id=? order by role",id)); |
||||
|
rows.put("companies",jdbc.queryForList("select * from companies where owner_user_id=? order by id",id)); |
||||
|
rows.put("tokens",jdbc.queryForList("select * from auth_tokens where user_id=? order by id",id)); |
||||
|
rows.put("notifications",jdbc.queryForList("select * from notifications order by id")); |
||||
|
return rows; |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(strings={"STUDENT","COMPANY"}) |
||||
|
void twoConcurrentSelectionsProduceOnlyOneCommittedIdentity(String other) throws Exception { |
||||
|
Fixture f = fixture(); |
||||
|
List<Object> results = parallel( |
||||
|
() -> service.select(f.id(),f.tokens().sessionId(),request("COMPANY"),context), |
||||
|
() -> service.select(f.id(),f.tokens().sessionId(),request(other),context)); |
||||
|
assertThat(results.stream().filter(IdentitySelectionResponse.class::isInstance).count()).isEqualTo(1); |
||||
|
assertThat(results.stream().filter(InvalidTokenException.class::isInstance).count()).isEqualTo(1); |
||||
|
String role = jdbc.queryForObject("select role from users where id=?",String.class,f.id()); |
||||
|
assertThat(jdbc.queryForList("select role from user_roles where user_id=?",String.class,f.id())).containsExactly(role); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from companies where owner_user_id=?",Long.class,f.id())) |
||||
|
.isEqualTo("COMPANY".equals(role)?1L:0L); |
||||
|
assertThat(jdbc.queryForObject("select count(*) from auth_tokens where user_id=? and revoked=0",Long.class,f.id())).isEqualTo(1L); |
||||
|
} |
||||
|
@Test void refreshRaceCannotKeepPreSelectionSessionAlive() throws Exception { |
||||
|
Fixture f = fixture(); |
||||
|
List<Object> results = parallel( |
||||
|
() -> service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context), |
||||
|
() -> sessions.refresh(f.tokens().refreshToken(),Instant.now(),context)); |
||||
|
assertThat(results.get(0)).isInstanceOf(IdentitySelectionResponse.class); |
||||
|
assertThat(results.get(1)).matches(x -> x instanceof AuthSessionService.SessionTokens || x instanceof InvalidTokenException); |
||||
|
assertThat(sessions.isSessionActive(f.id(),f.tokens().sessionId(),Instant.now())).isFalse(); |
||||
|
} |
||||
|
@Test void inactiveUserCannotSelectIdentity() { |
||||
|
Fixture f=fixture(); |
||||
|
// 锁内重新检查 active,不能依赖请求进入前的 JWT 校验。
|
||||
|
jdbc.update("update users set is_active=0 where id=?",f.id()); |
||||
|
assertThatThrownBy(() -> service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context)) |
||||
|
.isInstanceOf(InvalidTokenException.class); |
||||
|
assertThat(jdbc.queryForObject("select role from users where id=?",String.class,f.id())).isEqualTo("UNASSIGNED"); |
||||
|
} |
||||
|
@ParameterizedTest @ValueSource(strings={"PENDING","COMPLETED"}) |
||||
|
void deletingAccountCannotSelect(String status) { |
||||
|
Fixture f=fixture(); |
||||
|
jdbc.update("insert into account_deletions (user_id,status,version,created_at,updated_at) values (?,?,0,now(),now())",f.id(),status); |
||||
|
var before=snapshot(f.id()); |
||||
|
Throwable failure=catchThrowable(() -> service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context)); |
||||
|
// 此夹具故意保留 active=true;注销行由身份 Service 自身门禁拒绝。
|
||||
|
assertThat(failure).isInstanceOf(BusinessException.class).hasMessage(ErrorCode.IDENTITY_SELECTION_CONFLICT.getMessage()); |
||||
|
assertThat(snapshot(f.id())).isEqualTo(before); |
||||
|
} |
||||
|
@Test void cancelledDeletionDoesNotPreventIdentitySelection() { |
||||
|
Fixture f=fixture(); |
||||
|
jdbc.update("insert into account_deletions (user_id,status,version,created_at,updated_at) values (?, 'NONE',0,now(),now())",f.id()); |
||||
|
assertThat(service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context).role()).isEqualTo("STUDENT"); |
||||
|
} |
||||
|
@Test void contradictoryBusinessRolesFailClosed() { |
||||
|
Fixture f=fixture(); |
||||
|
jdbc.update("insert into user_roles (user_id,role) values (?, 'ADMIN')",f.id()); |
||||
|
var before=snapshot(f.id()); |
||||
|
assertThatThrownBy(() -> service.select(f.id(),f.tokens().sessionId(),request("STUDENT"),context)) |
||||
|
.isInstanceOf(BusinessException.class).hasMessage(ErrorCode.IDENTITY_SELECTION_CONFLICT.getMessage()); |
||||
|
assertThat(snapshot(f.id())).isEqualTo(before); |
||||
|
} |
||||
|
private List<Object> parallel(Callable<Object> first, Callable<Object> second) throws Exception { |
||||
|
ExecutorService pool=Executors.newFixedThreadPool(2); |
||||
|
CountDownLatch ready=new CountDownLatch(2), start=new CountDownLatch(1); |
||||
|
try { |
||||
|
List<Future<Object>> futures = new ArrayList<>(); |
||||
|
for (Callable<Object> action : List.of(first,second)) { |
||||
|
futures.add(pool.submit(() -> {ready.countDown(); assertThat(start.await(10,TimeUnit.SECONDS)).isTrue(); |
||||
|
try{return action.call();}catch(RuntimeException e){return e;}})); |
||||
|
} |
||||
|
assertThat(ready.await(10,TimeUnit.SECONDS)).isTrue(); start.countDown(); |
||||
|
return List.of(futures.get(0).get(30,TimeUnit.SECONDS),futures.get(1).get(30,TimeUnit.SECONDS)); |
||||
|
} finally {start.countDown(); pool.shutdownNow();} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.SessionAuthenticationDetails; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.dao.DataAccessResourceFailureException; |
||||
|
import org.springframework.mock.web.MockHttpServletRequest; |
||||
|
import org.springframework.mock.web.MockHttpServletResponse; |
||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.atomic.AtomicInteger; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.verifyNoInteractions; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
/** 独立检查过滤器实际响应,不用生产白名单生成期望。 */ |
||||
|
class UnifiedIdentityIndependentBoundaryTest { |
||||
|
private final UserRepository users = mock(UserRepository.class); |
||||
|
private final ObjectMapper mapper = new ObjectMapper().registerModule( |
||||
|
new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); |
||||
|
private final IdentityAccessFilter filter = new IdentityAccessFilter( |
||||
|
users, new CurrentUserAccessor(), mapper, new RequestIdAccessor()); |
||||
|
|
||||
|
@AfterEach |
||||
|
void clearContext() { |
||||
|
SecurityContextHolder.clearContext(); |
||||
|
} |
||||
|
|
||||
|
private MockHttpServletRequest authenticatedRequest(String method, String uri) { |
||||
|
var request = new MockHttpServletRequest(method, "/api/v1" + uri); |
||||
|
request.setContextPath("/api/v1"); |
||||
|
request.addHeader("X-Request-Id", "identity-independent-boundary"); |
||||
|
// 故意设置 ADMIN authority,证明数据库待选身份仍优先于凭证角色标签。
|
||||
|
var authentication = new UsernamePasswordAuthenticationToken("42", null, |
||||
|
List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))); |
||||
|
authentication.setDetails(new SessionAuthenticationDetails(request, "independent-session")); |
||||
|
SecurityContextHolder.getContext().setAuthentication(authentication); |
||||
|
return request; |
||||
|
} |
||||
|
|
||||
|
private void pendingUser() { |
||||
|
when(users.findById(42L)).thenReturn(Optional.of(User.builder().id(42L) |
||||
|
.role(User.Role.UNASSIGNED).roles(Set.of(User.Role.ADMIN)).active(true).build())); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void databaseIdentityOverridesAdminAuthorityBeforeBusinessChain() throws Exception { |
||||
|
pendingUser(); |
||||
|
var response = new MockHttpServletResponse(); |
||||
|
var entered = new AtomicInteger(); |
||||
|
filter.doFilter(authenticatedRequest("GET", "/admin/users"), response, |
||||
|
(request, result) -> entered.incrementAndGet()); |
||||
|
assertThat(entered.get()).isZero(); |
||||
|
assertThat(response.getStatus()).isEqualTo(403); |
||||
|
var json = mapper.readTree(response.getContentAsString()); |
||||
|
assertThat(json.path("code").asInt()).isEqualTo(10401); |
||||
|
assertThat(json.path("requestId").asText()).isEqualTo("identity-independent-boundary"); |
||||
|
assertThat(response.getContentAsString()).doesNotContain("Exception", "session", "jdbc:"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void databaseFailureStopsChainAndReturnsSafe503() throws Exception { |
||||
|
when(users.findById(42L)).thenThrow(new DataAccessResourceFailureException( |
||||
|
"private-database-diagnostic-marker")); |
||||
|
var response = new MockHttpServletResponse(); |
||||
|
var entered = new AtomicInteger(); |
||||
|
filter.doFilter(authenticatedRequest("POST", "/auth/identity/select"), response, |
||||
|
(request, result) -> entered.incrementAndGet()); |
||||
|
assertThat(entered.get()).isZero(); |
||||
|
assertThat(response.getStatus()).isEqualTo(503); |
||||
|
assertThat(mapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(10403); |
||||
|
assertThat(response.getContentAsString()).doesNotContain("private-database-diagnostic-marker", |
||||
|
"Exception", "jdbc:"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void allowedIdentitySelectionPassesExactlyOnceWithContextPath() throws Exception { |
||||
|
pendingUser(); |
||||
|
var entered = new AtomicInteger(); |
||||
|
filter.doFilter(authenticatedRequest("POST", "/auth/identity/select"), |
||||
|
new MockHttpServletResponse(), (request, result) -> entered.incrementAndGet()); |
||||
|
assertThat(entered.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void wrongMethodAndUnknownAccountPathStayClosed() { |
||||
|
for (String method : List.of("GET", "HEAD", "PUT", "PATCH", "DELETE")) { |
||||
|
assertThat(filter.allowed(method, "/auth/identity/select")).as(method).isFalse(); |
||||
|
} |
||||
|
assertThat(filter.allowed("POST", "/auth/identity/select/extra")).isFalse(); |
||||
|
assertThat(filter.allowed("POST", "/auth/future-feature")).isFalse(); |
||||
|
assertThat(filter.allowed("HEAD", "/companies/me/onboarding")).isFalse(); |
||||
|
assertThat(filter.allowed("HEAD", "/jobs")).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void selectedExistingCompanyIsNotBlockedByPendingIdentityFilter() throws Exception { |
||||
|
when(users.findById(42L)).thenReturn(Optional.of(User.builder().id(42L) |
||||
|
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)).active(true).build())); |
||||
|
var entered = new AtomicInteger(); |
||||
|
filter.doFilter(authenticatedRequest("GET", "/companies/me/onboarding"), |
||||
|
new MockHttpServletResponse(), (request, result) -> entered.incrementAndGet()); |
||||
|
assertThat(entered.get()).isEqualTo(1); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void anonymousRequestRemainsOwnedByExistingSecurityChain() throws Exception { |
||||
|
SecurityContextHolder.clearContext(); |
||||
|
var entered = new AtomicInteger(); |
||||
|
filter.doFilter(new MockHttpServletRequest("GET", "/admin/users"), |
||||
|
new MockHttpServletResponse(), (request, result) -> entered.incrementAndGet()); |
||||
|
assertThat(entered.get()).isEqualTo(1); |
||||
|
verifyNoInteractions(users); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void topLevelAndNestedPrivilegeInjectionAreRejectedEvenWithPermissiveMapper() { |
||||
|
var permissive = new ObjectMapper().disable( |
||||
|
com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); |
||||
|
for (String json : List.of( |
||||
|
"{\"identity\":\"STUDENT\",\"role\":\"ADMIN\"}", |
||||
|
"{\"identity\":\"STUDENT\",\"userId\":42}", |
||||
|
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"独立测试企业\",\"verified\":true}}")) { |
||||
|
assertThatThrownBy(() -> permissive.readValue(json, IdentitySelectionRequest.class)) |
||||
|
.isInstanceOf(com.fasterxml.jackson.databind.JsonMappingException.class); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,236 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; |
||||
|
import org.testcontainers.containers.GenericContainer; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import org.testcontainers.lifecycle.Startables; |
||||
|
|
||||
|
import java.net.ServerSocket; |
||||
|
import java.net.URI; |
||||
|
import java.net.http.*; |
||||
|
import java.nio.file.*; |
||||
|
import java.sql.*; |
||||
|
import java.time.Duration; |
||||
|
import java.util.*; |
||||
|
import java.util.concurrent.*; |
||||
|
import java.util.concurrent.locks.LockSupport; |
||||
|
import java.util.stream.Stream; |
||||
|
import static org.assertj.core.api.Assertions.*; |
||||
|
|
||||
|
/** 手工验收入口:构建后用 exec:java 执行,启动最新 JAR 和三个隔离真实中间件。 */ |
||||
|
public final class UnifiedIdentityJarAcceptanceProbe { |
||||
|
private static final ObjectMapper JSON=new ObjectMapper(); |
||||
|
private static final HttpClient HTTP=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); |
||||
|
private static final String PASSWORD="IdentityProbe!2026#A"; |
||||
|
private static int checks; |
||||
|
private static Process app; |
||||
|
private static int port; |
||||
|
private static Connection db; |
||||
|
|
||||
|
public static void main(String[] args) throws Exception { |
||||
|
Path evidence=Path.of("../docs/acceptance/unified-identity"); |
||||
|
Files.createDirectories(evidence); |
||||
|
try(var mysql=new MySQLContainer<>("mysql:8.4").withDatabaseName("identity_blackbox") |
||||
|
.withUsername("identity_probe").withPassword("identity-probe-container-only"); |
||||
|
var redis=new GenericContainer<>("redis:7.4-alpine").withExposedPorts(6379); |
||||
|
var rabbit=new GenericContainer<>("rabbitmq:3.13-management-alpine").withExposedPorts(5672,15672) |
||||
|
.withEnv("RABBITMQ_DEFAULT_USER","identity_probe") |
||||
|
.withEnv("RABBITMQ_DEFAULT_PASS","identity-probe-container-only")) { |
||||
|
Startables.deepStart(Stream.of(mysql,redis,rabbit)).join(); |
||||
|
try(Connection connection=DriverManager.getConnection(mysql.getJdbcUrl(),mysql.getUsername(),mysql.getPassword())) { |
||||
|
db=connection; |
||||
|
try { |
||||
|
start(mysql,redis,rabbit,false,evidence.resolve("jar-disabled.log")); |
||||
|
JsonNode company=login("19900009601"); |
||||
|
call("POST","/auth/identity/select",company.path("accessToken").asText(), |
||||
|
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"统一身份黑盒测试企业\"}}",503); |
||||
|
assertThat(one("select role from users where phone=?","19900009601")).isEqualTo("UNASSIGNED"); checks++; |
||||
|
stop(); |
||||
|
start(mysql,redis,rabbit,true,evidence.resolve("jar-enabled.log")); |
||||
|
JsonNode runtime=call("GET","/v3/api-docs",null,null,200); |
||||
|
JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json"))); |
||||
|
assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++; |
||||
|
assertThat(runtime.path("paths").size()).isEqualTo(183); checks++; |
||||
|
assertThat(operations(runtime)).hasSize(221); checks++; |
||||
|
JsonNode pending=login("19900009602"); |
||||
|
assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++; |
||||
|
assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++; |
||||
|
assertThat(one("select count(*) from user_roles where user_id=?",pending.path("userId").asLong())).isEqualTo(0L); checks++; |
||||
|
String pendingAccess=pending.path("accessToken").asText(); |
||||
|
var before=snapshot(); |
||||
|
for(String uri:List.of("/profile/me","/companies/me/onboarding","/notifications","/assessments/types","/company/candidates")) { |
||||
|
JsonNode denied=call("GET",uri,pendingAccess,null,403); |
||||
|
assertThat(denied.path("code").asInt()).isEqualTo(10401); checks++; |
||||
|
} |
||||
|
call("POST","/jobs",pendingAccess,"{}",403); |
||||
|
assertThat(snapshot()).isEqualTo(before); checks++; |
||||
|
call("GET","/jobs",pendingAccess,null,200); |
||||
|
assertThat(call("GET","/auth/me",pendingAccess,null,200).path("data").path("identitySelectionRequired").asBoolean()).isTrue(); checks++; |
||||
|
// 仅隔离验收库准备 BCrypt 夹具;不伪造真实短信服务商投递。
|
||||
|
String hash=new BCryptPasswordEncoder().encode(PASSWORD); |
||||
|
update("update users set password_hash=?, password_changed_at=now() where id=?",hash,pending.path("userId").asLong()); |
||||
|
JsonNode passwordLogin=call("POST","/auth/password/login",null, |
||||
|
JSON.writeValueAsString(Map.of("phone","19900009602","password",PASSWORD)),200).path("data"); |
||||
|
assertThat(passwordLogin.path("identitySelectionRequired").asBoolean()).isTrue(); checks++; |
||||
|
JsonNode student=call("POST","/auth/identity/select",passwordLogin.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}",200).path("data"); |
||||
|
assertThat(student.path("role").asText()).isEqualTo("STUDENT"); checks++; |
||||
|
assertThat(JSON.readTree(Base64.getUrlDecoder().decode(student.path("accessToken").asText().split("\\.")[1])).path("role").asText()).isEqualTo("STUDENT"); checks++; |
||||
|
assertThat(one("select role from user_roles where user_id=?",pending.path("userId").asLong())).isEqualTo("STUDENT"); checks++; |
||||
|
for(JsonNode old:List.of(pending,passwordLogin)) { |
||||
|
call("GET","/auth/me",old.path("accessToken").asText(),null,401); |
||||
|
call("POST","/auth/refresh",null,JSON.writeValueAsString(Map.of("refreshToken",old.path("refreshToken").asText())),401); |
||||
|
} |
||||
|
call("GET","/auth/me",student.path("accessToken").asText(),null,200); |
||||
|
JsonNode repeated=call("POST","/auth/identity/select",student.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}",200).path("data"); |
||||
|
assertThat(repeated.has("accessToken")).isFalse(); checks++; |
||||
|
call("POST","/auth/identity/select",student.path("accessToken").asText(), |
||||
|
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"拒绝切换企业\"}}",409); |
||||
|
JsonNode selectedCompany=call("POST","/auth/identity/select",company.path("accessToken").asText(), |
||||
|
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"统一身份黑盒测试企业\"}}",200).path("data"); |
||||
|
assertThat(selectedCompany.path("onboardingStatus").asText()).isEqualTo("UNVERIFIED"); checks++; |
||||
|
assertThat(one("select role from user_roles where user_id=?",company.path("userId").asLong())).isEqualTo("COMPANY"); checks++; |
||||
|
assertThat(one("select verified from companies where owner_user_id=?",company.path("userId").asLong())).isEqualTo(false); checks++; |
||||
|
assertThat(call("GET","/companies/me/onboarding",selectedCompany.path("accessToken").asText(),null,200) |
||||
|
.path("data").path("onboardingStatus").asText()).isEqualTo("UNVERIFIED"); checks++; |
||||
|
update("update users set password_hash=?, password_changed_at=now() where id=?",hash,company.path("userId").asLong()); |
||||
|
JsonNode companyPasswordLogin=call("POST","/auth/password/login",null, |
||||
|
JSON.writeValueAsString(Map.of("phone","19900009601","password",PASSWORD)),200).path("data"); |
||||
|
assertThat(companyPasswordLogin.path("role").asText()).isEqualTo("COMPANY"); checks++; |
||||
|
assertThat(companyPasswordLogin.path("identitySelectionRequired").asBoolean()).isFalse(); checks++; |
||||
|
assertThat(JSON.readTree(Base64.getUrlDecoder().decode(companyPasswordLogin.path("accessToken").asText().split("\\.")[1])).path("role").asText()).isEqualTo("COMPANY"); checks++; |
||||
|
call("GET","/company/candidates",selectedCompany.path("accessToken").asText(),null,403); |
||||
|
JsonNode race=login("19900009603"); |
||||
|
concurrentSelect(race); |
||||
|
assertThat(one("select count(*) from user_roles where user_id=?",race.path("userId").asLong())).isEqualTo(1L); checks++; |
||||
|
assertThat(one("select count(*) from auth_tokens where user_id=? and revoked=0 and consumed_at is null",race.path("userId").asLong())).isEqualTo(1L); checks++; |
||||
|
String rabbitUrl="http://"+rabbit.getHost()+":"+rabbit.getMappedPort(15672)+"/api/queues"; |
||||
|
String basic="Basic "+Base64.getEncoder().encodeToString("identity_probe:identity-probe-container-only".getBytes(java.nio.charset.StandardCharsets.UTF_8)); |
||||
|
JsonNode queues=JSON.readTree(HTTP.send(HttpRequest.newBuilder(URI.create(rabbitUrl)).header("Authorization",basic).GET().build(),HttpResponse.BodyHandlers.ofString()).body()); |
||||
|
assertThat(queues.size()).isEqualTo(4); checks++; |
||||
|
for(JsonNode q:queues) { |
||||
|
assertThat(q.has("messages")).isTrue(); |
||||
|
assertThat(q.path("messages").asLong()).isZero(); |
||||
|
assertThat(q.path("messages_ready").asLong()).isZero(); |
||||
|
assertThat(q.path("messages_unacknowledged").asLong()).isZero(); checks++; |
||||
|
} |
||||
|
cleanup(); |
||||
|
assertThat(one("select count(*) from users where phone in (?,?,?)","19900009601","19900009602","19900009603")).isEqualTo(0L); checks++; |
||||
|
stop(); |
||||
|
for(String name:List.of("jar-disabled.log","jar-enabled.log")) { |
||||
|
String log=Files.readString(evidence.resolve(name)); |
||||
|
assertThat(log).doesNotContain(PASSWORD,hash,pendingAccess,student.path("accessToken").asText(), |
||||
|
"19900009601","19900009602","19900009603"); checks++; |
||||
|
} |
||||
|
Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of( |
||||
|
"checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3", |
||||
|
"paths",183,"operations",221,"smsDelivery","mock sender; not real provider", |
||||
|
"fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle"))); |
||||
|
System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 183 paths/221 operations; fixture residual 0"); |
||||
|
} finally {stop();} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static void start(MySQLContainer<?> mysql,GenericContainer<?> redis,GenericContainer<?> rabbit,boolean enabled,Path log) throws Exception { |
||||
|
port=freePort(); int management=freePort(); |
||||
|
var builder=new ProcessBuilder(Path.of(System.getProperty("java.home"),"bin","java.exe").toString(), |
||||
|
"-Dfile.encoding=UTF-8","-jar","target/mydreamjob-server-0.0.1-SNAPSHOT.jar","--server.port="+port, |
||||
|
"--server.address=127.0.0.1","--management.server.port="+management); |
||||
|
var env=builder.environment(); |
||||
|
env.put("SPRING_PROFILES_ACTIVE",""); env.put("DB_URL",mysql.getJdbcUrl()); |
||||
|
env.put("DB_USERNAME",mysql.getUsername()); env.put("DB_PASSWORD",mysql.getPassword()); |
||||
|
env.put("REDIS_HOST",redis.getHost()); env.put("REDIS_PORT",redis.getMappedPort(6379).toString()); env.put("REDIS_PASSWORD",""); |
||||
|
env.put("RABBITMQ_HOST",rabbit.getHost()); env.put("RABBITMQ_PORT",rabbit.getMappedPort(5672).toString()); |
||||
|
env.put("RABBITMQ_USERNAME","identity_probe"); env.put("RABBITMQ_PASSWORD","identity-probe-container-only"); env.put("RABBITMQ_VHOST","/"); |
||||
|
env.put("JWT_SECRET","identity-probe-legacy-key-test-only-0001"); |
||||
|
env.put("JWT_ACCESS_SECRET","identity-probe-access-key-test-only-0001"); |
||||
|
env.put("JWT_REFRESH_SECRET","identity-probe-refresh-key-test-only-0001"); |
||||
|
env.put("JWT_TOKEN_HASH_SECRET","identity-probe-hash-key-test-only-0001"); |
||||
|
env.put("DELETION_PHONE_HASH_SECRET","identity-probe-deletion-key-test-only-0001"); |
||||
|
env.put("PASSWORD_LOGIN_ENABLED","true"); env.put("PASSWORD_RATE_KEY_SECRET","identity-probe-password-key-test-only-0001"); |
||||
|
env.put("COMPANY_REGISTRATION_ENABLED",Boolean.toString(enabled)); env.put("ADMIN_BOOTSTRAP_ENABLED","false"); |
||||
|
env.put("SMS_MOCK_ENABLED","true"); env.put("OFFER_SCHEDULER_ENABLED","false"); |
||||
|
env.put("MATCH_NOTIFICATION_SCHEDULER_ENABLED","false"); env.put("SCORING_REFRESH_ENABLED","false"); |
||||
|
env.put("SCORING_REFRESH_COMPENSATION_ENABLED","false"); |
||||
|
env.put("RESUME_STORAGE_DIR","./target/identity-probe-storage/resumes"); |
||||
|
env.put("RESUME_EXPORT_DIR","./target/identity-probe-storage/resume-exports"); |
||||
|
env.put("DATA_EXPORT_DIR","./target/identity-probe-storage/data-exports"); |
||||
|
app=builder.redirectErrorStream(true).redirectOutput(log.toFile()).start(); |
||||
|
long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(90); |
||||
|
while(System.nanoTime()<deadline) { |
||||
|
assertThat(app.isAlive()).as("JAR must remain alive; inspect redacted local evidence").isTrue(); |
||||
|
try { |
||||
|
if(HTTP.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1/health")) |
||||
|
.timeout(Duration.ofSeconds(2)).GET().build(),HttpResponse.BodyHandlers.discarding()).statusCode()==200) return; |
||||
|
} catch(java.io.IOException e) { /* 尚未监听,不视为验收成功。 */ } |
||||
|
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(100)); |
||||
|
} |
||||
|
throw new AssertionError("JAR readiness timeout"); |
||||
|
} |
||||
|
private static int freePort() throws Exception {try(ServerSocket socket=new ServerSocket(0)){return socket.getLocalPort();}} |
||||
|
private static void stop() throws Exception { |
||||
|
if(app!=null) {app.destroy(); if(!app.waitFor(15,TimeUnit.SECONDS)){app.destroyForcibly();assertThat(app.waitFor(10,TimeUnit.SECONDS)).isTrue();} app=null;} |
||||
|
} |
||||
|
private static HttpResponse<String> send(String method,String uri,String token,String body) throws Exception { |
||||
|
var b=HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/api/v1"+uri)).timeout(Duration.ofSeconds(15)); |
||||
|
if(token!=null)b.header("Authorization","Bearer "+token); |
||||
|
if(body!=null)b.header("Content-Type","application/json"); |
||||
|
return HTTP.send(b.method(method,body==null?HttpRequest.BodyPublishers.noBody():HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString()); |
||||
|
} |
||||
|
private static JsonNode call(String method,String uri,String token,String body,int expected) throws Exception { |
||||
|
var response=send(method,uri,token,body); |
||||
|
assertThat(response.statusCode()).as("%s %s",method,uri).isEqualTo(expected); checks++; |
||||
|
JsonNode result=JSON.readTree(response.body()); |
||||
|
if(expected>=400) assertThat(response.body()).doesNotContain("Exception","jdbc:","passwordHash","storagePath","at com.mydreamjob"); |
||||
|
return result; |
||||
|
} |
||||
|
private static JsonNode login(String phone) throws Exception { |
||||
|
assertThat(one("select count(*) from users where phone=?",phone)).isEqualTo(0L); |
||||
|
call("POST","/auth/sms/send?phone="+phone+"&purpose=login",null,null,200); |
||||
|
String code=(String) one("select code from sms_verification_codes where phone=? and purpose='login' order by id desc limit 1",phone); |
||||
|
return call("POST","/auth/login",null,JSON.writeValueAsString(Map.of("phone",phone,"code",code,"purpose","login")),200).path("data"); |
||||
|
} |
||||
|
private static Object one(String sql,Object...args) throws Exception { |
||||
|
try(var p=db.prepareStatement(sql)){for(int i=0;i<args.length;i++)p.setObject(i+1,args[i]); |
||||
|
try(var r=p.executeQuery()){assertThat(r.next()).isTrue();return r.getObject(1);}} |
||||
|
} |
||||
|
private static void update(String sql,Object...args) throws Exception { |
||||
|
try(var p=db.prepareStatement(sql)){for(int i=0;i<args.length;i++)p.setObject(i+1,args[i]);p.executeUpdate();} |
||||
|
} |
||||
|
private static Set<String> operations(JsonNode doc) { |
||||
|
Set<String> out=new TreeSet<>();doc.path("paths").fields().forEachRemaining(p -> |
||||
|
p.getValue().fieldNames().forEachRemaining(m -> out.add(m+" "+p.getKey())));return out; |
||||
|
} |
||||
|
private static Map<String,List<String>> snapshot() throws Exception { |
||||
|
Map<String,List<String>> result=new TreeMap<>(); |
||||
|
for(String table:List.of("users","user_roles","companies","auth_tokens","notifications","messages","audit_logs", |
||||
|
"jobs","applications","offers","interviews","company_verifications","sms_verification_codes")) { |
||||
|
List<String> rows=new ArrayList<>(); |
||||
|
try(var s=db.createStatement();var r=s.executeQuery("select * from "+table)) { |
||||
|
while(r.next()){List<String> columns=new ArrayList<>();for(int c=1;c<=r.getMetaData().getColumnCount();c++)columns.add(Objects.toString(r.getObject(c),"<null>"));rows.add(JSON.writeValueAsString(columns));} |
||||
|
} |
||||
|
Collections.sort(rows);result.put(table,rows); |
||||
|
}return result; |
||||
|
} |
||||
|
private static void concurrentSelect(JsonNode pending) throws Exception { |
||||
|
var executor=Executors.newFixedThreadPool(2);var ready=new CountDownLatch(2);var start=new CountDownLatch(1); |
||||
|
try { |
||||
|
List<Future<Integer>> requests=new ArrayList<>(); |
||||
|
for(int i=0;i<2;i++) requests.add(executor.submit(() -> {ready.countDown();assertThat(start.await(10,TimeUnit.SECONDS)).isTrue(); |
||||
|
return send("POST","/auth/identity/select",pending.path("accessToken").asText(),"{\"identity\":\"STUDENT\"}").statusCode();})); |
||||
|
assertThat(ready.await(10,TimeUnit.SECONDS)).isTrue();start.countDown(); |
||||
|
assertThat(List.of(requests.get(0).get(30,TimeUnit.SECONDS),requests.get(1).get(30,TimeUnit.SECONDS))).containsExactlyInAnyOrder(200,401);checks++; |
||||
|
} finally {start.countDown();executor.shutdownNow();} |
||||
|
} |
||||
|
private static void cleanup() throws Exception { |
||||
|
try(var p=db.prepareStatement("select id,phone from users where phone in (?,?,?)")) { |
||||
|
for(int i=1;i<=3;i++)p.setString(i,"1990000960"+i); |
||||
|
List<Long> ids=new ArrayList<>();try(var r=p.executeQuery()){while(r.next())ids.add(r.getLong(1));} |
||||
|
for(Long id:ids){update("delete from auth_tokens where user_id=?",id);update("delete from companies where owner_user_id=?",id);update("delete from user_roles where user_id=?",id);update("delete from users where id=?",id);} |
||||
|
for(int i=1;i<=3;i++)update("delete from sms_verification_codes where phone=?","1990000960"+i); |
||||
|
} |
||||
|
} |
||||
|
private UnifiedIdentityJarAcceptanceProbe() { } |
||||
|
} |
||||
@ -0,0 +1,213 @@ |
|||||
|
package com.mydreamjob.auth.identity; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.*; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
import org.springframework.http.MediaType; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.Set; |
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
||||
|
|
||||
|
@SpringBootTest(properties = {"app.company-registration.enabled=true", |
||||
|
"app.auth.password.enabled=true", "app.auth.password.rate-key-secret=identity-integration-test-rate-secret-only-0001", |
||||
|
"spring.datasource.url=jdbc:h2:mem:unified_identity;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"}) |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
@Transactional |
||||
|
class UnifiedRegistrationIntegrationTest { |
||||
|
@Autowired MockMvc mvc; |
||||
|
@Autowired ObjectMapper mapper; |
||||
|
@Autowired SmsVerificationCodeRepository codes; |
||||
|
@Autowired UserRepository users; |
||||
|
@Autowired JdbcTemplate jdbc; |
||||
|
@Autowired org.springframework.security.crypto.password.PasswordEncoder passwords; |
||||
|
@Autowired com.mydreamjob.auth.company.CompanyRegistrationProperties registration; |
||||
|
@MockBean RedisTemplate<String, Object> redis; |
||||
|
@org.springframework.boot.test.mock.mockito.SpyBean IdentitySelectionService identitySelection; |
||||
|
|
||||
|
private JsonNode login(String phone) throws Exception { |
||||
|
codes.saveAndFlush(SmsVerificationCode.builder().phone(phone).code("123456").purpose("login") |
||||
|
.attempts(0).maxAttempts(5).used(false).createdAt(LocalDateTime.now()) |
||||
|
.expiredAt(LocalDateTime.now().plusMinutes(5)).build()); |
||||
|
return mapper.readTree(mvc.perform(post("/auth/login").contentType(MediaType.APPLICATION_JSON) |
||||
|
.content("{\"phone\":\""+phone+"\",\"code\":\"123456\",\"purpose\":\"login\"}")) |
||||
|
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); |
||||
|
} |
||||
|
@Test void newAccountHasNoBusinessRole() throws Exception { |
||||
|
JsonNode data = login("19900009401"); |
||||
|
assertThat(data.path("role").asText()).isEqualTo("UNASSIGNED"); |
||||
|
assertThat(data.path("identitySelectionRequired").asBoolean()).isTrue(); |
||||
|
assertThat(jdbc.queryForList("select role from user_roles where user_id=?", data.path("userId").asLong())).isEmpty(); |
||||
|
} |
||||
|
|
||||
|
@Test void sessionRevokedAfterFilterValidationReturnsSafe401() throws Exception { |
||||
|
String token=login("19900009411").path("accessToken").asText(); |
||||
|
// 模拟通过过滤器后、取得用户锁前被并发请求撤销会话;真实并发另由 MySQL/JAR 覆盖。
|
||||
|
org.mockito.Mockito.doThrow(new InvalidTokenException()).when(identitySelection).select( |
||||
|
org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyString(), |
||||
|
org.mockito.ArgumentMatchers.any(IdentitySelectionRequest.class), |
||||
|
org.mockito.ArgumentMatchers.any(com.mydreamjob.audit.AuditContext.class)); |
||||
|
String body=mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+token) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) |
||||
|
.andExpect(status().isUnauthorized()).andExpect(jsonPath("$.code").value(401)) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
assertThat(body).doesNotContain("Exception","jdbc:","auth_tokens",token); |
||||
|
} |
||||
|
@Test void pendingIdentityCannotUseAuthenticatedOnlyBusinessEndpoints() throws Exception { |
||||
|
String token = login("19900009402").path("accessToken").asText(); |
||||
|
for (String path : new String[]{"/assessments/types","/abilities/types","/notifications", |
||||
|
"/companies/me/onboarding","/profile/me","/messages","/admin/unknown"}) { |
||||
|
mvc.perform(get(path).header("Authorization", "Bearer "+token)).andExpect(status().isForbidden()); |
||||
|
} |
||||
|
mvc.perform(get("/auth/me").header("Authorization", "Bearer "+token)) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.identitySelectionRequired").value(true)); |
||||
|
mvc.perform(get("/jobs").header("Authorization", "Bearer "+token)).andExpect(status().isOk()); |
||||
|
} |
||||
|
@Test void studentSelectionRotatesCredentials() throws Exception { |
||||
|
JsonNode before = login("19900009403"); |
||||
|
JsonNode selected = mapper.readTree(mvc.perform(post("/auth/identity/select") |
||||
|
.header("Authorization", "Bearer "+before.path("accessToken").asText()) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("STUDENT")) |
||||
|
.andReturn().getResponse().getContentAsString()).path("data"); |
||||
|
mvc.perform(get("/auth/me").header("Authorization", "Bearer "+before.path("accessToken").asText())) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
mvc.perform(get("/auth/me").header("Authorization", "Bearer "+selected.path("accessToken").asText())) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.identitySelectionRequired").value(false)); |
||||
|
mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON) |
||||
|
.content(mapper.writeValueAsString(java.util.Map.of("refreshToken", before.path("refreshToken").asText())))) |
||||
|
.andExpect(status().isUnauthorized()); |
||||
|
} |
||||
|
@Test void companySelectionCreatesUnverifiedCompany() throws Exception { |
||||
|
JsonNode before = login("19900009404"); |
||||
|
mvc.perform(post("/auth/identity/select").header("Authorization", "Bearer "+before.path("accessToken").asText()) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"身份选择测试企业\"}}")) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("COMPANY")) |
||||
|
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED")); |
||||
|
assertThat(jdbc.queryForList("select role from user_roles where user_id=?", String.class, before.path("userId").asLong())) |
||||
|
.containsExactly("COMPANY"); |
||||
|
assertThat(jdbc.queryForObject("select verified from companies where owner_user_id=?", Boolean.class, |
||||
|
before.path("userId").asLong())).isFalse(); |
||||
|
} |
||||
|
@Test void forbiddenClientFieldsAndAdminIdentityAreRejected() throws Exception { |
||||
|
String token = login("19900009405").path("accessToken").asText(); |
||||
|
for (String body : new String[]{"{\"identity\":\"ADMIN\"}","{\"identity\":\"STUDENT\",\"userId\":1}", |
||||
|
"{\"identity\":\"COMPANY\"}","{\"identity\":\"STUDENT\",\"company\":{\"name\":\"bad\"}}", |
||||
|
"{\"identity\":\"COMPANY\",\"company\":{\"name\":\"bad\",\"verified\":true}}"}) { |
||||
|
mvc.perform(post("/auth/identity/select").header("Authorization", "Bearer "+token) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest()); |
||||
|
} |
||||
|
} |
||||
|
@Test void existingStudentIsNotReset() throws Exception { |
||||
|
users.saveAndFlush(User.builder().phone("19900009406").role(User.Role.STUDENT) |
||||
|
.roles(Set.of(User.Role.STUDENT)).active(true).build()); |
||||
|
JsonNode data = login("19900009406"); |
||||
|
assertThat(data.path("role").asText()).isEqualTo("STUDENT"); |
||||
|
assertThat(data.has("identitySelectionRequired")).isTrue(); |
||||
|
assertThat(data.path("identitySelectionRequired").asBoolean()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest @ValueSource(strings={"COMPANY","ADMIN"}) |
||||
|
void otherExistingRolesAreNeverReset(String role) throws Exception { |
||||
|
String phone=role.equals("COMPANY")?"19900009411":"19900009412"; |
||||
|
User.Role existing=User.Role.valueOf(role); |
||||
|
users.saveAndFlush(User.builder().phone(phone).role(existing).roles(Set.of(existing)).active(true).build()); |
||||
|
JsonNode data=login(phone); |
||||
|
assertThat(data.path("role").asText()).isEqualTo(role); |
||||
|
assertThat(data.path("identitySelectionRequired").asBoolean()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test void passwordLoginRetainsPendingState() throws Exception { |
||||
|
org.mockito.Mockito.when(redis.execute(org.mockito.ArgumentMatchers.any(org.springframework.data.redis.core.script.RedisScript.class), |
||||
|
org.mockito.ArgumentMatchers.anyList(),org.mockito.ArgumentMatchers.any(Object[].class))).thenReturn(1L); |
||||
|
users.saveAndFlush(User.builder().phone("19900009407").role(User.Role.UNASSIGNED).roles(Set.of()) |
||||
|
.active(true).passwordHash(passwords.encode("IdentityTest!2026#A")).build()); |
||||
|
mvc.perform(post("/auth/password/login").contentType(MediaType.APPLICATION_JSON) |
||||
|
.content("{\"phone\":\"19900009407\",\"password\":\"IdentityTest!2026#A\"}")) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.role").value("UNASSIGNED")) |
||||
|
.andExpect(jsonPath("$.data.identitySelectionRequired").value(true)); |
||||
|
} |
||||
|
|
||||
|
@Test void companySwitchOffDoesNotPreventStudentSelection() throws Exception { |
||||
|
JsonNode before=login("19900009408"); |
||||
|
String previous=registration.getEnabled(); |
||||
|
try { |
||||
|
registration.setEnabled("false"); |
||||
|
mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+before.path("accessToken").asText()) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"测试企业\"}}")) |
||||
|
.andExpect(status().isServiceUnavailable()); |
||||
|
assertThat(users.findById(before.path("userId").asLong()).orElseThrow().getRole()).isEqualTo(User.Role.UNASSIGNED); |
||||
|
mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+before.path("accessToken").asText()) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) |
||||
|
.andExpect(status().isOk()); |
||||
|
} finally {registration.setEnabled(previous);} |
||||
|
} |
||||
|
|
||||
|
@Test void repeatSelectionIsReadOnlyAndChangingIdentityConflicts() throws Exception { |
||||
|
JsonNode before=login("19900009409"); |
||||
|
JsonNode result=mapper.readTree(mvc.perform(post("/auth/identity/select") |
||||
|
.header("Authorization","Bearer "+before.path("accessToken").asText()) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) |
||||
|
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString()).path("data"); |
||||
|
var tokens=jdbc.queryForList("select * from auth_tokens where user_id=? order by id",before.path("userId").asLong()); |
||||
|
var audits=jdbc.queryForList("select * from audit_logs order by id"); |
||||
|
mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+result.path("accessToken").asText()) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"STUDENT\"}")) |
||||
|
.andExpect(status().isOk()).andExpect(jsonPath("$.data.accessToken").doesNotExist()) |
||||
|
.andExpect(jsonPath("$.data.reauthenticationRequired").value(true)); |
||||
|
mvc.perform(post("/auth/identity/select").header("Authorization","Bearer "+result.path("accessToken").asText()) |
||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"identity\":\"COMPANY\",\"company\":{\"name\":\"测试企业\"}}")) |
||||
|
.andExpect(status().isConflict()); |
||||
|
assertThat(jdbc.queryForList("select * from auth_tokens where user_id=? order by id",before.path("userId").asLong())).isEqualTo(tokens); |
||||
|
assertThat(jdbc.queryForList("select * from audit_logs order by id")).isEqualTo(audits); |
||||
|
} |
||||
|
|
||||
|
@Test void runtimeInventoryHasNoPendingIdentityBusinessBypass() throws Exception { |
||||
|
JsonNode doc=mapper.readTree(mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()) |
||||
|
.andReturn().getResponse().getContentAsString()); |
||||
|
// 生成产物只用于本地结构对账,不提交运行日志或令牌。
|
||||
|
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-openapi-runtime.json"),mapper.writeValueAsString(doc)); |
||||
|
String token=login("19900009410").path("accessToken").asText(); |
||||
|
// 独立列出已批准的公开契约,不调用生产 gate.allowed 生成期望值。
|
||||
|
Set<String> publicGet=Set.of("/","/health","/health/details","/jobs","/jobs/search","/jobs/filters", |
||||
|
"/jobs/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}", |
||||
|
"/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs", |
||||
|
"/job-recommendation-taxonomies","/auth/me"); |
||||
|
Set<String> accountPost=Set.of("/auth/identity/select","/auth/logout","/auth/password/set", |
||||
|
"/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset", |
||||
|
"/auth/refresh","/auth/sms/send","/auth/company/register"); |
||||
|
int denied=0; |
||||
|
var paths=doc.path("paths").fields(); |
||||
|
while(paths.hasNext()) { |
||||
|
var path=paths.next(); |
||||
|
String uri=path.getKey().replaceAll("\\{[^}]+}","1"); |
||||
|
var methods=path.getValue().fieldNames(); |
||||
|
while(methods.hasNext()) { |
||||
|
String method=methods.next().toUpperCase(java.util.Locale.ROOT); |
||||
|
if((method.equals("GET") && publicGet.contains(path.getKey())) |
||||
|
|| (method.equals("POST") && accountPost.contains(path.getKey()))) continue; |
||||
|
mvc.perform(request(org.springframework.http.HttpMethod.valueOf(method),uri) |
||||
|
.header("Authorization","Bearer "+token).contentType(MediaType.APPLICATION_JSON).content("{}")) |
||||
|
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(10401)); |
||||
|
denied++; |
||||
|
} |
||||
|
} |
||||
|
assertThat(denied).isEqualTo(196); |
||||
|
java.nio.file.Files.writeString(java.nio.file.Path.of("target/unified-identity-gate-result.json"), |
||||
|
"{\"deniedOperations\":"+denied+",\"paths\":"+doc.path("paths").size()+"}"); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue