diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 288bd14..f57076e 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -189,6 +189,21 @@ } ], "paths": { + "/auth/identity/select": { + "post": { + "tags": ["认证"], + "summary": "首次选择账号身份", + "description": "统一账号一次性选择 STUDENT 或 COMPANY;COMPANY 必须提交 company 资料且注册开关开启。首次成功撤销全部旧会话并返回新凭证;同身份重复请求只返回状态,不重复颁发凭证。", + "operationId": "selectIdentity", + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/IdentitySelectionRequest"}}}, + "required": true + }, + "responses": { + "200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponseIdentitySelectionResponse"}}}} + } + } + }, "/": { "get": { "tags": [ @@ -1391,8 +1406,8 @@ "tags": [ "认证" ], - "summary": "Mock 登录", - "description": "使用手机号+验证码登录,返回 JWT token", + "summary": "手机号验证码登录", + "description": "手机号验证码登录;新手机号创建 UNASSIGNED 统一账号,identitySelectionRequired=true 时前端必须进入首次身份选择;旧角色保持不变。保留原字段及成功码 200。", "operationId": "mockLogin", "requestBody": { "content": { @@ -9484,6 +9499,38 @@ }, "components": { "schemas": { + "IdentitySelectionRequest": { + "type": "object", + "required": ["identity"], + "properties": { + "identity": {"type": "string", "enum": ["STUDENT", "COMPANY"]}, + "company": {"$ref": "#/components/schemas/CompanyDetails"} + } + }, + "IdentitySelectionResponse": { + "type": "object", + "properties": { + "userId": {"type": "integer", "format": "int64"}, + "role": {"type": "string"}, + "identitySelectionRequired": {"type": "boolean"}, + "companyId": {"type": "integer", "format": "int64"}, + "onboardingStatus": {"type": "string"}, + "accessToken": {"type": "string"}, + "refreshToken": {"type": "string"}, + "expiresIn": {"type": "integer", "format": "int64"}, + "reauthenticationRequired": {"type": "boolean"} + } + }, + "ApiResponseIdentitySelectionResponse": { + "type": "object", + "properties": { + "code": {"type": "integer", "format": "int32"}, + "message": {"type": "string"}, + "data": {"$ref": "#/components/schemas/IdentitySelectionResponse"}, + "requestId": {"type": "string"}, + "timestamp": {"type": "string", "format": "date-time"} + } + }, "TaxonomyResponse": { "type": "object", "properties": { diff --git a/docs/unified-registration-identity-selection-design.md b/docs/unified-registration-identity-selection-design.md new file mode 100644 index 0000000..6ba1be4 --- /dev/null +++ b/docs/unified-registration-identity-selection-design.md @@ -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,不冒充短信送达。 diff --git a/docs/unified-registration-identity-selection-implementation-report.md b/docs/unified-registration-identity-selection-implementation-report.md new file mode 100644 index 0000000..7206d23 --- /dev/null +++ b/docs/unified-registration-identity-selection-implementation-report.md @@ -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;本轮不暂存、不提交、不推送、不部署。上线开关和已有生产账号由后续独立验收与发布检查点另行处理。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index af2a7db..1917bbf 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 182, f"Expected 182 paths, got {len(d['paths'])}" +assert len(d['paths']) == 183, f"Expected 183 paths, got {len(d['paths'])}" paths = d['paths'] @@ -62,6 +62,7 @@ assert used_tags <= declared_tags, f'Undeclared operation tags: {sorted(used_tag required = [ # Phase A 企业注册与入驻聚合(2 个 path / 2 个 operation) '/auth/company/register', + '/auth/identity/select', '/companies/me/onboarding', '/review-weight-templates', '/profile/me/review-weights', @@ -647,7 +648,7 @@ assert _auth_paths == { '/auth/sms/send', '/auth/login', '/auth/me', '/auth/refresh', '/auth/logout', '/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset', - '/auth/company/register', + '/auth/company/register', '/auth/identity/select', }, f'Unexpected /auth paths: {sorted(_auth_paths)}' print(' S02 refresh/logout and password authentication operations validated') @@ -705,7 +706,14 @@ print(' S03 company resume download contract validated; candidate DTOs remain r # 统计 operation 总数并校验 V43 + N07 + S02 + S03 + Phase A 计数 220 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 220, f"Expected 220 operations, got {operations}" +assert operations == 221, f"Expected 221 operations, got {operations}" + +# 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。 +_select = paths['/auth/identity/select']['post'] +assert _select['requestBody']['content']['application/json']['schema']['$ref'] == '#/components/schemas/IdentitySelectionRequest' +assert set(d['components']['schemas']['IdentitySelectionRequest']['properties']['identity']['enum']) == {'STUDENT', 'COMPANY'} +assert 'identitySelectionRequired' in d['components']['schemas']['IdentitySelectionResponse']['properties'] +assert 'reauthenticationRequired' in d['components']['schemas']['IdentitySelectionResponse']['properties'] print(" Offer advance endpoints all present") print(" 26 operations all present") print(" Account deletion endpoints present (3 operations)") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java index 75ca01d..f7e62b0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java @@ -62,7 +62,8 @@ public class AuthService { user = User.builder() .phone(phone) .nickname("用户_" + phone.substring(7)) - .role(User.Role.STUDENT) + .role(User.Role.UNASSIGNED) + .roles(Set.of()) .active(true) .verified(true) .build(); @@ -97,6 +98,7 @@ public class AuthService { result.put("phone", user.getPhone()); result.put("nickname", user.getNickname()); result.put("role", user.getRole()); + result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED); result.put("accessToken", tokens.accessToken()); result.put("refreshToken", tokens.refreshToken()); result.put("expiresIn", EXPIRES_IN_SECONDS); @@ -137,6 +139,7 @@ public class AuthService { result.put("nickname", user.getNickname()); result.put("avatarUrl", user.getAvatarUrl()); result.put("role", user.getRole()); + result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED); result.put("active", user.getActive()); result.put("verified", user.getVerified()); result.put("createdAt", user.getCreatedAt()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java index 023e1d8..be4a8d7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java @@ -75,6 +75,7 @@ public class User { private LocalDateTime updatedAt; public enum Role { + UNASSIGNED, STUDENT, COMPANY, ADMIN diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java index 2e79705..3b62335 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationRequest.java @@ -27,6 +27,10 @@ public class CompanyRegistrationRequest { @Data public static class CompanyDetails { + @com.fasterxml.jackson.annotation.JsonAnySetter + public void rejectUnknown(String name, Object value) { + throw new IllegalArgumentException("company details contain unsupported fields"); + } @NotBlank @Size(max = 100) private String name; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java new file mode 100644 index 0000000..c2df10c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java @@ -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 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 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)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionRequest.java new file mode 100644 index 0000000..7db57fa --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionRequest.java @@ -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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionResponse.java new file mode 100644 index 0000000..3af0072 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionResponse.java @@ -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) { } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java new file mode 100644 index 0000000..4fec286 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentitySelectionService.java @@ -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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java index 96dd6cb..1543cf9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java @@ -59,6 +59,7 @@ public class PasswordAuthenticationService { result.put("phone", user.getPhone()); result.put("nickname", user.getNickname()); result.put("role", user.getRole()); + result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED); result.put("accessToken", tokens.accessToken()); result.put("refreshToken", tokens.refreshToken()); result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 9c6482f..a6ee177 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -240,7 +240,11 @@ public enum ErrorCode { COMPANY_REGISTRATION_DISABLED(10301, "company registration is disabled"), COMPANY_REGISTRATION_CONFLICT(10302, "company registration conflict"), COMPANY_ONBOARDING_INCONSISTENT_STATE(10303, "company onboarding state is inconsistent"), - COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"); + COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE(10304, "company registration dependency unavailable"), + + IDENTITY_SELECTION_REQUIRED(10401, "identity selection is required"), + IDENTITY_SELECTION_CONFLICT(10402, "identity selection conflict"), + IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE(10403, "identity selection temporarily unavailable"); private final int code; private final String message; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java index a2323aa..4443e6c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java @@ -112,6 +112,7 @@ public final class ErrorHttpStatusRegistry { .put(HttpStatus.FORBIDDEN, ErrorCode.APPLICATION_OWNER_MISMATCH, ErrorCode.COMPANY_NOT_VERIFIED, + ErrorCode.IDENTITY_SELECTION_REQUIRED, ErrorCode.FORBIDDEN, ErrorCode.INTERVIEW_ACCESS_DENIED, ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION, @@ -146,6 +147,7 @@ public final class ErrorHttpStatusRegistry { ErrorCode.VERIFICATION_NOT_FOUND) .put(HttpStatus.CONFLICT, ErrorCode.PASSWORD_STATE_CONFLICT, + ErrorCode.IDENTITY_SELECTION_CONFLICT, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, ErrorCode.ABILITY_CONCURRENT_UPDATE, ErrorCode.ABILITY_DUPLICATE_ACTIVE, @@ -227,6 +229,7 @@ public final class ErrorHttpStatusRegistry { ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE) .put(HttpStatus.SERVICE_UNAVAILABLE, ErrorCode.COMPANY_REGISTRATION_DISABLED, + ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE, ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE) .put(HttpStatus.TOO_MANY_REQUESTS, ErrorCode.PASSWORD_RATE_LIMITED, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index b0f669c..a508bf5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -1,6 +1,8 @@ package com.mydreamjob.config; import com.mydreamjob.auth.JwtAuthenticationFilter; +import com.mydreamjob.auth.identity.IdentityAccessFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; import com.mydreamjob.audit.AuditService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpMethod; @@ -36,6 +38,14 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final AuditService auditService; + private final IdentityAccessFilter identityAccessFilter; + + @Bean + public FilterRegistrationBean identityFilterRegistration() { + var registration = new FilterRegistrationBean<>(identityAccessFilter); + registration.setEnabled(false); // 只在 JWT 验证后的安全链执行。 + return registration; + } @Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:3000}") private String allowedOrigins; @@ -154,7 +164,8 @@ public class SecurityConfig { .requestMatchers("/").permitAll() .anyRequest().authenticated() ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(identityAccessFilter, JwtAuthenticationFilter.class); return http.build(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java index d7c66ca..d571711 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java @@ -16,6 +16,9 @@ import com.mydreamjob.auth.password.PasswordAuthenticationService; import com.mydreamjob.auth.company.CompanyRegistrationRequest; import com.mydreamjob.auth.company.CompanyRegistrationResponse; import com.mydreamjob.auth.company.CompanyRegistrationService; +import com.mydreamjob.auth.identity.IdentitySelectionService; +import com.mydreamjob.auth.identity.IdentitySelectionRequest; +import com.mydreamjob.auth.identity.IdentitySelectionResponse; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; @@ -55,6 +58,21 @@ public class AuthController { private final CurrentUserAccessor currentUserAccessor; private final PasswordAuthenticationService passwordAuthenticationService; private final CompanyRegistrationService companyRegistrationService; + private final IdentitySelectionService identitySelectionService; + + @Operation(summary = "首次选择账号身份", description = "统一账号一次性选择求职者或企业,成功后替换旧登录凭证") + @PostMapping("/identity/select") + public ResponseEntity> selectIdentity( + @Valid @RequestBody IdentitySelectionRequest body, HttpServletRequest request) { + try { + var response = identitySelectionService.select(currentUserAccessor.requireUserId(), + currentUserAccessor.requireSessionId(), body, AuditContext.from(request)); + return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request))); + } catch (InvalidTokenException e) { + // 过滤器校验后仍可能被并发选择/登出撤销,沿用认证接口的统一安全响应。 + return unauthorized(request); + } + } @Operation(summary = "企业注册", description = "使用 company-register 短信验证码创建企业账号和企业资料") @PostMapping("/company/register") @@ -88,7 +106,7 @@ public class AuthController { } } - @Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 Access/Refresh Token 并创建服务端登录会话") + @Operation(summary = "手机号验证码登录", description = "新手机号创建待选身份统一账号;已有账号保持身份,返回 Access/Refresh Token") @PostMapping("/login") public ResponseEntity>> mockLogin( @Valid @RequestBody LoginRequest body, @@ -240,7 +258,7 @@ public class AuthController { return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), requestIdAccessor.get(request))); } - private ResponseEntity> unauthorized(HttpServletRequest request) { + private ResponseEntity> unauthorized(HttpServletRequest request) { return ResponseEntity.status(401).body( ApiResponse.unauthorized("invalid or expired token", requestIdAccessor.get(request))); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java b/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java index c2a0c45..4574bab 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/security/UserRoleGuard.java @@ -122,6 +122,10 @@ public class UserRoleGuard { if (!isActive(user)) { throw new BusinessException(ErrorCode.FORBIDDEN); } + // 未选择身份不能通过矛盾的 user_roles 数据获得业务权限。 + if (user.getRole() == User.Role.UNASSIGNED) { + throw new BusinessException(ErrorCode.IDENTITY_SELECTION_REQUIRED); + } if (allowedRoles == null || allowedRoles.length == 0) { throw new BusinessException(ErrorCode.FORBIDDEN); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index 8873a91..792524d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -453,8 +453,8 @@ class CompanyResumeDownloadIntegrationTest { } } } - assertThat(pathCount).as("密码登录与企业注册/入驻新增路径后应为 182 paths").isEqualTo(182); - assertThat(opCount).as("密码登录与企业注册/入驻新增 operation 后应为 220 operations").isEqualTo(220); + assertThat(pathCount).as("统一账号身份选择新增一个路径后应为 183 paths").isEqualTo(183); + assertThat(opCount).as("统一账号身份选择新增一个 operation 后应为 221 operations").isEqualTo(221); var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java index 53db2d4..851dfd7 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java @@ -104,7 +104,8 @@ class AuthServiceTest { Map result = authService.mockLogin("13800000099", "123456", "login", null, null, null); assertEquals(99L, result.get("userId")); - assertEquals(User.Role.STUDENT, result.get("role")); + assertEquals(User.Role.UNASSIGNED, result.get("role")); + assertEquals(true, result.get("identitySelectionRequired")); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java new file mode 100644 index 0000000..51d891e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentityPolicyTest.java @@ -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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentitySelectionMySqlTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentitySelectionMySqlTest.java new file mode 100644 index 0000000..265d153 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/IdentitySelectionMySqlTest.java @@ -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 redis; + private final List 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>> 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>> snapshot(Long id) { + Map>> 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 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 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 parallel(Callable first, Callable second) throws Exception { + ExecutorService pool=Executors.newFixedThreadPool(2); + CountDownLatch ready=new CountDownLatch(2), start=new CountDownLatch(1); + try { + List> futures = new ArrayList<>(); + for (Callable 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();} + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityIndependentBoundaryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityIndependentBoundaryTest.java new file mode 100644 index 0000000..a0a355c --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityIndependentBoundaryTest.java @@ -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); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java new file mode 100644 index 0000000..9184c7e --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java @@ -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() 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 operations(JsonNode doc) { + Set out=new TreeSet<>();doc.path("paths").fields().forEachRemaining(p -> + p.getValue().fieldNames().forEachRemaining(m -> out.add(m+" "+p.getKey())));return out; + } + private static Map> snapshot() throws Exception { + Map> 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 rows=new ArrayList<>(); + try(var s=db.createStatement();var r=s.executeQuery("select * from "+table)) { + while(r.next()){List columns=new ArrayList<>();for(int c=1;c<=r.getMetaData().getColumnCount();c++)columns.add(Objects.toString(r.getObject(c),""));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> 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 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() { } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java new file mode 100644 index 0000000..e53cd22 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java @@ -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 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 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 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()+"}"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java index edfc78f..e27dac2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java @@ -19,7 +19,7 @@ class GlobalExceptionHandlerStatusContractTest { private static final Set FORBIDDEN = Set.of( "FORBIDDEN", "INTERVIEW_ACCESS_DENIED", "APPLICATION_OWNER_MISMATCH", - "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER", "COMPANY_NOT_VERIFIED"); + "MATCH_REQUIRED_FOR_CONVERSATION", "OFFER_COMPARE_NOT_OWNER", "COMPANY_NOT_VERIFIED", "IDENTITY_SELECTION_REQUIRED"); private static final Set NOT_FOUND = Set.of( "COMPANY_NOT_FOUND", "PROFILE_NOT_FOUND", "JOB_NOT_FOUND", "APPLICATION_NOT_FOUND", @@ -65,7 +65,7 @@ class GlobalExceptionHandlerStatusContractTest { "ABILITY_ATTEMPT_INVALID_STATE", "ABILITY_CONCURRENT_UPDATE", "ABILITY_DUPLICATE_ACTIVE", "ABILITY_IDEMPOTENCY_CONFLICT", "ABILITY_RETAKE_COOLDOWN", "RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION", - "COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE"); + "COMPANY_REGISTRATION_CONFLICT", "COMPANY_ONBOARDING_INCONSISTENT_STATE", "IDENTITY_SELECTION_CONFLICT"); private static final Set TOO_MANY_REQUESTS = Set.of( "REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED", @@ -73,7 +73,7 @@ class GlobalExceptionHandlerStatusContractTest { private static final Set UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); private static final Set SERVICE_UNAVAILABLE = Set.of( - "COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE"); + "COMPANY_REGISTRATION_DISABLED", "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", "IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE"); private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java index 2cd2341..7c0ef2c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java @@ -34,10 +34,10 @@ class ErrorHttpStatusRegistryTest { @Test @DisplayName("Phase B:显式映射包含企业未认证拒绝状态") void explicitMappingCountMatchesBaseline() { - assertThat(registry.explicitMappingCount()).isEqualTo(120); - // 120 个常量只覆盖 119 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 + assertThat(registry.explicitMappingCount()).isEqualTo(123); + // 123 个常量只覆盖 122 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(119); + assertThat(registry.explicitNumericCodeCount()).isEqualTo(122); assertThat(registry.resolve(ErrorCode.COMPANY_NOT_VERIFIED)).isEqualTo(HttpStatus.FORBIDDEN); } @@ -123,12 +123,12 @@ class ErrorHttpStatusRegistryTest { .isInstanceOf(UnsupportedOperationException.class); assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(view).hasSize(120); + assertThat(view).hasSize(123); Map numeric = ErrorHttpStatusRegistry.numericMappingsView(); assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(numeric).hasSize(119); + assertThat(numeric).hasSize(122); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java index 329b18c..24b9cf9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java @@ -98,6 +98,10 @@ class GlobalExceptionHandlerMappingParityTest { private static HttpStatus baselineStatus(ErrorCode errorCode) { String name = errorCode.name(); + // 新功能追加映射与历史快照分离,不改历史错误码的期望值。 + if (name.equals("IDENTITY_SELECTION_REQUIRED")) return HttpStatus.FORBIDDEN; + if (name.equals("IDENTITY_SELECTION_CONFLICT")) return HttpStatus.CONFLICT; + if (name.equals("IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE")) return HttpStatus.SERVICE_UNAVAILABLE; if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN; if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; @@ -114,8 +118,8 @@ class GlobalExceptionHandlerMappingParityTest { @Test @DisplayName("B2:显式映射集合与重构前快照逐项相等") void explicitMappingSetEqualsPreRefactorSnapshot() { - assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(120); - assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(119); + assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(123); + assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(122); for (ErrorCode errorCode : ErrorCode.values()) { HttpStatus expected = baselineStatus(errorCode); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java index 52f63fd..f48bc64 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java @@ -42,6 +42,10 @@ class R01BIndependentHttpMappingParityTest { "COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE); private static final Map PHASE_B_ADDITIONS = Map.of("COMPANY_NOT_VERIFIED", HttpStatus.FORBIDDEN); + private static final Map IDENTITY_ADDITIONS = Map.of( + "IDENTITY_SELECTION_REQUIRED", HttpStatus.FORBIDDEN, + "IDENTITY_SELECTION_CONFLICT", HttpStatus.CONFLICT, + "IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE); private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final GlobalExceptionHandler handler = @@ -88,7 +92,7 @@ class R01BIndependentHttpMappingParityTest { // HEAD 基线有 169 条;后续阶段新增码单独列出,不修改任何历史基线行。 assertThat(base).hasSize(169); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); - assertThat(ErrorCode.values()).hasSize(174); + assertThat(ErrorCode.values()).hasSize(177); int checked = 0; for (ErrorCode code : ErrorCode.values()) { @@ -96,6 +100,7 @@ class R01BIndependentHttpMappingParityTest { if (expected == null) { HttpStatus addedStatus = PHASE_A_ADDITIONS.get(code.name()); if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(code.name()); + if (addedStatus == null) addedStatus = IDENTITY_ADDITIONS.get(code.name()); assertThat(addedStatus).as("新增错误码必须逐项明确登记: %s", code.name()).isNotNull(); assertThat(registry.resolve(code)).isEqualTo(addedStatus); checked++; @@ -108,7 +113,7 @@ class R01BIndependentHttpMappingParityTest { .isEqualTo(expected[1]); checked++; } - assertThat(checked).isEqualTo(174); + assertThat(checked).isEqualTo(177); } @Test @@ -120,10 +125,10 @@ class R01BIndependentHttpMappingParityTest { assertThat(explicitInBaseline).isEqualTo(115); assertThat(base.size() - explicitInBaseline).isEqualTo(54); - assertThat(registry.explicitMappingCount()).isEqualTo(120); + assertThat(registry.explicitMappingCount()).isEqualTo(123); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); - // 120 个显式常量覆盖 119 个唯一数字码(9103 共用)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(119); + // 123 个显式常量覆盖 122 个唯一数字码(9103 共用)。 + assertThat(registry.explicitNumericCodeCount()).isEqualTo(122); } @Test @@ -235,9 +240,10 @@ class R01BIndependentHttpMappingParityTest { // 真实注册表对每个枚举常量的解析与其声明位置无关:逐个比对基线即可证明 Map base = baseline(); for (ErrorCode c : ErrorCode.values()) { - if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(c.name())) { + if (PHASE_A_ADDITIONS.containsKey(c.name()) || PHASE_B_ADDITIONS.containsKey(c.name()) || IDENTITY_ADDITIONS.containsKey(c.name())) { HttpStatus addedStatus = PHASE_A_ADDITIONS.get(c.name()); if (addedStatus == null) addedStatus = PHASE_B_ADDITIONS.get(c.name()); + if (addedStatus == null) addedStatus = IDENTITY_ADDITIONS.get(c.name()); assertThat(registry.resolve(c)).isEqualTo(addedStatus); } else { assertThat(registry.resolve(c).value()).isEqualTo(base.get(c.name())[1]); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java index 0806ae4..43084e2 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java @@ -20,7 +20,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @AutoConfigureMockMvc(addFilters = false) @MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, AuthSessionService.class, java.time.Clock.class, - RequestIdAccessor.class, CurrentUserAccessor.class}) + RequestIdAccessor.class, CurrentUserAccessor.class, + com.mydreamjob.auth.identity.IdentityAccessFilter.class}) class CompanyControllerMvcTest { @Autowired diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java index 878f5d7..960843d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyOnboardingControllerTest.java @@ -22,7 +22,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @WebMvcTest(CompanyController.class) @AutoConfigureMockMvc(addFilters = false) @MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, AuthSessionService.class, - java.time.Clock.class, RequestIdAccessor.class, CurrentUserAccessor.class}) + java.time.Clock.class, RequestIdAccessor.class, CurrentUserAccessor.class, + com.mydreamjob.auth.identity.IdentityAccessFilter.class}) class CompanyOnboardingControllerTest { @Autowired MockMvc mockMvc; @MockBean CompanyService companyService;