diff --git a/docs/api-v0.1.md b/docs/api-v0.1.md index 8892115..4f9abbd 100644 --- a/docs/api-v0.1.md +++ b/docs/api-v0.1.md @@ -169,12 +169,65 @@ http://localhost:8080/swagger-ui.html http://localhost:8080/v3/api-docs ``` -## 已知限制 (Week 1) +### 4. 刷新 Token(S02) + +``` +POST /api/v1/auth/refresh +Content-Type: application/json + +{ "refreshToken": "" } +``` + +- 匿名可调用;是否成功取决于 Refresh Token 校验结果。 +- **只接受 Refresh Token**;把 Access Token 传进来一律 401。 +- 请求体只允许 `refreshToken` 一个字段;Token 不接受 query/path 参数。 +- 成功后返回新的 Token 对,**旧 Refresh Token 立即失效**(强制轮换)。 +- 已消费/已撤销的 Refresh Token 再次提交 → 401,并撤销该登录会话(重放处置)。 +- 失败响应不说明 Token 是"不存在""已消费"还是"已撤销"。 + +**成功响应:** +```json +{ + "code": 200, + "data": { + "accessToken": "eyJhbGciOi...", + "refreshToken": "eyJhbGciOi...", + "expiresIn": 86400 + }, + "requestId": "" +} +``` + +### 5. 登出(S02) + +``` +POST /api/v1/auth/logout +Authorization: Bearer +``` + +- 必须已认证;**无请求体**。 +- 身份与会话来自 Access Token,不接受客户端传入 `userId`/`sessionId`/`refreshToken`。 +- 只撤销**当前登录会话**,其他设备会话不受影响。 +- 重复登出按幂等处理;成功响应不包含任何 Token。 + +## Token 与会话模型(S02) + +| Token | 用途 | 说明 | +|---|---|---| +| Access Token | 普通业务接口的 `Authorization` 头 | 带 `type=access`,有效期 24h;每次请求都会校验服务端会话状态 | +| Refresh Token | 仅用于 `POST /auth/refresh` | 带 `type=refresh`,有效期 7 天;不落库,数据库只保存其 HMAC-SHA256 摘要 | + +- Access 与 Refresh **使用不同签名密钥**,交叉验签必然失败。 +- 每次登录创建独立的**登录会话**(`sessionId`);同一用户可同时存在多个会话。 +- 登出只撤销当前会话;账号注销完成、用户禁用会撤销该用户**全部会话**。 +- 本版**不再兼容**无 `type` 的旧 Token(所有旧 Token 失效,用户需重新登录一次)。 +- Token 原文不进入数据库、日志、审计、URL 或错误响应。 + +## 已知限制 1. 短信验证码为 Mock 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用” 2. 无企业注册/认证流程 -3. 无岗位、投递、聊天、评分等业务功能 -4. 无 Token 刷新接口(Week 2+) -5. 无密码修改/重置功能 -6. RabbitMQ 未实现消息监听器(仅队列定义) -7. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN +3. 无密码修改/重置功能 +4. 未提供查看全部登录设备、远程踢出单个设备、Token introspection 等接口 +5. RabbitMQ 未实现消息监听器(仅队列定义) +6. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index ddcb7be..daadd23 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -11,6 +11,8 @@ | V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | | 评分规则解释与合理性 | N02 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | 保持分数、权重、排名和企业评分契约不变 | | 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 | +| S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 | +| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index c25ae04..16f4bd0 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -59,9 +59,9 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev 4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型 5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}` -## 5. Token 使用方式 +## 5. Token 使用方式(S02) -登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`。 +登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`(有效期 7 天)。 ```json { @@ -81,6 +81,36 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev Authorization: Bearer ``` +### 5.1 两个 Token 的用途必须分开 + +| Token | 用在哪 | +|---|---| +| `accessToken` | 所有普通业务接口的 `Authorization` 头 | +| `refreshToken` | **只能**用于 `POST /api/v1/auth/refresh` | + +把 `refreshToken` 放进普通接口的 `Authorization` 头会被拒绝(401); +把 `accessToken` 传给刷新接口同样会被拒绝(401)。两者由不同密钥签发,不可互换。 + +### 5.2 刷新与登出 + +``` +POST /api/v1/auth/refresh body: { "refreshToken": "" } +POST /api/v1/auth/logout header: Authorization: Bearer (无 body) +``` + +### 5.3 前端必须遵守的规则 + +1. **上线即强制重新登录**:S02 之后所有旧 Token(不含类型与会话信息)全部失效, + 前端需要接受一次强制登录,不要尝试兼容旧 Token。 +2. 普通请求**只携带 Access Token**。 +3. 收到普通接口 401 时,**最多刷新一次**;刷新成功后原子替换两个 Token 并重放原请求。 +4. **刷新请求必须串行**:多个并发 401 不能同时用同一个 Refresh Token 去刷新 + (并发刷新会被服务端判定为重放并撤销整个会话)。 +5. **刷新失败一律清除本地两个 Token 并跳转登录页**,不要循环刷新。 +6. **登出时先调用 `/auth/logout`,随后无条件清除本地 Token**; + 不要依赖第二次登出请求(Access Token 失效后它本来就会 401)。 +7. **不要记录或上报 Token**:不写入 URL、本地日志、埋点或错误上报正文。 + ## 3. 请求头 | Header | 必需 | 说明 | diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 5dcdd2f..a4c18f0 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -9213,6 +9213,60 @@ } } } + }, + "/auth/refresh": { + "post": { + "tags": [ + "认证" + ], + "summary": "刷新 Token", + "description": "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效", + "operationId": "refresh", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RefreshTokenRequest" + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseTokenPairResponse" + } + } + } + } + } + } + }, + "/auth/logout": { + "post": { + "tags": [ + "认证" + ], + "summary": "登出", + "description": "撤销当前登录会话;需要 Access Token,不接受任何请求体", + "operationId": "logout", + "responses": { + "200": { + "description": "成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringString" + } + } + } + } + } + } } }, "components": { @@ -11881,19 +11935,30 @@ }, "limitations": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "evidenceLevel": { "type": "string", - "enum": ["DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY"] + "enum": [ + "DIRECT_RULE", + "SELF_REPORTED", + "BEHAVIOR_PROXY", + "UNVALIDATED_PROXY" + ] }, "ruleContributions": { "type": "array", - "items": { "$ref": "#/components/schemas/CandidateScoringRuleContribution" } + "items": { + "$ref": "#/components/schemas/CandidateScoringRuleContribution" + } }, "improvementActions": { "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } }, "explanationVersion": { "type": "string" @@ -15301,41 +15366,91 @@ "CandidateScoringCoverage": { "type": "object", "properties": { - "computableDimensions": { "type": "integer", "format": "int32" }, - "totalDimensions": { "type": "integer", "format": "int32" }, - "includedDimensions": { "type": "array", "items": { "type": "string" } }, + "computableDimensions": { + "type": "integer", + "format": "int32" + }, + "totalDimensions": { + "type": "integer", + "format": "int32" + }, + "includedDimensions": { + "type": "array", + "items": { + "type": "string" + } + }, "excludedDimensions": { "type": "array", - "items": { "$ref": "#/components/schemas/CandidateScoringExcludedDimension" } + "items": { + "$ref": "#/components/schemas/CandidateScoringExcludedDimension" + } } } }, "CandidateScoringExcludedDimension": { "type": "object", "properties": { - "dimension": { "type": "string" }, - "status": { "type": "string" }, - "reason": { "type": "string" } + "dimension": { + "type": "string" + }, + "status": { + "type": "string" + }, + "reason": { + "type": "string" + } } }, "CandidateScoringRuleContribution": { "type": "object", "properties": { - "ruleCode": { "type": "string" }, - "outcome": { "type": "string", "enum": ["APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"] }, - "contribution": { "type": "number", "nullable": true }, - "maxContribution": { "type": "number", "nullable": true }, - "label": { "type": "string" }, - "observation": { "type": "string" }, - "explanation": { "type": "string" } + "ruleCode": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "APPLIED", + "NOT_APPLIED", + "MISSING", + "EXPIRED", + "UNAVAILABLE" + ] + }, + "contribution": { + "type": "number", + "nullable": true + }, + "maxContribution": { + "type": "number", + "nullable": true + }, + "label": { + "type": "string" + }, + "observation": { + "type": "string" + }, + "explanation": { + "type": "string" + } } }, "CandidateScoringValidity": { "type": "object", "properties": { - "completedAt": { "type": "string", "format": "date-time" }, - "validUntil": { "type": "string", "format": "date-time" }, - "expired": { "type": "boolean" } + "completedAt": { + "type": "string", + "format": "date-time" + }, + "validUntil": { + "type": "string", + "format": "date-time" + }, + "expired": { + "type": "boolean" + } } }, "StudentProfileResponse": { @@ -15632,6 +15747,48 @@ "format": "date-time" } } + }, + "RefreshTokenRequest": { + "type": "object", + "required": [ + "refreshToken" + ], + "properties": { + "refreshToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } + }, + "TokenPairResponse": { + "type": "object", + "properties": { + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "format": "int64" + } + } + }, + "ApiResponseTokenPairResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TokenPairResponse" + } + } } }, "securitySchemes": { diff --git a/docs/phase1-scope-and-acceptance.md b/docs/phase1-scope-and-acceptance.md index 14d479c..fe07a4c 100644 --- a/docs/phase1-scope-and-acceptance.md +++ b/docs/phase1-scope-and-acceptance.md @@ -29,6 +29,8 @@ | C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 | | C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 | | C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 | +| S01 认证安全整改 | 验证码不落日志、受控取码、`/jobs` 认证边界 | auth/、docs/smoke-test.sh、job/JobController.java | 双开关受控取码;mock=false 完整回滚;`/jobs` 权限矩阵(独立代码复验与真实 MySQL/Docker 黑盒已通过) | 张顺 / 独立复验 | +| S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 | ## 3. 一期必须补齐的八个功能组 diff --git a/docs/s02-auth-token-isolation-design.md b/docs/s02-auth-token-isolation-design.md new file mode 100644 index 0000000..4e8f052 --- /dev/null +++ b/docs/s02-auth-token-isolation-design.md @@ -0,0 +1,674 @@ +# S02 Access/Refresh Token 隔离、轮换与吊销设计 + +状态:S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。 + +文档类型:解释与技术参考。 + +目标读者:产品负责人、后端开发、前端联调、安全复验和真实环境验收人员。 + +解释版本:`s02-auth-token-isolation-v1` + +## 1. 目标 + +S02 修复当前 Refresh Token 可以作为 Access Token 使用的认证边界缺陷,并建立可轮换、可吊销、可检测重放的登录会话模型。 + +本设计必须保证: + +- 普通业务接口只接受 Access Token; +- 刷新接口只接受 Refresh Token; +- Token 原文不进入数据库、日志、审计、URL 或错误响应; +- 登录、刷新、登出、账号注销和用户禁用具有明确的吊销语义; +- 同一个 Refresh Token 在并发或重放场景中最多成功刷新一次; +- 旧的无 `type` Token 不被永久兼容; +- 不改变手机号验证码登录、角色、资源归属、评分、排名、推荐、邀约、申诉、消息撤回和简历上传语义。 + +## 2. 当前实现事实 + +截至基线提交 `96a920b`: + +1. `JwtTokenProvider.generateAccessToken` 没有强制写入 `type=access`。 +2. `generateRefreshToken` 写入了 `type=refresh`。 +3. `JwtAuthenticationFilter` 只验证 JWT 签名和过期时间,不验证 Token 类型。 +4. Access Token 与 Refresh Token 使用同一 `app.jwt.secret`。 +5. 因此签名有效且未过期的 Refresh Token 可以通过过滤器并建立普通业务登录态。 +6. 登录响应已经包含 `accessToken`、`refreshToken` 和 `expiresIn`。 +7. 当前没有 `/auth/refresh` 和 `/auth/logout`。 +8. `auth_tokens` 表、`AuthToken` 实体和 `AuthTokenRepository` 已存在,但未被登录、刷新、鉴权、登出或账号注销使用。 +9. 当前 JWT 没有 `sid` 和 `jti`,不能标识登录会话和单个 Token。 +10. 当前账号注销完成不会使已经签发的 JWT 立即失效。 +11. 当前 JWT 包含完整手机号;普通鉴权实际只需要用户 ID 和会话信息。 +12. `application.yml` 的 `refresh-expiration-ms` 当前错误地位于 `app.resume` 下,而 `application-dev.yml` 位于正确的 `app.jwt` 下。非 dev 环境可能依赖 Java 默认值,配置契约不一致。 +13. 现有 Access Token 默认有效期为 24 小时,Refresh Token 默认有效期为 7 天。 +14. 现有 `auth_tokens` 表没有会话标识、轮换父子关系、消费时间、撤销时间和乐观锁版本。 + +## 3. 威胁模型 + +本版重点防护以下风险: + +- Refresh Token 被复制到 `Authorization: Bearer` 后直接调用普通业务接口; +- 已登出或已注销用户继续使用旧 Access Token; +- Refresh Token 在日志、数据库、错误响应或 URL 中泄露; +- 同一个 Refresh Token 被并发刷新两次,产生两组有效 Token; +- 已消费 Refresh Token 被攻击者再次使用而系统无法识别; +- Token 类型缺失、大小写变化或未知值被宽松接受; +- 非法 subject、`sid` 或 `jti` 引发 500 或回显内部数据; +- 数据库或会话校验异常时过滤器错误放行; +- 多设备登录时,一个设备的普通登出误伤其他设备; +- 账号注销、用户禁用等全局安全事件没有撤销全部会话。 + +不在本版解决:终端设备指纹、地理异常检测、硬件绑定、OAuth/OIDC、短信供应商、验证码摘要化、密码登录和单点登录。 + +## 4. 设计决策 + +### D01:Token 类型强制隔离 + +- Access Token 必须包含精确字符串 `type=access`。 +- Refresh Token 必须包含精确字符串 `type=refresh`。 +- Token 类型区分大小写。 +- 类型缺失、空白、未知或不匹配均拒绝。 +- 普通业务过滤器只能解析 Access Token。 +- 刷新服务只能解析 Refresh Token。 + +### D02:Access 与 Refresh 使用独立签名密钥 + +新增配置: + +```yaml +app: + jwt: + access-secret: ${JWT_ACCESS_SECRET} + refresh-secret: ${JWT_REFRESH_SECRET} + token-hash-secret: ${JWT_TOKEN_HASH_SECRET} +``` + +三项密钥必须彼此独立。生产环境不允许自动回退到同一个默认密钥。测试配置使用固定的测试专用值,但不得进入生产配置。 + +独立密钥是类型 claim 之外的第二道隔离:即使未来某处遗漏类型判断,Refresh Token 也不能通过 Access Token 验签器。 + +### D03:旧 Token 采用强制重新登录 + +部署 S02 后: + +- 无 `type` 的旧 Access Token 拒绝; +- 旧 Refresh Token 虽有 `type=refresh`,但没有 `sid`、`jti` 和服务端记录,同样拒绝; +- 用户需要重新完成一次手机号验证码登录; +- 不提供永久兼容开关; +- 不把无 `type` Token 猜测为 Access Token。 + +前端必须统一处理 401:清除本地 Token,跳转登录页,不循环刷新。 + +### D04:保持现有有效期 + +- Access Token 默认 24 小时; +- Refresh Token 默认 7 天; +- 登录响应的 `expiresIn=86400` 保持现有含义; +- S02 不顺带缩短 Token 有效期; +- 有效期必须来自配置并经过正数、上限和单位校验。 + +### D05:每次登录创建独立设备会话 + +- 每次成功登录生成新的 `sessionId`; +- 同一用户可以同时拥有多个活动会话; +- 普通登出只撤销当前 `sessionId`; +- Refresh Token 重放只撤销对应 `sessionId`; +- 账号注销完成、用户禁用及未来的敏感认证信息变更撤销该用户全部会话。 + +本版不采集或保存设备指纹,不通过 User-Agent 判断设备身份。 + +### D06:JWT Claim 白名单 + +Access Token 必须包含: + +| Claim | 说明 | +|---|---| +| `sub` | 十进制正整数用户 ID | +| `type` | 固定为 `access` | +| `sid` | UUID 格式的会话 ID | +| `jti` | UUID 格式的 Token ID | +| `role` | 登录时角色,仅作响应和诊断辅助;资源权限仍以现有服务层校验为准 | +| `iat` | 签发时间 | +| `exp` | 过期时间 | +| `iss` | 固定发行者 `mydreamjob` | +| `aud` | 固定受众 `mydreamjob-api` | + +Refresh Token 必须包含: + +| Claim | 说明 | +|---|---| +| `sub` | 十进制正整数用户 ID | +| `type` | 固定为 `refresh` | +| `sid` | 与当前登录会话一致 | +| `jti` | 本次 Refresh Token 的唯一 ID | +| `iat` | 签发时间 | +| `exp` | 过期时间 | +| `iss` | 固定发行者 `mydreamjob` | +| `aud` | 固定受众 `mydreamjob-auth` | + +新 Token 不再包含手机号。不得加入验证码、邮箱、昵称、简历地址或其他个人资料。 + +### D07:Refresh Token 原文不落库 + +数据库只保存: + +```text +HMAC-SHA256(JWT_TOKEN_HASH_SECRET, rawRefreshToken) +``` + +- 不使用普通可逆加密; +- 不使用无密钥的快速摘要作为唯一保护; +- 比较摘要时使用常量时间比较; +- Token、摘要和签名密钥均不得写日志或审计; +- `token_hash` 的业务含义固定为 Refresh Token 摘要。 + +### D08:每次刷新强制轮换 + +刷新成功必须: + +1. 捕获一次 `capturedNow`; +2. 使用 Refresh 专用密钥验签; +3. 验证 `type`、`iss`、`aud`、`sub`、`sid`、`jti`、`iat` 和 `exp`; +4. 计算原始 Refresh Token 摘要; +5. 查询对应服务端记录; +6. 原子消费旧记录; +7. 生成新的 Access/Refresh Token 对; +8. 插入新的 Refresh Token 记录; +9. 提交事务后返回响应。 + +旧 Refresh Token 被消费后永久失效。 + +### D09:Refresh Token 重放关闭当前会话 + +若签名有效的 Refresh Token 对应记录已经被消费或撤销: + +- 本次刷新返回 HTTP 401; +- 撤销相同 `sessionId` 下的全部 Token 记录; +- 当前会话的所有 Access Token 随即失效; +- 不影响同一用户的其他设备会话; +- 写一条不含 Token、摘要或个人信息的安全审计; +- 错误响应不得说明记录是否存在、已消费还是已撤销。 + +### D10:Access Token 必须检查服务端会话状态 + +`JwtAuthenticationFilter` 在完成 Access JWT 验签后,还必须确认: + +- `sid` 属于 `sub` 对应用户; +- 会话存在活动、未消费且未撤销的 Refresh Token 记录; +- 会话未过期; +- 用户存在、处于 active 状态且未完成注销。 + +任一条件不满足时不得建立 `SecurityContext`。 + +数据库是第一版的事实来源。Redis 缓存不属于 S02 首版;后续若增加缓存,数据库仍是最终事实来源,缓存异常不得放行。 + +### D11:普通登出只撤销当前会话 + +`POST /auth/logout`: + +- 需要有效 Access Token; +- 从 Token 中读取 `sub` 和 `sid`,不接受客户端传 userId/sessionId; +- 撤销该用户当前 `sid` 的所有 Token 记录; +- 重复登出保持幂等; +- 不影响其他设备会话; +- 成功响应不返回 Token。 + +### D12:全局安全事件撤销全部会话 + +以下事件必须调用统一的 `revokeAllUserSessions(userId, reason, capturedNow)`: + +- 账号注销完成; +- 用户被禁用; +- 未来的手机号或认证凭据变更。 + +账号注销申请处于冷静期时不立即撤销全部会话,保持当前产品语义;注销完成时必须撤销。取消注销不恢复已经撤销的会话。 + +### D13:单次时间捕获 + +登录、刷新、登出、重放处置和全局吊销每个入口只允许调用一次 `Clock`: + +- JWT `iat`、`exp`; +- 数据库 `createdAt`、`expiredAt`、`consumedAt`、`revokedAt`; +- 审计时间判断; +- 响应有效期; + +均使用同一个 `capturedNow`。不得依赖 `new Date()`、`LocalDateTime.now()` 或系统默认时区散落取时。 + +固定业务时区为 `Asia/Shanghai`,数据库时间按项目现有约定落库。 + +### D14:失败关闭 + +以下情况一律不建立认证: + +- Token 解析异常; +- 类型、发行者或受众错误; +- subject、`sid` 或 `jti` 非法; +- 服务端记录不存在; +- 会话已撤销、已消费或过期; +- 用户不存在、被禁用或已注销; +- 数据库读取或摘要计算失败。 + +过滤器只记录固定安全文本和 requestId,不记录异常消息、Token、摘要或 claim 原值。 + +### D15:错误码与 HTTP 语义 + +优先复用现有: + +- `UNAUTHORIZED(2001)`; +- `TOKEN_EXPIRED(2003)`; +- `TOKEN_INVALID(2004)`; +- `SERVICE_UNAVAILABLE(9001)`。 + +公共认证失败统一表现为 HTTP 401。不得通过不同响应告诉客户端 Token 是“不存在”“已消费”“已撤销”还是“重放”。 + +除非实现阶段发现现有错误映射无法满足固定 401,才允许提出新增 ErrorCode;未获确认不得新增。 + +### D16:接口增量固定为两个 operation + +新增: + +- `POST /auth/refresh`; +- `POST /auth/logout`。 + +预计 OpenAPI 从 `173 paths / 211 operations` 变为 `175 paths / 213 operations`。 + +不得新增会话管理列表、全部设备登出、Token introspection 或管理员代操作接口。 + +### D17:并发由数据库条件更新保证 + +同一 Refresh Token 并发刷新: + +- 使用包含 `id`、`consumed_at IS NULL`、`revoked=0` 和 `version` 的条件更新; +- 只有一个事务可以消费成功; +- 其他事务不得生成第二组有效 Token; +- 失败方执行重放处置或返回固定 401; +- 不使用 JVM 本地锁作为正确性基础。 + +### D18:设计范围隔离 + +S02 不改变: + +- 短信验证码生成、过期、锁定和发送语义; +- 登录自动创建用户的现有规则; +- 用户角色及资源归属; +- 岗位、投递、Offer、评分、排名、推荐、人才发现、邀约; +- 申诉、消息撤回和简历上传; +- V1–V41 迁移内容。 + +## 5. Token 决策表 + +| Token/状态 | 普通业务接口 | `/auth/refresh` | `/auth/logout` | 注销完成后 | 过期后 | +|---|---|---|---|---|---| +| 有效 Access | 允许 | 拒绝 | 允许 | 拒绝 | 拒绝 | +| 有效 Refresh | 拒绝 | 允许并轮换 | 拒绝 | 拒绝 | 拒绝 | +| 无 `type` 旧 Token | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | +| 未知 `type` | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | +| 已消费 Refresh | 拒绝 | 401 并撤销当前会话 | 拒绝 | 拒绝 | 拒绝 | +| 已撤销 Token | 拒绝 | 拒绝 | 幂等 | 拒绝 | 拒绝 | +| 会话不存在 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | +| 用户被禁用 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | + +## 6. 状态机 + +单个 Refresh Token 记录状态: + +```text +ACTIVE -> CONSUMED +ACTIVE -> REVOKED +CONSUMED -> REVOKED (重放或全局吊销) +``` + +不允许从 `CONSUMED` 或 `REVOKED` 恢复为 `ACTIVE`。 + +会话状态由同一 `sessionId` 下的记录推导:存在一个未消费、未撤销且未过期的当前 Refresh Token 记录时,会话为活动状态。 + +## 7. 数据模型 + +### 7.1 迁移原则 + +- 编码前重新确认最新 Flyway;当前建议文件名为 `V42__auth_token_sessions.sql`; +- 若 V42 已被占用,停止并重新编号; +- 不修改 V1–V41; +- 不删除旧记录; +- 新字段先允许旧数据为空,新签发记录由应用强制非空; +- Hibernate 使用 `ddl-auto=validate`; +- 迁移必须兼容真实 MySQL 8.4 和项目 H2 测试模式。 + +### 7.2 `auth_tokens` 扩展字段 + +建议增加: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `session_id` | VARCHAR(36) NULL | 每次登录的会话 UUID | +| `token_jti` | VARCHAR(36) NULL | Refresh Token 的 jti | +| `parent_token_id` | BIGINT NULL | 轮换前一条记录 ID | +| `consumed_at` | DATETIME NULL | 成功刷新时消费时间 | +| `revoked_at` | DATETIME NULL | 撤销时间 | +| `revocation_reason` | VARCHAR(40) NULL | 固定白名单原因 | +| `version` | BIGINT NOT NULL DEFAULT 0 | 乐观锁/条件更新版本 | + +固定含义: + +- `token_hash`:Refresh Token 的 HMAC-SHA256 摘要; +- `token_type`:新记录固定 `REFRESH`; +- `expired_at`:Refresh Token 到期时间; +- `revoked`:现有兼容布尔值; +- `refresh_token_hash`:旧字段保留但新逻辑不依赖,后续版本再评估移除。 + +索引建议: + +```sql +INDEX idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at) +INDEX idx_auth_tokens_user_session (user_id, session_id) +UNIQUE INDEX uk_auth_tokens_token_jti (token_jti) +``` + +不得在本轮清理或改写旧 `auth_tokens` 数据。`session_id IS NULL` 的旧记录永远不能被新鉴权逻辑视为活动会话。 + +## 8. 组件职责 + +### `JwtTokenProvider` + +- 分别生成和解析 Access/Refresh Token; +- 使用不同签名密钥; +- 强制校验类型、发行者、受众和必需 claim; +- 接收外部传入的 `capturedNow`,不自行重复取时; +- 不访问数据库。 + +### `AuthTokenHasher` + +- 只负责 Refresh Token HMAC 摘要; +- 使用独立密钥; +- 支持常量时间比较; +- 不记录输入和输出。 + +### `AuthSessionService` + +- 创建会话和首个 Refresh Token 记录; +- 验证、消费、轮换 Refresh Token; +- 判断 Access Token 所属会话是否活动; +- 撤销当前会话或用户全部会话; +- 承担事务与并发控制。 + +### `JwtAuthenticationFilter` + +- 只接受 Access Token; +- 调用 Provider 完成密码学和 claim 校验; +- 调用 Session Service 验证服务端会话状态; +- 通过后建立 SecurityContext; +- 失败时保持匿名,由 SecurityFilterChain 返回既有 401。 + +### `AuthService` + +- 登录成功时创建服务端会话; +- 只在会话记录成功持久化后返回 Token; +- 编排刷新和登出; +- 保持现有用户登录/自动创建语义。 + +### `AuthController` + +- 接收白名单 DTO; +- 不解析 JWT、不计算摘要、不接收 userId/sessionId; +- 不在异常响应中回显 Token; +- 不返回 Entity 或数据库字段。 + +## 9. API 契约 + +### 9.1 登录 + +现有 `POST /auth/login` 请求不变。 + +响应字段保持: + +- `userId` +- `phone` +- `nickname` +- `role` +- `accessToken` +- `refreshToken` +- `expiresIn` + +新 Token 从 S02 起包含完整类型和会话 claim。旧客户端不需要解析 JWT 内部 claim。 + +### 9.2 刷新 + +`POST /auth/refresh` + +请求: + +```json +{ + "refreshToken": "" +} +``` + +成功响应: + +```json +{ + "code": 1000, + "data": { + "accessToken": "", + "refreshToken": "", + "expiresIn": 86400 + }, + "requestId": "" +} +``` + +要求: + +- 请求体只允许 `refreshToken`; +- 不能为空,设置合理长度上限; +- 不接受查询参数或路径参数中的 Token; +- 旧 Refresh Token 刷新成功后立即失效。 + +### 9.3 登出 + +`POST /auth/logout` + +- 使用 `Authorization: Bearer `; +- 无 request body; +- 成功返回现有 `ApiResponse` 包装的固定消息; +- 重复登出按幂等成功处理,但已经撤销的 Access Token通常无法再通过过滤器,因此客户端不应依赖第二次请求; +- 不接收 Refresh Token、userId 或 sessionId 参数。 + +## 10. SecurityConfig + +- `/auth/login`、`/auth/sms/send` 和 `/auth/refresh` 允许匿名进入; +- `/auth/logout` 必须 authenticated; +- `permitAll` 只表示可进入 Controller,不代表 Refresh Token 校验成功; +- 其他接口规则不变; +- 不扩大 `/auth/**` 为整体公开; +- Refresh Token 放入 Authorization 头访问任何普通接口必须得到 401。 + +## 11. 事务与并发 + +### 登录 + +用户读取/创建、会话记录创建和登录审计在一个事务内完成。会话记录失败时不能返回 Token。 + +### 刷新 + +旧 Token 条件消费和新 Token 记录插入在一个事务内完成。提交失败时不能返回新 Token。 + +### 登出 + +同一事务撤销当前会话记录并写脱敏审计。 + +### 重放 + +发现已消费 Token 时,在独立明确事务中撤销对应会话,避免刷新事务回滚后撤销也丢失。实现必须通过测试证明最终会话已撤销。 + +## 12. 审计与日志 + +允许的审计动作建议: + +- `AUTH_SESSION_CREATED` +- `AUTH_TOKEN_REFRESHED` +- `AUTH_SESSION_REVOKED` +- `AUTH_REFRESH_REUSE_DETECTED` + +若新增审计动作不需要新增 ErrorCode,也不需要新表。 + +审计详情仅允许: + +- userId; +- sessionId 的 HMAC 摘要或固定截断不可逆标识; +- reason 白名单; +- requestId; +- 结果状态。 + +禁止: + +- Access Token; +- Refresh Token; +- Token 摘要; +- JWT claim 全文; +- 完整手机号、邮箱; +- Authorization 请求头; +- 异常对象 `toString()`。 + +## 13. 前端联调语义 + +前端必须: + +1. 登录后分别保存 Access 和 Refresh Token; +2. 普通请求只携带 Access Token; +3. 收到普通 401 时最多执行一次刷新; +4. 刷新成功后原子替换两个 Token; +5. 刷新失败时清除两个 Token并跳转登录; +6. 防止多个并发请求同时用同一 Refresh Token刷新; +7. 登出时调用 `/auth/logout`,随后无条件清除本地 Token; +8. S02 上线后接受一次强制重新登录。 + +不允许把 Refresh Token 放入 URL、本地日志、埋点或错误上报正文。 + +## 14. 自动化测试要求 + +至少覆盖: + +### Provider + +- Access 包含 `type=access`; +- Refresh 包含 `type=refresh`; +- 两类 Token 使用不同密钥; +- 交叉验签失败; +- issuer/audience/type/sub/sid/jti 缺失或错误时拒绝; +- 过期边界; +- 新 Token 不含手机号。 + +### Filter + +- Access Token 可访问普通接口; +- Refresh Token 访问普通接口为 401; +- 无 type、未知 type、非法 subject/sid/jti 为 401; +- 已撤销会话、用户禁用、注销完成为 401; +- 服务端检查异常时不放行; +- 响应和日志不回显 Token。 + +### 登录与存储 + +- 登录生成一条活动会话记录; +- 数据库只保存摘要; +- 登录响应结构不变; +- 会话持久化失败不返回 Token; +- 多次登录生成不同 sessionId。 + +### 刷新与并发 + +- 正常刷新轮换 Token; +- 原 Token 变为 consumed; +- 新旧 jti 不同、sid 相同; +- 同一 Refresh Token 第二次使用返回 401并撤销当前会话; +- 两线程并发刷新最多一个成功; +- 最终只有一个后继 Token; +- 无 500、死锁或重复活动记录。 + +### 登出与注销 + +- 登出撤销当前设备; +- 其他设备仍可用; +- 重复撤销幂等; +- 注销完成撤销全部设备; +- 注销取消不恢复旧会话; +- 被禁用用户 Token 无效。 + +### JSON 与日志脱敏 + +递归禁止出现 Token、摘要、Authorization、手机号、邮箱、secret、password 和内部对象。 + +## 15. 真实 MySQL/Docker 验收 + +必须验证: + +- 新迁移在真实 MySQL 8.4 成功; +- `ddl-auto=validate` 通过; +- 登录记录只含摘要; +- Refresh Token 无法调用普通接口; +- Access Token 无法调用刷新接口; +- 正常轮换、重放和两线程并发符合设计; +- 登出后旧 Access/Refresh 均失效; +- 多设备隔离; +- 注销完成全部失效; +- 运行时 OpenAPI 为预计的 `175/213`; +- 日志、审计和数据库无 Token 原文; +- RabbitMQ、通知、评分和其他业务表无新增副作用。 + +## 16. 发布与回滚 + +### 发布前 + +- 前端必须先支持刷新失败后重新登录; +- 配置三个独立强密钥; +- 在隔离环境完成真实 MySQL 迁移和黑盒; +- 确认所有节点同时升级,避免新旧 Token 策略混跑。 + +### 发布结果 + +- 所有旧 Token失效; +- 所有用户需要重新登录一次; +- 新登录产生服务端会话记录。 + +### 回滚 + +- 不回滚或修改已执行 Flyway; +- 应用回滚到旧版会重新接受只看签名的 Token,存在安全倒退; +- 因此 S02 发布后不允许直接回滚到未隔离版本; +- 紧急回滚应发布保持类型隔离的兼容修复版本,而不是恢复旧过滤器。 + +## 17. 验收门槛 + +只有全部满足才可冻结 S02: + +1. Refresh Token 不能访问任何普通业务接口; +2. Access Token 不能用于刷新; +3. 无 type 旧 Token 被拒绝; +4. Token 原文不落库、不入日志、不入审计; +5. 每次刷新完成轮换; +6. 重放撤销当前会话; +7. 并发刷新最多成功一次; +8. 登出立即使当前会话 Access/Refresh 失效; +9. 多设备会话互不误伤; +10. 注销完成和用户禁用使全部会话失效; +11. 旧登录响应字段保持兼容; +12. 自动化测试 0 Failures/Errors/Skipped; +13. `mvn clean verify` 成功; +14. 静态和运行时 OpenAPI 一致; +15. 真实 MySQL迁移和 schema validation成功; +16. 不改变其他业务模块结果。 + +## 18. 明确非目标与剩余风险 + +本版不实现: + +- 查看全部登录设备; +- 远程踢出单个其他设备; +- OAuth/OIDC; +- 短信验证码数据库摘要化; +- 真实短信供应商; +- Redis 会话缓存; +- Access Token 有效期缩短; +- 浏览器 HttpOnly Cookie 模式; +- 设备指纹和异常登录检测。 + +后续可以单独评估,但不得作为 S02 验收通过的替代条件。 diff --git a/docs/s02-auth-token-isolation-implementation-instruction.md b/docs/s02-auth-token-isolation-implementation-instruction.md new file mode 100644 index 0000000..8488d77 --- /dev/null +++ b/docs/s02-auth-token-isolation-implementation-instruction.md @@ -0,0 +1,724 @@ +# S02 Access/Refresh Token 隔离与吊销:编码实施指令 + +> 本文可原样交给编码执行者。权威设计为 `docs/s02-auth-token-isolation-design.md`。任何与设计冲突、会改变其他业务语义或需要扩大公共接口的情况,必须停止并报告,不得自行改设计。 + +## 一、任务目标 + +执行 S02「Access/Refresh Token 隔离、轮换与吊销」。 + +项目根目录: + +```text +C:\Users\15642\Desktop\zhilai +``` + +基线要求: + +- 必须包含 `b5084fd`(S01 认证安全整改); +- 必须包含 `96a920b`(简历上传安全加固); +- 当前最新 Flyway 应为 `V41__message_recall.sql`; +- 当前静态 OpenAPI 应为 `173 paths / 211 operations`; +- S02 预计新增两个接口,完成后为 `175 paths / 213 operations`。 + +本轮只实现 S02,不进入 S03、Q01 或 R01。 + +## 二、硬性边界 + +允许: + +1. 修改认证 Token 生成、解析、过滤、登录、刷新和登出链路; +2. 扩展现有 `auth_tokens` 表; +3. 新增一个 Flyway,建议 `V42__auth_token_sessions.sql`; +4. 新增 `/auth/refresh` 和 `/auth/logout`; +5. 将账号注销完成、用户禁用接入会话吊销; +6. 更新认证文档、前端联调文档和 OpenAPI; +7. 新增自动化测试。 + +禁止: + +1. 修改 V1–V41; +2. 删除或重建 `auth_tokens` 表; +3. 在数据库、日志、审计、异常或 URL 中保存/输出原始 Token; +4. 永久兼容无 `type` 旧 Token; +5. 把 Refresh Token 当 Access Token; +6. 使用同一密钥签发 Access 和 Refresh; +7. 使用 JVM 本地锁代替数据库并发控制; +8. 修改短信验证码、登录自动注册、角色和资源归属语义; +9. 修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回或简历上传; +10. 新增设备列表、全部设备登出、Token introspection 等接口; +11. 删除、禁用或放宽既有测试; +12. `git reset`、`git restore`、`git checkout`、`git clean`; +13. `git add -A`、commit、push; +14. 删除、移动或覆盖历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env`; +15. 操作 Docker或执行真实 MySQL 黑盒,除非负责人另行授权。 + +## 三、开始前检查 + +执行并记录: + +```powershell +cd C:\Users\15642\Desktop\zhilai +git branch --show-current +git status --short +git diff --cached --name-only +git log -5 --oneline +git diff --check +git diff --name-only -- mydreamjob-server/src/main/resources/db/migration +``` + +要求: + +- 暂存区为空; +- 记录 HEAD; +- 确认基线包含 `96a920b`; +- 保护全部已有修改和未跟踪文件; +- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录; +- 若认证相关文件存在不属于 S02 的未提交修改,停止并报告; +- 建议从已包含 `96a920b` 的基线创建独立分支 `feat/s02-token-isolation`,但不得覆盖用户现有分支或工作树。 + +## 四、必须完整阅读 + +权威文档: + +```text +docs/s02-auth-token-isolation-design.md +docs/api-v0.1.md +docs/frontend-integration.md +docs/phase1-scope-and-acceptance.md +docs/backend-development-roadmap.md +docs/openapi-rc.json +docs/verify_openapi.py +``` + +生产代码: + +```text +mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java +mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java +mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java +mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java +mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java +mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java +mydreamjob-server/src/main/resources/application.yml +mydreamjob-server/src/main/resources/application-dev.yml +mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql +``` + +测试: + +```text +mydreamjob-server/src/test/java/com/mydreamjob/auth/** +mydreamjob-server/src/test/java/com/mydreamjob/security/** +mydreamjob-server/src/test/java/com/mydreamjob/deletion/** +mydreamjob-server/src/test/resources/application-test.yml +mydreamjob-server/src/test/resources/application-integration-test.yml +``` + +搜索: + +```powershell +rg -n "generateAccessToken|generateRefreshToken|validateToken|parseUserId|AuthToken|AuthTokenRepository|refreshToken|Authorization|logout|type.*refresh|JWT_SECRET|refresh-expiration" mydreamjob-server/src docs +``` + +## 五、先补失败测试 + +编码前先增加失败测试,证明当前缺陷存在。至少包括: + +1. `refreshTokenCannotAuthenticateOrdinaryEndpoint`:当前 Refresh Token 访问 `/auth/me` 不应成功;修复前应失败。 +2. `accessTokenMustContainAccessType`:Access Token 必须有 `type=access`。 +3. `accessTokenCannotBeUsedAtRefreshEndpoint`。 +4. `legacyTokenWithoutTypeIsRejected`。 +5. `accessAndRefreshTokensCannotCrossVerify`。 +6. `revokedSessionCannotAuthenticate`。 +7. `sameRefreshTokenCannotBeConsumedTwice`。 + +保留修复前失败输出。不得通过修改期望状态码掩盖问题。 + +## 六、配置整改 + +修改 `application.yml`: + +1. 将错误位于 `app.resume.refresh-expiration-ms` 的配置移回 `app.jwt`; +2. 增加: + +```yaml +app: + jwt: + access-secret: ${JWT_ACCESS_SECRET} + refresh-secret: ${JWT_REFRESH_SECRET} + token-hash-secret: ${JWT_TOKEN_HASH_SECRET} + expiration-ms: ${JWT_EXPIRATION_MS:86400000} + refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} + issuer: mydreamjob + access-audience: mydreamjob-api + refresh-audience: mydreamjob-auth +``` + +要求: + +- 生产配置不得提供秘密默认值; +- 三项密钥不得互相回退; +- 启动时校验密钥长度和互异性; +- 测试 profile 使用固定测试密钥; +- 不打印密钥; +- 原 `app.jwt.secret` 仅作为旧配置迁移项处理,S02 新 Token 不使用它; +- 缺失、空值或三项相同必须启动失败,并给出固定、不含密钥的安全配置错误。 + +新增配置测试: + +- 三项有效且互异时启动成功; +- 任一缺失、空白或过短时失败; +- access/refresh 相同时失败; +- hash secret 与签名密钥相同时失败; +- 错误日志不包含密钥值。 + +## 七、JWT Provider 重构 + +修改 `JwtTokenProvider`,禁止继续使用通用的 `validateToken + parseUserId` 组合来判断 Token 用途。 + +建议提供明确 API: + +```java +IssuedToken generateAccessToken(..., Instant capturedNow); +IssuedToken generateRefreshToken(..., Instant capturedNow); +AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow); +RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow); +``` + +要求: + +- Access 使用 access secret; +- Refresh 使用 refresh secret; +- 精确校验 `type`、`iss` 和 `aud`; +- 验证 `sub` 为正整数; +- 验证 `sid`、`jti` 为规范 UUID; +- Access 必须含 `role`; +- 新 Token 不含 `phone`; +- `iat`、`exp` 使用调用方传入的同一 `capturedNow`; +- 过期判断边界固定:`exp <= capturedNow` 视为过期; +- 不向调用方返回原始 Claims 对象; +- 异常消息不得包含 Token 或 claim 原值。 + +增加强类型内部 record/DTO,例如: + +```text +AccessTokenClaims +RefreshTokenClaims +IssuedToken +``` + +不要让 Controller 直接依赖 JJWT 类型。 + +## 八、Token 摘要 + +新增内部服务,例如: + +```text +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java +``` + +要求: + +- `HmacSHA256`; +- 密钥为 `app.jwt.token-hash-secret`; +- 输出固定长度十六进制或 Base64URL; +- 不存原文; +- 不记录输入、输出或异常原值; +- 对固定输入输出稳定; +- 比较使用 `MessageDigest.isEqual`; +- 单元测试使用公开测试向量或确定性断言。 + +## 九、Flyway V42 + +编码前执行: + +```powershell +Get-ChildItem mydreamjob-server/src/main/resources/db/migration | Sort-Object Name | Select-Object -Last 10 +``` + +只有 V42 未被占用时才新增: + +```text +mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql +``` + +扩展 `auth_tokens`: + +```text +session_id VARCHAR(36) NULL +token_jti VARCHAR(36) NULL +parent_token_id BIGINT NULL +consumed_at DATETIME NULL +revoked_at DATETIME NULL +revocation_reason VARCHAR(40) NULL +version BIGINT NOT NULL DEFAULT 0 +``` + +新增索引: + +```text +idx_auth_tokens_session_active(session_id, revoked, consumed_at, expired_at) +idx_auth_tokens_user_session(user_id, session_id) +uk_auth_tokens_token_jti(token_jti) UNIQUE +``` + +要求: + +- 不修改旧列、不删除旧数据; +- `session_id IS NULL` 的旧行不参与新会话认证; +- 不新增其他表; +- 不添加级联删除; +- CHECK/索引语法必须通过 MySQL 8.4 和 H2 MODE=MySQL; +- 更新 `AuthToken` 实体并使用 `@Version`; +- `token_hash` 保存 Refresh Token HMAC 摘要; +- 新记录 `token_type=REFRESH`; +- `refresh_token_hash` 保留但新逻辑不依赖。 + +新增持久化测试: + +- 旧格式行仍可存在; +- 新记录字段正确; +- `token_jti` 唯一; +- 默认 version=0; +- 两个 EntityManager 的旧 version 更新冲突; +- 不保存原始 JWT。 + +## 十、Repository 原子操作 + +`AuthTokenRepository` 增加明确查询和条件更新,禁止 `findById -> set -> save` 作为并发消费保证。 + +至少实现: + +1. 按 `tokenHash` 查询候选 Refresh 记录; +2. 按 `userId + sessionId` 判断活动会话; +3. 原子消费:条件包含 `id`、`expectedVersion`、`revoked=false`、`consumedAt IS NULL`、`expiredAt > capturedNow`; +4. 按 `userId + sessionId` 撤销当前会话; +5. 按 `userId` 撤销全部会话。 + +所有更新返回受影响行数并严格检查。不得通过捕获异常后继续返回成功。 + +## 十一、AuthSessionService + +新增: + +```text +mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java +``` + +职责: + +- 创建登录会话; +- 保存首个 Refresh Token 摘要; +- 校验 Access 所属会话; +- 消费并轮换 Refresh Token; +- 撤销当前会话; +- 撤销用户全部会话; +- 处理重放。 + +### 登录 + +- 捕获一次时间; +- 生成随机 UUID `sid`; +- 生成 Access 和 Refresh 的独立 `jti`; +- 生成 Token; +- 保存 Refresh 摘要; +- 事务提交成功后才返回; +- 多次登录必须得到不同 `sid`。 + +### 刷新 + +- Controller 传入原始 Refresh Token,但原文只在内存短暂存在; +- Provider 校验 Refresh; +- 摘要查询记录; +- claim 中的 userId/sid/jti 必须与数据库一致; +- 原子消费旧记录; +- 插入同 sid 的新记录; +- 新旧 jti 不同; +- 新 Access 和 Refresh 使用同一次 `capturedNow`; +- 旧 Token 再次使用返回固定 401。 + +### 重放 + +- 已消费或已撤销 Token 被再次提交时撤销整个当前 session; +- 撤销必须在不会随当前失败事务回滚丢失的事务边界执行; +- 写一条脱敏审计; +- 不回显“已消费”“已撤销”或数据库状态。 + +### 活动会话判断 + +- session 必须属于 user; +- 存在未消费、未撤销、未过期的当前 Refresh 记录; +- 用户 active; +- 账号注销未完成; +- 数据库错误失败关闭。 + +## 十二、登录服务 + +修改 `AuthService.mockLogin`: + +- 保持验证码、自动注册、注销墓碑和响应字段不变; +- 改为创建 AuthSession; +- Access Token 必须带 `type=access`; +- Refresh Token 必须带 `type=refresh`; +- 响应仍包含 `userId/phone/nickname/role/accessToken/refreshToken/expiresIn`; +- 数据库只保存 Refresh 摘要; +- 会话持久化失败时整个登录事务失败,不得返回 Token或写成功审计; +- 不改变 `expiresIn=86400`。 + +## 十三、认证过滤器 + +修改 `JwtAuthenticationFilter`: + +- 只调用 `parseAndValidateAccessToken`; +- Refresh Token、无 type、未知 type、错误 issuer/audience 必须拒绝; +- 验证服务端 session; +- 验证用户 active 和注销状态; +- 通过后使用 userId 建立 SecurityContext; +- 不把 Token claim 直接作为权限真相; +- 任何失败均不得建立部分认证; +- 清理可能存在的 SecurityContext; +- 日志使用固定安全文本,不使用异常 `getMessage()`; +- 不回显 Token、subject、sid、jti。 + +真实 MockMvc 测试必须覆盖: + +- Access 访问 `/auth/me` 成功; +- Refresh 访问 `/auth/me` 为 401; +- Access/Refresh 交叉密钥失败; +- 无 type、未知 type、非法 subject/sid/jti 为 401; +- 撤销 session 为 401; +- 用户禁用为 401; +- 服务端校验抛异常时不放行、不返回 500 内部对象。 + +## 十四、刷新和登出接口 + +新增白名单 DTO: + +```text +auth/dto/RefreshTokenRequest.java +auth/dto/TokenPairResponse.java +``` + +### `POST /auth/refresh` + +- `SecurityConfig` 中精确 permitAll; +- 请求体只包含 `refreshToken`; +- `@NotBlank` 且设置合理长度上限; +- Token 不允许作为 query/path 参数; +- 成功返回新 Token 对和 `expiresIn`; +- Access Token 作为 refreshToken 提交必须 401; +- 所有认证失败统一安全响应。 + +### `POST /auth/logout` + +- 必须 authenticated; +- 无 requestBody; +- 从通过验证的 Access Token claims / SecurityContext 取得 userId 和 sid; +- 不接收 userId、sessionId、refreshToken; +- 撤销当前 session; +- 保持幂等语义; +- 成功响应不包含 Token。 + +`AuthController` 不得计算摘要、解析 JWT、访问 Repository 或重复实现会话逻辑。 + +## 十五、注销与用户禁用 + +在不改变账号注销状态机的前提下: + +- `AccountDeletionCompletionWorker` 成功完成注销时撤销用户全部会话; +- 状态更新和会话撤销必须具备一致事务语义; +- 重复完成不重复产生不受控副作用; +- 注销申请 PENDING 时不强制退出; +- 取消注销不恢复旧 session; +- Filter 每次认证必须拒绝 inactive 或 COMPLETED 用户; +- 不接受客户端指定需要撤销的 userId。 + +补测试证明: + +- 注销完成后所有设备 Access/Refresh 均失效; +- 其他用户会话不受影响; +- 禁用用户不能继续认证; +- candidate scoring、消息、投递等业务表不被修改。 + +## 十六、审计隐私 + +允许的固定动作: + +```text +AUTH_SESSION_CREATED +AUTH_TOKEN_REFRESHED +AUTH_SESSION_REVOKED +AUTH_REFRESH_REUSE_DETECTED +``` + +details 使用 ObjectMapper 序列化白名单 Map/record,禁止拼接不受控 JSON。 + +禁止进入日志、审计和响应: + +```text +accessToken +refreshToken +Authorization +tokenHash +refreshTokenHash +JWT 原文 +完整手机号 +email +password +secret +Claims 对象 +异常对象 toString +``` + +日志测试必须捕获真实 Logback 输出,不得只 Mockito verify logger。 + +## 十七、单次 Clock + +注入 `Clock`,使用 `ZoneId.of("Asia/Shanghai")` 的测试 Clock。 + +分别验证登录、刷新、登出、重放和注销完成: + +- 每个入口只读取 Clock 一次; +- JWT iat/exp; +- DB createdAt/expiredAt/consumedAt/revokedAt; +- 审计和响应; + +均使用首次捕获时间。 + +禁止使用 `Thread.sleep()`、系统默认时区或真实时间碰撞作为核心测试。 + +## 十八、并发测试 + +新增真实 H2 集成并发测试: + +- `@SpringBootTest`; +- integration-test profile; +- 真实 Service、Repository、事务和 EntityManager; +- 两个线程和独立事务; +- 使用 CountDownLatch 同时开始; +- 不 Mock 条件更新。 + +测试: + +1. `concurrentRefreshOnlyOneRequestSucceeds`; +2. `refreshReplayRevokesOnlyCurrentSession`; +3. `differentSessionsRefreshIndependently`; +4. `logoutRacingWithRefreshCannotLeaveActiveToken`。 + +断言: + +- 同一 Token 最多一个刷新成功; +- 不产生两个活动后继 Token; +- 失败方不返回 500; +- 当前 session 被安全撤销; +- 其他 session 不受影响; +- 无死锁和未处理乐观锁异常。 + +## 十九、完整测试矩阵 + +建议新增: + +```text +JwtTokenTypeIsolationTest.java +JwtAuthenticationTypeIntegrationTest.java +AuthTokenHasherTest.java +AuthSessionServiceTest.java +AuthSessionPersistenceIntegrationTest.java +AuthRefreshControllerTest.java +AuthRefreshHttpIntegrationTest.java +AuthRefreshConcurrencyIntegrationTest.java +AuthSessionRevocationIntegrationTest.java +AuthTokenClockConsistencyTest.java +AuthTokenRedactionTest.java +``` + +至少覆盖: + +### 类型和 claim + +- access/refresh 类型; +- 独立密钥; +- issuer/audience; +- subject/sid/jti; +- 无 type 和未知 type; +- 过期边界; +- Token 不含手机号。 + +### 生命周期 + +- 登录; +- 刷新轮换; +- 二次使用; +- 并发刷新; +- 当前设备登出; +- 多设备隔离; +- 注销完成; +- 用户禁用。 + +### HTTP + +- 匿名刷新携带有效 Refresh 成功; +- Access 调刷新 401; +- Refresh 调普通接口 401; +- 无 Token 登出 401; +- 有效 Access 登出成功; +- 错误响应不泄漏。 + +### 副作用 + +- 原始 Token 不落库; +- 不产生 RabbitMQ、通知或短信; +- 不修改评分、排名、推荐、邀约、消息和简历表; +- 只产生预期 auth_tokens 和白名单审计变化。 + +## 二十、OpenAPI 和文档 + +更新: + +```text +docs/openapi-rc.json +docs/verify_openapi.py +docs/api-v0.1.md +docs/frontend-integration.md +docs/backend-development-roadmap.md +docs/phase1-scope-and-acceptance.md +docs/s02-auth-token-isolation-design.md +docs/s02-auth-token-isolation-implementation-instruction.md +``` + +运行时生成 OpenAPI,不得手工伪造 schema。 + +`verify_openapi.py` 增加递归断言: + +1. 总数为 `175 paths / 213 operations`; +2. 只新增 `/auth/refresh` 和 `/auth/logout`; +3. Refresh 请求体只含 `refreshToken`; +4. Logout 无 requestBody; +5. 普通安全方案说明只接受 Access Token; +6. schema 不包含 tokenHash、refreshTokenHash、session 数据库对象、phone、secret; +7. 登录原有字段不删除; +8. 不新增其他认证或管理接口。 + +前端文档必须明确: + +- 上线时旧 Token 全部失效; +- 普通请求只使用 Access; +- 401 时最多刷新一次; +- 刷新请求必须串行; +- 刷新失败清除 Token并重新登录; +- 登出后无条件清除本地 Token; +- 不记录或上报 Token。 + +## 二十一、验证顺序 + +先运行新增定向测试,按实际类名调整: + +```powershell +cd C:\Users\15642\Desktop\zhilai\mydreamjob-server +mvn -Dtest="JwtTokenTypeIsolationTest,JwtAuthenticationTypeIntegrationTest,AuthTokenHasherTest,AuthSessionServiceTest,AuthSessionPersistenceIntegrationTest,AuthRefreshControllerTest,AuthRefreshHttpIntegrationTest,AuthRefreshConcurrencyIntegrationTest,AuthSessionRevocationIntegrationTest,AuthTokenClockConsistencyTest,AuthTokenRedactionTest" test +``` + +再运行认证和注销关联回归: + +```powershell +mvn -Dtest="*Auth*Test,*Jwt*Test,*Security*Test,*Deletion*Test,*Sms*Test" test +``` + +最后执行: + +```powershell +mvn test +mvn clean verify +python ..\docs\verify_openapi.py +cd C:\Users\15642\Desktop\zhilai +git diff --check +git status --short +git diff --cached --name-only +git diff --name-only -- mydreamjob-server/src/main/resources/db/migration +``` + +要求: + +- Failures=0; +- Errors=0; +- Skipped=0; +- `mvn clean verify` BUILD SUCCESS; +- OpenAPI=`175/213`; +- 暂存区为空; +- 唯一新迁移为实际确认编号的 auth token migration; +- 未修改 V1–V41; +- 未 commit、未 push。 + +如果 clean verify 因正在运行的 JAR 锁定: + +- 查询实际 PID、端口和完整命令行; +- 不结束未知进程; +- 报告并等待负责人确认。 + +## 二十二、禁止通过的绕过方式 + +- 只给 Refresh 增加 claim,却不让 Filter 检查; +- 仍用同一签名密钥,只依赖字符串类型; +- 继续接受无 type 旧 Token; +- 把 Refresh Token 黑名单只放在 JVM 内存; +- 数据库存原始 JWT; +- 日志打印 Token 便于调试; +- 刷新后旧 Token仍有效; +- 并发刷新两次都成功; +- 登出只删前端 Token,不做服务端吊销; +- Access Token 不检查 session,导致登出后仍可用; +- 数据库异常时跳过 session 检查; +- 用 Mockito 模拟 Repository 代替全部并发和持久化证据; +- 修改测试期望值掩盖旧行为; +- 修改旧 Flyway; +- 为完成 S02 重构全仓身份解析或异常映射。 + +## 二十三、完成状态 + +编码和自动化全部通过后,将设计及路线图状态写为: + +```text +S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。 +``` + +不得写“已冻结”“已完成生产验收”或“S03 已开始”。 + +## 二十四、最终报告 + +必须包含: + +1. 修复前 Refresh Token 访问普通接口的复现; +2. Access/Refresh 的 claim 和密钥隔离; +3. 无 type 旧 Token 结果; +4. 登录响应兼容结果; +5. Token 摘要算法和数据库脱敏结果; +6. `auth_tokens` 迁移和实体变化; +7. 正常刷新与轮换结果; +8. 二次使用和重放处置; +9. 并发刷新结果; +10. 当前设备登出和多设备隔离; +11. 注销完成、用户禁用后的结果; +12. 单次 capturedNow 证据; +13. 日志、审计和完整 JSON 脱敏; +14. 数据库与外围副作用; +15. 新增接口和 OpenAPI 实际计数; +16. 专项、关联、全量测试数; +17. Failures、Errors、Skipped; +18. clean verify 结果; +19. 精确修改文件; +20. Git 状态; +21. 是否修改 V1–V41、ErrorCode 或其他业务接口; +22. 明确未执行 `git add -A`、commit、push; +23. 明确真实 MySQL/Docker 黑盒仍待独立验收。 + +任何失败或 SKIP 必须如实报告。完成后停止,不进入 S03。 diff --git a/docs/s02-independent-mysql-acceptance-20260914.md b/docs/s02-independent-mysql-acceptance-20260914.md new file mode 100644 index 0000000..a797ba2 --- /dev/null +++ b/docs/s02-independent-mysql-acceptance-20260914.md @@ -0,0 +1,202 @@ +# S02 独立代码复验与真实 MySQL/Docker 黑盒验收报告(脱敏版) + +- **验收日期**:2026-09-14 +- **分支**:`feat/s02-token-isolation` +- **验收对象**:S02「Access/Refresh Token 隔离、轮换与吊销」 +- **结论**:**PASS** —— *S02独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。* +- **本报告不含**:JWT 原文、Token 摘要全文、验证码、完整手机号、数据库口令、JWT 密钥、`.env` 内容、`Authorization` 请求头 + +--- + +## 1. Git 基线与工作区状态 + +| 项 | 实测 | +|---|---| +| 分支 | `feat/s02-token-isolation` | +| HEAD | `96a920b fix: harden resume upload safety` | +| 暂存区 | **空** | +| `git diff --check` | 无空白错误 | +| 工作区 | 63 个已修改 + 116 个未跟踪 | +| 唯一新增迁移 | `V42__auth_token_sessions.sql`(V1–V41 未修改) | +| 删除文件 | 0 | +| AGENTS.md | **未发现** | +| 静态 OpenAPI | 175 paths / 213 operations(`All checks passed!`) | + +## 2. 独立代码审查逐项结论 + +| 项 | 结论 | 关键证据 | +|---|---|---| +| A1 Token 类型隔离 | **PASS** | Access 用 `accessKey`、带 `type=access`;Refresh 用 `refreshKey`、带 `type=refresh`、无 `role`;摘要用独立 `tokenHashSecret`;三项密钥经 `requireDistinct` 强制互异;两类 Token 均不含 phone/email/nickname/resumeUrl | +| A2 JWT claims | **PASS** | `JwtTokenProvider.parse()` 统一校验 `type`/`iss`/`aud`/`exp`/`iat`,`requirePositiveSubject` 与 `requireCanonicalUuid` 校验 sub/sid/jti;时间契约 `iat <= capturedNow < exp`;异常统一 `InvalidTokenException`(固定文本,不含 Token/claim) | +| A3 服务端会话状态 | **PASS** | `JwtAuthenticationFilter` 验签后调用 `isSessionActive`:用户存在 + active + session 下存在未消费/未撤销/未过期记录;`session_id IS NULL` 永不命中;异常失败关闭;`createSession` 对 inactive 用户先抛异常(不先发 Token) | +| A4 刷新轮换事务 | **PASS** | `refresh()` 无 `@Transactional`(编排入口);`executeRefreshAttempt` 在独立事务;消费与写后继同事务;条件更新含 id/version/revoked/consumedAt/expiredAt;`consumed != 1` 即判定重放;重放信息经 `RefreshAttempt.Replayed` 返回而非抛异常;重放撤销在尝试事务**之后**执行;无 JVM 锁、无 `Thread.sleep` | +| A5 并发重放 | **PASS** | `revokeReplayedSession` 用 REQUIRES_NEW,逐轮 `revokeSession` 后**校验活动 Token 数**,最多重试 3 次(无 sleep),仍失败则抛出(不静默)。日志中已无 `could not take locks` 分支 | +| A6 logout | **PASS** | 只撤销当前 session;幂等;其他设备不受影响;测试未使用 `catch (Throwable)`,未在最终断言前补 logout | +| A7 Token 摘要与脱敏 | **PASS** | HMAC-SHA256 + 独立密钥;64 位小写十六进制;`MessageDigest.isEqual` 常量时间比较;`hash(null)` 与 `hash("")` 同值但生产路径在校验后才调用 | +| A8 capturedNow | **PASS** | 登录/刷新/登出每个入口只读一次 Clock;`created_at`/`expired_at`/`consumed_at`/`revoked_at`/审计均来自该次捕获;固定时区 `Asia/Shanghai` | +| A9 用户禁用与注销 | **PASS** | `createSession` 拒绝 inactive;注销完成在同一业务事务内 `desensitize()`(active=false)+ `revokeAllUserSessions`;回退状态不恢复已撤销 Token;其他用户不受影响 | +| A10 配置安全 | **PASS**(代码层)| 三项密钥缺失/过短/相同/issuer 空/audience 空均启动失败,错误只含**密钥名**不含值;旧 `JWT_SECRET` 不再强制解析;`CandidateKeyGenerator` 在空密钥时**先抛异常**,不产生可预测 key | + +**观察项(非阻断)**:`AppJwtProperties` 的校验规则没有专门的单元测试(设计 §六 曾要求)。本轮改用阶段 O 的真实 JAR 启动矩阵实证,覆盖更强;仍建议后续补一个配置校验单测。 + +## 3. Docker / MySQL / Redis / RabbitMQ + +| 组件 | 版本 | 状态 | +|---|---|---| +| Docker Engine | 29.6.2(linux/amd64) | 可用 | +| `mydreamjob-mysql` | MySQL **8.4.11** | healthy(127.0.0.1:3307) | +| `mydreamjob-redis` | redis:7.4-alpine | healthy | +| `mydreamjob-rabbitmq` | rabbitmq:3.13-management-alpine | healthy | + +验收数据库名:`mydreamjob`。未重建容器、未删除 volume、未打印 `.env` 或口令。 + +## 4–6. Flyway / 表结构 / 启动 + +**Flyway**:接受验收前最新版本为 V41;启动最新 JAR 后 **V42 `auth token sessions` success=1**,失败迁移 0。 + +**`auth_tokens` 真实表结构**(新增 7 列 + 3 索引,旧索引保留): + +``` +session_id VARCHAR(36) NULL token_jti VARCHAR(36) NULL +parent_token_id BIGINT NULL consumed_at / revoked_at DATETIME NULL +revocation_reason VARCHAR(40) version BIGINT NOT NULL DEFAULT 0 +UNIQUE uk_auth_tokens_token_jti (token_jti) +KEY idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at) +KEY idx_auth_tokens_user_session (user_id, session_id) +KEY idx_auth_tokens_user / idx_auth_tokens_hash (既有,保留) +``` + +无任何保存 Token 原文的列。 + +**启动**:`Started MyDreamjobApplication`(16.3 s),无 Flyway 错误、无 schema validation 错误;`GET /api/v1/health` → **HTTP 200 / status=UP**;运行时 OpenAPI 175/213。 + +## 7. 自动化实际测试数 + +| 命令 | Tests run | Failures | Errors | Skipped | BUILD | +|---|---|---|---|---|---| +| 原独立失败测试(2 类) | **3** | 0 | 0 | 0 | SUCCESS | +| S02 专项(13 类) | **84** | 0 | 0 | 0 | SUCCESS | +| 认证关联回归 | **208** | 0 | 0 | 0 | SUCCESS | +| `mvn test` | **1758** | 0 | 0 | 0 | SUCCESS | +| `mvn clean verify` | **1758** | 0 | 0 | 0 | SUCCESS(2:21) | + +(候选基线称"扩展专项 87/87" = 专项 84 + 独立 3,与实测一致。) + +## 8–9. Token claims 与类型矩阵(真实 HTTP) + +**claims**:登录响应字段兼容、`expiresIn=86400`、三段 JWT、两 Token 原文不同、jti 不同、sid 相同、type 正确、iss/aud 正确、Access 有 role、Refresh 无 role、均不含个人信息、有效期 24h/7d、均含 iat —— **全部 PASS**。 + +**类型矩阵**(每格记录 method / path / HTTP status / business code / requestId): + +| Token | `/auth/me` | `/auth/refresh` | +|---|---|---| +| Access | **200** | **401** | +| Refresh | **401** | **200**(并轮换) | +| 无 type 旧 Token | 401 | 401 | +| Access 用 Refresh 密钥伪造 | 401 | 401 | +| Refresh 用 Access 密钥伪造 | 401 | 401 | +| 过期 Token | 401 | 401 | +| 缺 iat | 401 | 401 | +| 未来 iat | 401 | 401 | +| 非法 sid | 401 | 401 | +| 非法 sub | 401 | 401 | + +错误响应均未泄漏 JWT 原文、`Bearer`、`token_hash`、sid、jti、手机号、密钥、异常类名或堆栈。 + +## 10–11. 正常轮换与串行重放 + +**轮换**:sid 不变、jti 变化、旧记录 `consumed_at` 非空、version 只 +1、新记录 `parent_token_id` 指向旧记录、同一时刻仅 1 个活动 Refresh、A2 可访问、R2 可再次刷新 —— 全部 PASS。 + +**串行重放**:R1 重放 → **401**;当前 session 活动 Refresh 数 **0**;A1 与 A2 访问 `/auth/me` 均 **401**;R2 刷新 **401**;其他设备 session **200**;写入一次重放安全审计。 + +## 12–13. 真实并发(10 轮)与胜出方 Access + +每轮两个独立 HTTP 客户端 + `threading.Barrier` 同时发起,**10/10 轮**全部满足: + +| 断言 | 实测 | +|---|---| +| 无 HTTP 500 | 10/10 | +| 最多一个刷新成功 | 10/10 | +| 失败方为 401 | 10/10 | +| 重放后活动 Refresh **严格为 0** | 10/10 | +| 原 Access 与**胜出方新 Access** 均失效 | 20 次断言全部满足 | +| 其他设备 session 始终 200 | 10/10 | +| 无锁异常 / 堆栈泄漏 | 10/10 | +| 日志无 `refresh reuse revocation could not take locks` | ✅ | +| 无同一 session 两个活动后继 | ✅ | + +## 14–16. 多设备 / 竞争 / inactive 与注销 + +- **logout**:第一次成功,Access 与 Refresh 随即 401;重复 logout 按既有幂等语义;其他设备不受影响。 +- **多设备隔离**:两设备 sessionId 不同;A 登出后 A 失效、B 的 Access 与 Refresh 均保持有效。 +- **logout 与 refresh 并发**:无 500;未补 logout 的情况下活动 Refresh 数为 0;竞争可能返回的新 Access 最终 401。 +- **inactive**:登录不返回 Token、不新增 `auth_tokens`;置为 inactive 后旧 Access/Refresh 均 401。 +- **注销完成**(走正式懒转换完成路径):状态 COMPLETED、用户 `active=false`、全部会话撤销、两台设备 Access/Refresh 均 401、其他用户完全不受影响。 + +## 17. Clock 与真实数据库时间 + +| 断言 | 实测 | +|---|---| +| `created_at` 与 Access `iat` 同一时刻 | ±1s 内一致 | +| `expired_at` 与 Refresh `exp` 同一时刻 | ±1s 内一致 | +| `consumed_at` 与刷新时刻 | ±1s 内一致 | +| `revoked_at` 与登出时刻 | ±5s 内一致 | +| AUTH 审计 `created_at` 与请求时刻 | ±120s 内一致 | + +## 18–19. 明文扫描 / 审计与日志脱敏 + +- `token_hash` 为 64 位小写十六进制,不等于 Refresh 原文;库中搜索两类 Token 原文命中 **0**。 +- 应用日志与 stdout 扫描:Token / 验证码 / 密钥命中 **0**;扫描器经**注入探针**验证有效。 +- 审计动作仅限 4 个白名单值;details 不含 Token、摘要、`Bearer`、手机号、jti、`Authorization`、密钥;不含原始 sid(用不可逆 `sessionRef`)。 + +## 20–22. 副作用 / RabbitMQ / OpenAPI + +- **数据库副作用**:登录+刷新+串行重放前后,`candidate_dimension_scores`、`messages`、`notifications`、`applications`、`talent_invitations` 内容摘要**零变化**(按 id 摘要比对,非仅 COUNT)。 +- **RabbitMQ**:4 个项目队列全部存在,`ready=0`、`unacked=0`;S02 运行期间无新增消息。 +- **OpenAPI**:静态与运行时均 **175 / 213**;`/auth/refresh` 请求体仅 `refreshToken`;`/auth/logout` 无请求体;公共 schema 不含 tokenHash/sessionId/phone/secret 等内部字段。 + +## 23. PASS / FAIL / SKIP 清单 + +| 阶段 | PASS | FAIL | SKIP | +|---|---|---|---| +| A 代码审查(10 项) | 10 | 0 | 0 | +| B 自动化(5 组) | 5 | 0 | 0 | +| C–E 环境/迁移/启动 | 8 | 0 | 0 | +| F–M 黑盒(含 10 轮并发) | **117** | 0 | 0 | +| N–S 时间/脱敏/副作用/外围/OpenAPI | **30** | 0 | 0 | +| O 配置启动矩阵(9 配置) | **18** | 0 | 0 | +| **合计** | **165** | **0** | **0** | + +## 24. 剩余风险 + +1. **重放撤销重试可能重复写审计**:`revokeReplayedSession` 在需要重试时会再写一对 `AUTH_REFRESH_REUSE_DETECTED` / `AUTH_SESSION_REVOKED`。本轮所有场景(含 10 轮并发)**重试次数为 0**,未观测到重复;属潜在项,建议后续把审计移到循环外。 +2. **`AppJwtProperties` 无专门单元测试**(见第 2 节观察项)。 +3. **旧 `JWT_SECRET` 仍被 `talentdiscovery.CandidateKeyGenerator` 使用**:本轮已确认空密钥时**安全失败**(抛异常、不产生可预测 key),但该配置项仍存在;建议后续为其单独引入专用密钥。 +4. 本轮**未执行**:真实短信供应商、Redis 会话缓存、设备指纹、Access 有效期缩短等 S02 明确非目标项。 +5. 验收数据保留在库(见第 26 节)。 + +## 25. 是否允许冻结 S02 + +**允许。** 24 项验收判据全部满足,无 FAIL、无核心项 SKIP,24 条"不得冻结"清单全部未触发。 + +--- + +## 26. 验收结束处理 + +- 本轮启动的 JAR(PID 21420,命令行含 `s02-acc-main-app.log`)**已停止**;8080 恢复空闲;未停止任何未知 Java 进程;未停止 Docker。 +- **验收数据保留**,未执行删除。理由:`audit_logs` 有 **143** 条 `AUTH_*` 行引用本轮验收用户且该表**无外键**,删除用户会产生孤儿审计行;而本任务边界禁止删除 `audit_logs`。精确足迹如下(供后续人工处置): + +| 范围 | 行数 | +|---|---| +| `users`(本轮 15:25 之后创建,199 前缀) | 35 | +| `auth_tokens`(这些用户) | 93 | +| `audit_logs` `AUTH_*`(这些用户) | 143 | +| `account_deletions`(这些用户) | 1 | + +> 注:库中另有 3 个 199 前缀用户属于 **S01 验收**(2026-09-14 09:48 与 2026-08-13),及其 2 条投递记录,**非本轮产生,未触碰**。 + +- 最终 Git:暂存区**空**、`git diff --check` 无错误、V1–V41 未修改、V42 为唯一新增迁移、未删除文件、**未** `git add -A` / commit / push、未进入 S03。 + +--- + +# S02 独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 0e29331..8313aba 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']) == 173, f"Expected 173 paths, got {len(d['paths'])}" +assert len(d['paths']) == 175, f"Expected 175 paths, got {len(d['paths'])}" paths = d['paths'] @@ -591,10 +591,51 @@ assert '/conversations' in paths and '/conversations/messages' in paths assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths print(' N07 recall path/schema validated; existing messaging operations preserved') -# 统计 operation 总数并校验 V43 + N07 计数 211 +# ── S02:Access/Refresh Token 隔离的两个新增 operation ───────────── +# 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除; +# 新 schema 不得暴露数据库字段或凭据。 +assert '/auth/refresh' in paths, 'Missing /auth/refresh' +assert '/auth/logout' in paths, 'Missing /auth/logout' +assert 'post' in paths['/auth/refresh'], '/auth/refresh must be POST' +assert 'post' in paths['/auth/logout'], '/auth/logout must be POST' + +_refresh_body = paths['/auth/refresh']['post']['requestBody']['content']['application/json']['schema'] +_refresh_req = resolve_schema(_refresh_body) +assert sorted(_refresh_req['properties']) == ['refreshToken'], \ + f"Refresh request body must only contain refreshToken, got {sorted(_refresh_req['properties'])}" + +assert 'requestBody' not in paths['/auth/logout']['post'], '/auth/logout must not declare requestBody' + +# 登录接口的静态契约不得被 S02 改动(请求/响应引用保持原样) +_login_post = paths['/auth/login']['post'] +assert _login_post['requestBody']['content']['application/json']['schema']['$ref'] == \ + '#/components/schemas/LoginRequest', 'login request schema changed' +assert _login_post['responses']['200']['content']['*/*']['schema']['$ref'] == \ + '#/components/schemas/ApiResponseMapStringObject', 'login response schema changed' +assert _login_post.get('operationId') == 'mockLogin', 'login operationId changed' + +_s02_text = json.dumps({ + 'refresh': paths['/auth/refresh'], + 'logout': paths['/auth/logout'], + 'schemas': {k: v for k, v in d['components']['schemas'].items() + if k in ('RefreshTokenRequest', 'TokenPairResponse', 'ApiResponseTokenPairResponse')}, +}, ensure_ascii=False) +for forbidden in ('tokenHash', 'refreshTokenHash', 'sessionId', 'revocationReason', + 'phone', 'secret', 'password', 'jwt'): + assert forbidden not in _s02_text, f'S02 contract exposes forbidden field {forbidden}' + +# 未新增其他认证或管理接口(认证相关路径只能是既有集合 + 这两个) +_auth_paths = {p for p in paths if p.startswith('/auth')} +assert _auth_paths == { + '/auth/sms/send', '/auth/login', '/auth/me', + '/auth/refresh', '/auth/logout', +}, f'Unexpected /auth paths: {sorted(_auth_paths)}' +print(' S02 refresh/logout operations and isolation schemas validated') + +# 统计 operation 总数并校验 V43 + N07 + S02 计数 213 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 211, f"Expected 211 operations, got {operations}" +assert operations == 213, f"Expected 213 operations, got {operations}" 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/audit/AuditLog.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java index aa0968b..8c11813 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java @@ -50,7 +50,10 @@ public class AuditLog { @Column(name = "request_id", length = 32) private String requestId; - @CreatedDate + // S02:不再使用 @CreatedDate。 + // Spring Data 审计会在持久化时用“当前时间”覆盖显式设置的值,而 S02 认证入口 + // (登录/刷新/登出/重放处置/全局吊销)要求审计时间与入口单次捕获的 capturedNow 同源。 + // 所有写入方(AuditService、SecurityAuditService)本就显式设置 createdAt,移除后行为不变。 @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java index 493e525..01ff687 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java @@ -22,7 +22,8 @@ public class AuditService { public void log(Long userId, String action, String entityType, Long entityId, String details, String ipAddress, String userAgent, String requestId) { logWithContext(userId, action, entityType, entityId, details, - AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build()); + AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build(), + LocalDateTime.now()); } /** @@ -30,11 +31,22 @@ public class AuditService { */ public void log(Long userId, String action, String entityType, Long entityId, String details, AuditContext context) { - logWithContext(userId, action, entityType, entityId, details, context); + logWithContext(userId, action, entityType, entityId, details, context, LocalDateTime.now()); + } + + /** + * 记录一个审计事件(显式时间戳版本,S02)。 + * + *

供 S02 认证入口使用:登录、刷新、登出、重放处置与全局吊销在各自入口 + * 只捕获一次 {@code capturedNow},审计时间必须与之同源,不得再次读时。

+ */ + public void log(Long userId, String action, String entityType, Long entityId, + String details, AuditContext context, LocalDateTime capturedAt) { + logWithContext(userId, action, entityType, entityId, details, context, capturedAt); } private void logWithContext(Long userId, String action, String entityType, Long entityId, - String details, AuditContext ctx) { + String details, AuditContext ctx, LocalDateTime createdAt) { try { // 脱敏:details 中不得包含 code/password/token/secret 等关键词的值 String safeDetails = sanitize(details); @@ -47,7 +59,7 @@ public class AuditService { .ipAddress(ctx != null ? ctx.getIpAddress() : null) .userAgent(ctx != null ? ctx.getUserAgent() : null) .requestId(ctx != null ? ctx.getRequestId() : null) - .createdAt(LocalDateTime.now()) + .createdAt(createdAt) .build(); auditLogRepository.save(auditLog); } catch (Exception e) { @@ -73,6 +85,32 @@ public class AuditService { log(userId, "LOGOUT", "USER", userId, "User logged out", ctx); } + /** S02:登录会话创建,使用显式 capturedNow。 */ + public void logSessionCreated(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) { + log(userId, "AUTH_SESSION_CREATED", "AUTH_SESSION", null, + "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); + } + + /** S02:Refresh Token 轮换成功,使用显式 capturedNow。 */ + public void logTokenRefreshed(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) { + log(userId, "AUTH_TOKEN_REFRESHED", "AUTH_SESSION", null, + "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); + } + + /** S02:会话被撤销(登出、重放处置或全局吊销)。 */ + public void logSessionRevoked(Long userId, String sessionRef, String reason, + AuditContext ctx, LocalDateTime capturedAt) { + log(userId, "AUTH_SESSION_REVOKED", "AUTH_SESSION", null, + "{\"sessionRef\":\"" + sessionRef + "\",\"reason\":\"" + reason + "\"}", ctx, capturedAt); + } + + /** S02:检测到 Refresh Token 重放。 */ + public void logRefreshReuseDetected(Long userId, String sessionRef, + AuditContext ctx, LocalDateTime capturedAt) { + log(userId, "AUTH_REFRESH_REUSE_DETECTED", "AUTH_SESSION", null, + "{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt); + } + /** * 脱敏:不记录可能的敏感字段值 */ diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java new file mode 100644 index 0000000..214cffa --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java @@ -0,0 +1,17 @@ +package com.mydreamjob.auth; + +import java.time.Instant; + +/** + * 已通过完整校验的 Access Token 声明(S02)。 + * + *

强类型只读视图:调用方不再接触 JJWT 的 Claims 对象,也不依赖 claim 名。

+ */ +public record AccessTokenClaims( + Long userId, + String sessionId, + String tokenId, + String role, + Instant issuedAt, + Instant expiresAt) { +} 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 66357a4..75ca01d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java @@ -1,5 +1,6 @@ package com.mydreamjob.auth; +import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; @@ -12,25 +13,34 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; +import java.time.Instant; import java.util.*; /** - * Mock 认证服务 - 仅用于开发/测试环境登录原型 + * 认证服务(S02):手机号验证码登录,并编排刷新与登出。 + * + *

登录成功后创建服务端登录会话;Access/Refresh 由各自的密钥签发, + * 数据库只保存 Refresh Token 的 HMAC 摘要。

*/ @Slf4j @Service @RequiredArgsConstructor public class AuthService { + /** 登录与刷新响应中的 expiresIn 秒数,保持既有契约不变(设计 D04)。 */ + public static final long EXPIRES_IN_SECONDS = 86400L; + private final UserRepository userRepository; - private final JwtTokenProvider jwtTokenProvider; private final SmsVerificationService smsVerificationService; private final AuditService auditService; private final AccountDeletionRepository accountDeletionRepository; private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; + private final AuthSessionService authSessionService; + private final Clock clock; /** - * Mock 登录:验证短信验证码后自动创建/登录用户 + * Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。 */ @Transactional public Map mockLogin(String phone, String code, String purpose, @@ -64,28 +74,55 @@ public class AuthService { } // 已注销(COMPLETED)用户禁止登录 - java.util.Optional deletion = accountDeletionRepository.findByUserId(user.getId()); + Optional deletion = accountDeletionRepository.findByUserId(user.getId()); if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) { throw new BusinessException(ErrorCode.ACCOUNT_DELETED); } - // 生成 token - String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name())); - String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone()); + // 认证入口只取一次时间,后续 JWT iat/exp、数据库时间与审计全部同源。 + Instant capturedNow = clock.instant(); + AuditContext ctx = AuditContext.builder() + .requestId(requestId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .build(); + + // 会话记录必须与登录在同一事务内持久化;失败则整个登录失败,不返回 Token。 + AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, capturedNow, ctx); - auditService.logLogin(user.getId(), ipAddress, userAgent, requestId); + auditService.log(user.getId(), "LOGIN", "USER", user.getId(), "User logged in", ctx, toLocal(capturedNow)); Map result = new HashMap<>(); result.put("userId", user.getId()); result.put("phone", user.getPhone()); result.put("nickname", user.getNickname()); result.put("role", user.getRole()); - result.put("accessToken", accessToken); - result.put("refreshToken", refreshToken); - result.put("expiresIn", 86400); + result.put("accessToken", tokens.accessToken()); + result.put("refreshToken", tokens.refreshToken()); + result.put("expiresIn", EXPIRES_IN_SECONDS); return result; } + /** + * 刷新:消费并轮换 Refresh Token。 + * + * @throws InvalidTokenException 任何失败(统一对外 401) + */ + public AuthSessionService.SessionTokens refresh(String refreshToken, + String requestId, String ipAddress, String userAgent) { + Instant capturedNow = clock.instant(); + return authSessionService.refresh(refreshToken, capturedNow, context(requestId, ipAddress, userAgent)); + } + + /** + * 登出:撤销当前会话(幂等)。 + */ + public void logout(Long userId, String sessionId, + String requestId, String ipAddress, String userAgent) { + Instant capturedNow = clock.instant(); + authSessionService.logout(userId, sessionId, capturedNow, context(requestId, ipAddress, userAgent)); + } + /** * 获取当前用户信息 */ @@ -105,4 +142,16 @@ public class AuthService { result.put("createdAt", user.getCreatedAt()); return result; } + + private AuditContext context(String requestId, String ipAddress, String userAgent) { + return AuditContext.builder() + .requestId(requestId) + .ipAddress(ipAddress) + .userAgent(userAgent) + .build(); + } + + private static java.time.LocalDateTime toLocal(Instant instant) { + return AuthSessionService.toLocal(instant); + } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java new file mode 100644 index 0000000..e0348d3 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java @@ -0,0 +1,332 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.audit.AuditContext; +import com.mydreamjob.audit.AuditService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/** + * S02 登录会话服务:创建会话、校验会话、轮换与消费 Refresh Token、撤销会话。 + * + *

并发正确性完全由数据库条件更新保证({@link AuthTokenRepository#consumeIfCurrent}), + * 不使用任何 JVM 本地锁。

+ * + *

时间一律由调用方(认证入口)传入的 {@code capturedNow} 决定,本类不自行取时。

+ */ +@Slf4j +@Service +public class AuthSessionService { + + /** 固定业务时区(设计 D13)。 */ + public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + /** 撤销原因白名单(设计 D12 / 第十二节)。 */ + public static final String REASON_LOGOUT = "USER_LOGOUT"; + public static final String REASON_REPLAY = "REFRESH_REUSE_DETECTED"; + public static final String REASON_ACCOUNT_DELETED = "ACCOUNT_DELETED"; + public static final String REASON_USER_DISABLED = "USER_DISABLED"; + + private final AuthTokenRepository authTokenRepository; + private final AuthTokenHasher authTokenHasher; + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final AuditService auditService; + /** 刷新尝试:消费旧 Token + 写入后继 Token 必须在同一事务内完成。 */ + private final TransactionTemplate refreshTransaction; + /** 重放撤销:必须在刷新尝试事务**结束之后**独立提交,避免与并发胜出方互相持锁。 */ + private final TransactionTemplate replayTransaction; + + public AuthSessionService(AuthTokenRepository authTokenRepository, + AuthTokenHasher authTokenHasher, + JwtTokenProvider jwtTokenProvider, + UserRepository userRepository, + AuditService auditService, + PlatformTransactionManager transactionManager) { + this.authTokenRepository = authTokenRepository; + this.authTokenHasher = authTokenHasher; + this.jwtTokenProvider = jwtTokenProvider; + this.userRepository = userRepository; + this.auditService = auditService; + this.refreshTransaction = new TransactionTemplate(transactionManager); + this.refreshTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); + this.replayTransaction = new TransactionTemplate(transactionManager); + this.replayTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + /** 刷新尝试的结果:成功(携带新 Token 对)或识别为重放。 */ + private sealed interface RefreshAttempt { + record Succeeded(SessionTokens tokens) implements RefreshAttempt { + } + + record Replayed(Long userId, String sessionId) implements RefreshAttempt { + } + } + + /** 一次会话签发的结果。Token 原文只在内存中短暂存在。 */ + public record SessionTokens(String accessToken, String refreshToken, String sessionId) { + } + + // ── 登录:创建会话 ─────────────────────────────────────────── + + /** + * 创建一次登录会话并签发 Access/Refresh Token。 + * + *

必须运行在调用方事务内:会话记录持久化失败时,整个登录事务失败,不得返回 Token。

+ */ + @Transactional + public SessionTokens createSession(User user, Instant capturedNow, AuditContext ctx) { + // 失败关闭:不给不可用用户创建活动会话,更不返回 Token。 + // 不能先发 Token 再由过滤器拒绝 —— 那样 Token 已经流出去了。 + if (user == null || !isUsable(user)) { + throw new InvalidTokenException(); + } + String sessionId = UUID.randomUUID().toString(); + String accessJti = UUID.randomUUID().toString(); + String refreshJti = UUID.randomUUID().toString(); + + IssuedToken access = jwtTokenProvider.generateAccessToken( + user.getId(), user.getRole().name(), sessionId, accessJti, capturedNow); + IssuedToken refresh = jwtTokenProvider.generateRefreshToken( + user.getId(), sessionId, refreshJti, capturedNow); + + authTokenRepository.save(AuthToken.builder() + .userId(user.getId()) + .tokenHash(authTokenHasher.hash(refresh.token())) + .tokenType(AuthToken.TYPE_REFRESH) + .expiredAt(toLocal(refresh.expiresAt())) + .revoked(false) + .sessionId(sessionId) + .tokenJti(refreshJti) + // created_at 必须与入口捕获的 capturedNow 同源,不能由审计读取系统时间。 + .createdAt(toLocal(capturedNow)) + .build()); + + auditService.logSessionCreated(user.getId(), sessionRef(sessionId), ctx, toLocal(capturedNow)); + return new SessionTokens(access.token(), refresh.token(), sessionId); + } + + // ── 校验:Access 所属会话是否可用(失败关闭)────────────────── + + /** + * Access Token 的服务端会话校验(设计 D10)。任一条不满足即返回 {@code false}: + * 会话属于该用户、存在未消费未撤销未过期的 Refresh 记录、用户存在且 active。 + * + *

关于账号注销:这里刻意只检查 {@code active},不单独判定注销记录的 + * COMPLETED 状态。账号注销完成时 {@code AccountDeletionCompletionWorker} 会 + * 1) 脱敏并将用户置为 inactive,2) 撤销该用户全部会话 —— 两道处理叠加后, + * 真实注销用户的旧 Token 无论走哪条路径都会失败。把 COMPLETED 的 409/9303 语义 + * 保留给 {@code DeletionGuardInterceptor},可避免改变既有注销端点契约。

+ * + *

任何数据库/运行时异常一律**失败关闭**,不建立认证。

+ */ + public boolean isSessionActive(Long userId, String sessionId, Instant capturedNow) { + if (userId == null || sessionId == null || sessionId.isBlank()) { + return false; + } + try { + if (!isUserUsable(userId)) { + // 固定安全文本:不包含 userId、sessionId、Token 或 claim。 + log.warn("session validation rejected: user not usable"); + return false; + } + if (authTokenRepository.countActiveSessionTokens(userId, sessionId, toLocal(capturedNow)) <= 0) { + log.warn("session validation rejected: no active session"); + return false; + } + return true; + } catch (Exception e) { + // 固定安全文本:不回显异常消息、userId、sessionId 或 Token。 + log.warn("session validation failed"); + return false; + } + } + + // ── 刷新:消费并轮换 ───────────────────────────────────────── + + /** + * 刷新编排入口。**本方法不运行在事务中** —— 这一点是并发重放处置正确性的前提: + * 刷新尝试事务必须先结束(释放其持有的行锁),重放撤销才能在无竞争的事务里生效, + * 从而覆盖并发胜出方刚写入的后继 Token。 + * + * @throws InvalidTokenException 任何失败,包括重放 + */ + public SessionTokens refresh(String rawRefreshToken, Instant capturedNow, AuditContext ctx) { + // 校验与摘要不访问数据库,放在事务外,异常时仍能拿到 sessionId 用于重放撤销。 + RefreshTokenClaims claims = jwtTokenProvider.parseAndValidateRefreshToken(rawRefreshToken, capturedNow); + String hash = authTokenHasher.hash(rawRefreshToken); + + RefreshAttempt attempt; + try { + attempt = refreshTransaction.execute( + status -> executeRefreshAttempt(claims, hash, capturedNow, ctx)); + } catch (DataAccessException e) { + // 并发的另一次刷新正持有该记录的行锁。本次刷新无法完成, + // 按重放处置并撤销整个会话(失败关闭),对调用方仍是统一的 401。 + revokeReplayedSession(claims.userId(), claims.sessionId(), ctx, capturedNow); + throw new InvalidTokenException(); + } + + if (attempt instanceof RefreshAttempt.Replayed replayed) { + // 此时刷新尝试事务已经结束:胜出方要么已提交、要么已被本事务回滚释放, + // 撤销事务不会再与之互相持锁。 + revokeReplayedSession(replayed.userId(), replayed.sessionId(), ctx, capturedNow); + throw new InvalidTokenException(); + } + + return ((RefreshAttempt.Succeeded) attempt).tokens(); + } + + /** + * 一次刷新尝试:校验记录、原子消费旧 Token、写入后继 Token,全部在同一事务内。 + * + *

识别为重放时**不抛异常**,而是返回 {@link RefreshAttempt.Replayed} —— + * 否则事务回滚会连带丢失撤销所需的会话信息。

+ */ + private RefreshAttempt executeRefreshAttempt(RefreshTokenClaims claims, String hash, + Instant capturedNow, AuditContext ctx) { + AuthToken record = authTokenRepository.findTopByTokenHashOrderByIdDesc(hash) + .orElseThrow(InvalidTokenException::new); + + // 声明必须与数据库记录一致,否则视为不可信。 + if (!Objects.equals(record.getUserId(), claims.userId()) + || !Objects.equals(record.getSessionId(), claims.sessionId()) + || !Objects.equals(record.getTokenJti(), claims.tokenId())) { + throw new InvalidTokenException(); + } + + User user = requireUsableUser(claims.userId()); + + int consumed = authTokenRepository.consumeIfCurrent( + record.getId(), record.getVersion(), toLocal(capturedNow), toLocal(capturedNow)); + + if (consumed != 1) { + // 已消费 / 已撤销 / 已过期:交回编排入口做重放处置。 + return new RefreshAttempt.Replayed(record.getUserId(), record.getSessionId()); + } + + String newAccessJti = UUID.randomUUID().toString(); + String newRefreshJti = UUID.randomUUID().toString(); + + IssuedToken access = jwtTokenProvider.generateAccessToken( + user.getId(), user.getRole().name(), claims.sessionId(), newAccessJti, capturedNow); + IssuedToken refresh = jwtTokenProvider.generateRefreshToken( + user.getId(), claims.sessionId(), newRefreshJti, capturedNow); + + authTokenRepository.save(AuthToken.builder() + .userId(user.getId()) + .tokenHash(authTokenHasher.hash(refresh.token())) + .tokenType(AuthToken.TYPE_REFRESH) + .expiredAt(toLocal(refresh.expiresAt())) + .revoked(false) + .sessionId(claims.sessionId()) + .tokenJti(newRefreshJti) + .parentTokenId(record.getId()) + // created_at 与入口捕获的 capturedNow 同源。 + .createdAt(toLocal(capturedNow)) + .build()); + + auditService.logTokenRefreshed(user.getId(), sessionRef(claims.sessionId()), ctx, toLocal(capturedNow)); + return new RefreshAttempt.Succeeded( + new SessionTokens(access.token(), refresh.token(), claims.sessionId())); + } + + /** + * 重放处置:在独立 REQUIRES_NEW 事务中撤销该会话全部记录并写脱敏审计, + * 随后**校验该会话已无活动 Token**(含并发胜出方的后继 Token)。 + * + *

撤销失败不做吞掉处理:先做有限次重试(无 sleep),仍失败则让异常向外传播 —— + * 宁可暴露基础设施故障,也不能留下一个可继续使用的会话。

+ */ + private void revokeReplayedSession(Long userId, String sessionId, AuditContext ctx, Instant capturedNow) { + if (sessionId == null) { + return; + } + for (int attempt = 0; attempt < MAX_REPLAY_REVOKE_ATTEMPTS; attempt++) { + replayTransaction.execute(status -> { + authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_REPLAY); + auditService.logRefreshReuseDetected(userId, sessionRef(sessionId), ctx, toLocal(capturedNow)); + auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_REPLAY, ctx, toLocal(capturedNow)); + return null; + }); + if (authTokenRepository.countActiveSessionTokens(userId, sessionId, toLocal(capturedNow)) <= 0) { + return; + } + // 胜出方的后继 Token 可能在本轮撤销之后才提交,重试一轮覆盖它。 + // 固定安全文本:不记录 userId、sessionId 或 Token。 + log.warn("replayed session still had active tokens; retrying revocation"); + } + throw new IllegalStateException("replayed session could not be revoked"); + } + + /** 重放撤销的重试上限(无 sleep;正常情况下第一轮即成功)。 */ + private static final int MAX_REPLAY_REVOKE_ATTEMPTS = 3; + + // ── 登出:撤销当前会话 ─────────────────────────────────────── + + /** + * 撤销当前会话(幂等)。重复登出不报错,也不影响其他设备会话。 + */ + @Transactional + public void logout(Long userId, String sessionId, Instant capturedNow, AuditContext ctx) { + if (userId == null || sessionId == null || sessionId.isBlank()) { + throw new InvalidTokenException(); + } + authTokenRepository.revokeSession(userId, sessionId, toLocal(capturedNow), REASON_LOGOUT); + auditService.logSessionRevoked(userId, sessionRef(sessionId), REASON_LOGOUT, ctx, toLocal(capturedNow)); + } + + // ── 全局吊销 ───────────────────────────────────────────────── + + /** + * 撤销该用户全部会话(账号注销完成、用户禁用等)。 + * + * @return 受影响行数 + */ + @Transactional + public int revokeAllUserSessions(Long userId, String reason, Instant capturedNow) { + if (userId == null) { + return 0; + } + return authTokenRepository.revokeAllForUser(userId, toLocal(capturedNow), reason); + } + + // ── 内部工具 ───────────────────────────────────────────────── + + private User requireUsableUser(Long userId) { + return userRepository.findById(userId) + .filter(this::isUsable) + .orElseThrow(InvalidTokenException::new); + } + + private boolean isUserUsable(Long userId) { + Optional user = userRepository.findById(userId); + return user.isPresent() && isUsable(user.get()); + } + + private boolean isUsable(User user) { + return user.getActive() != null && user.getActive(); + } + + /** + * 审计用的会话标识:sessionId 的 HMAC 截断,不可逆且不泄露原值。 + */ + String sessionRef(String sessionId) { + return authTokenHasher.hash("sid:" + sessionId).substring(0, 16); + } + + static LocalDateTime toLocal(Instant instant) { + return LocalDateTime.ofInstant(instant, BUSINESS_ZONE); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java index 02fe3a7..344e0f2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java @@ -5,13 +5,22 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; /** - * JWT Token 实体(用于 token 吊销和刷新) + * 登录会话中的 Refresh Token 记录(S02)。 + * + *

字段含义(S02 固定):

+ *
    + *
  • {@code tokenHash} —— **Refresh Token 原文的 HMAC-SHA256 摘要**,绝不保存原文;
  • + *
  • {@code tokenType} —— 新记录固定 {@code REFRESH};
  • + *
  • {@code expiredAt} —— Refresh Token 到期时间;
  • + *
  • {@code revoked} —— 兼容既有布尔语义,与 {@code revokedAt} 同步维护;
  • + *
  • {@code refreshTokenHash} —— 旧字段保留,S02 新逻辑不依赖。
  • + *
+ * + *

旧数据({@code sessionId IS NULL})永远不被新鉴权逻辑视为活动会话。

*/ @Data @Builder @@ -19,9 +28,11 @@ import java.time.LocalDateTime; @AllArgsConstructor @Entity @Table(name = "auth_tokens") -@EntityListeners(AuditingEntityListener.class) public class AuthToken { + /** Token 类型:S02 起 auth_tokens 只保存 Refresh 记录。 */ + public static final String TYPE_REFRESH = "REFRESH"; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -34,7 +45,7 @@ public class AuthToken { @Column(name = "token_type", nullable = false, length = 20) @Builder.Default - private String tokenType = "ACCESS"; + private String tokenType = TYPE_REFRESH; @Column(name = "expired_at", nullable = false) private LocalDateTime expiredAt; @@ -46,7 +57,32 @@ public class AuthToken { @Column(name = "refresh_token_hash", length = 255) private String refreshTokenHash; - @CreatedDate + @Column(name = "session_id", length = 36) + private String sessionId; + + @Column(name = "token_jti", length = 36) + private String tokenJti; + + @Column(name = "parent_token_id") + private Long parentTokenId; + + @Column(name = "consumed_at") + private LocalDateTime consumedAt; + + @Column(name = "revoked_at") + private LocalDateTime revokedAt; + + @Column(name = "revocation_reason", length = 40) + private String revocationReason; + + @Version + @Column(name = "version", nullable = false) + @Builder.Default + private Long version = 0L; + + // S02:不使用 @CreatedDate —— Spring Data 审计会在持久化时用“当前时间”覆盖显式值, + // 导致 created_at 与认证入口捕获的 capturedNow 不同源。生产写入路径只有 + // AuthSessionService(创建会话、轮换后继)两处,均显式设置 createdAt。 @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java new file mode 100644 index 0000000..ea26933 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java @@ -0,0 +1,71 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AppJwtProperties; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Refresh Token 摘要器(S02)。 + * + *

数据库只保存 {@code HMAC-SHA256(token-hash-secret, rawToken)} 的十六进制表示, + * 不保存 Token 原文,也不使用可逆加密或无密钥的裸摘要。

+ * + *

本类**不记录**输入、输出或异常原值。比较使用常量时间比较。

+ */ +@Component +public class AuthTokenHasher { + + private static final String ALGORITHM = "HmacSHA256"; + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private final byte[] key; + + public AuthTokenHasher(AppJwtProperties properties) { + this.key = properties.getTokenHashSecret().getBytes(StandardCharsets.UTF_8); + } + + /** + * 计算摘要,返回固定 64 字符的小写十六进制字符串。 + * + * @param rawToken Token 原文;{@code null} 视为空串以保证稳定输出 + */ + public String hash(String rawToken) { + byte[] input = (rawToken == null ? "" : rawToken).getBytes(StandardCharsets.UTF_8); + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(new SecretKeySpec(key, ALGORITHM)); + return toHex(mac.doFinal(input)); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + // 固定安全文本:不回显输入、输出或异常原值。 + throw new IllegalStateException("token hash unavailable"); + } + } + + /** + * 常量时间比较两个摘要。任一为 {@code null} 时返回 {@code false}。 + */ + public boolean matches(String expectedHash, String actualHash) { + if (expectedHash == null || actualHash == null) { + return false; + } + return MessageDigest.isEqual( + expectedHash.getBytes(StandardCharsets.UTF_8), + actualHash.getBytes(StandardCharsets.UTF_8)); + } + + private static String toHex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xFF; + out[i * 2] = HEX[v >>> 4]; + out[i * 2 + 1] = HEX[v & 0x0F]; + } + return new String(out); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java index b5e74ae..d16ef71 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java @@ -1,12 +1,21 @@ package com.mydreamjob.auth; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +/** + * auth_tokens 数据访问(S02)。 + * + *

并发消费与撤销**一律通过带条件的原子 UPDATE** 完成,并严格检查受影响行数; + * 禁止以 {@code findById -> set -> save} 作为并发保证。

+ */ @Repository public interface AuthTokenRepository extends JpaRepository { @@ -17,4 +26,94 @@ public interface AuthTokenRepository extends JpaRepository { Optional findByRefreshTokenHash(String refreshTokenHash); List findByExpiredAtBefore(LocalDateTime expiredAt); + + // ── S02 会话查询 ──────────────────────────────────────────── + + /** + * 按摘要取候选 Refresh 记录(同摘要理论上唯一,取最新一条以防历史重复)。 + */ + Optional findTopByTokenHashOrderByIdDesc(String tokenHash); + + /** + * 会话是否活动:同一 user + session 下存在未消费、未撤销且未过期的 Refresh 记录。 + * + *

旧数据 {@code sessionId IS NULL} 永远不会命中。

+ */ + @Query(""" + SELECT COUNT(t) FROM AuthToken t + WHERE t.userId = :userId + AND t.sessionId = :sessionId + AND t.revoked = false + AND t.consumedAt IS NULL + AND t.expiredAt > :capturedNow + """) + long countActiveSessionTokens(@Param("userId") Long userId, + @Param("sessionId") String sessionId, + @Param("capturedNow") LocalDateTime capturedNow); + + List findByUserIdAndSessionIdOrderByIdAsc(Long userId, String sessionId); + + // ── S02 原子条件更新(返回值必须被严格检查)──────────────────── + + /** + * 原子消费一条 Refresh 记录。只有满足以下全部条件才会成功: + * id、expectedVersion、未撤销、未消费、未过期。 + * + * @return 受影响行数;0 表示并发冲突、已被消费、已被撤销或已过期 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE AuthToken t + SET t.consumedAt = :consumedAt, + t.version = t.version + 1 + WHERE t.id = :id + AND t.version = :expectedVersion + AND t.revoked = false + AND t.consumedAt IS NULL + AND t.expiredAt > :capturedNow + """) + int consumeIfCurrent(@Param("id") Long id, + @Param("expectedVersion") Long expectedVersion, + @Param("consumedAt") LocalDateTime consumedAt, + @Param("capturedNow") LocalDateTime capturedNow); + + /** + * 撤销某用户某个会话下所有未撤销记录。 + * + * @return 受影响行数 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE AuthToken t + SET t.revoked = true, + t.revokedAt = :revokedAt, + t.revocationReason = :reason, + t.version = t.version + 1 + WHERE t.userId = :userId + AND t.sessionId = :sessionId + AND t.revoked = false + """) + int revokeSession(@Param("userId") Long userId, + @Param("sessionId") String sessionId, + @Param("revokedAt") LocalDateTime revokedAt, + @Param("reason") String reason); + + /** + * 撤销某用户全部未撤销记录(账号注销完成、用户禁用等全局安全事件)。 + * + * @return 受影响行数 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE AuthToken t + SET t.revoked = true, + t.revokedAt = :revokedAt, + t.revocationReason = :reason, + t.version = t.version + 1 + WHERE t.userId = :userId + AND t.revoked = false + """) + int revokeAllForUser(@Param("userId") Long userId, + @Param("revokedAt") LocalDateTime revokedAt, + @Param("reason") String reason); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java new file mode 100644 index 0000000..492f3f5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java @@ -0,0 +1,16 @@ +package com.mydreamjob.auth; + +/** + * Token 校验失败(S02)。 + * + *

异常消息固定为安全文本,**绝不包含 Token 原文、claim 原值或摘要**。 + * 对外一律表现为统一的 401,不区分“不存在”“已消费”“已撤销”或“重放”。

+ */ +public class InvalidTokenException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public InvalidTokenException() { + super("invalid token"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java new file mode 100644 index 0000000..80338e7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java @@ -0,0 +1,17 @@ +package com.mydreamjob.auth; + +import java.time.Instant; + +/** + * 一次 Token 签发的结果(S02)。 + * + *

{@code token} 为原始 JWT 原文,只在内存中短暂存在;调用方不得将其写入 + * 数据库、日志、审计或错误响应。

+ */ +public record IssuedToken( + String token, + String sessionId, + String tokenId, + Instant issuedAt, + Instant expiresAt) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java index ffbb21a..700b27a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java @@ -9,16 +9,28 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.time.Clock; +import java.time.Instant; import java.util.Collections; /** - * JWT 认证过滤器 + * S02 认证过滤器:只接受 Access Token。 + * + *

与修复前的关键差异:

+ *
    + *
  • 只调用 {@link JwtTokenProvider#parseAndValidateAccessToken}, + * Refresh Token、无 {@code type}、未知 {@code type}、错误 issuer/audience 一律拒绝;
  • + *
  • 验签之后还必须通过服务端会话校验(会话存在、未消费、未撤销、未过期, + * 且用户 active、注销未完成);
  • + *
  • 任何失败都不建立部分认证,并清理 SecurityContext;
  • + *
  • 数据库或会话校验异常时**失败关闭**,绝不放行;
  • + *
  • 日志只用固定安全文本,不使用异常 {@code getMessage()},不回显 Token、subject、sid、jti。
  • + *
*/ @Slf4j @Component @@ -26,32 +38,47 @@ import java.util.Collections; public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; + private final AuthSessionService authSessionService; + private final Clock clock; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = extractToken(request); - if (token != null && tokenProvider.validateToken(token)) { - try { - Long userId = tokenProvider.parseUserId(token); - if (userId == null || userId <= 0) { - // 非法主体一律不建立认证;不回显 subject - log.warn("JWT authentication rejected: invalid subject"); - } else { - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - log.debug("Authenticated user {} from JWT", userId); - } - } catch (Exception e) { - // 固定安全文本:异常消息可能包含非法 principal 原文,一律不回显 - log.warn("JWT authentication rejected: unparsable subject"); - } + if (token != null) { + authenticate(request, token); } filterChain.doFilter(request, response); } + private void authenticate(HttpServletRequest request, String token) { + try { + Instant capturedNow = clock.instant(); + AccessTokenClaims claims = tokenProvider.parseAndValidateAccessToken(token, capturedNow); + + if (!authSessionService.isSessionActive(claims.userId(), claims.sessionId(), capturedNow)) { + SecurityContextHolder.clearContext(); + log.warn("JWT authentication rejected: session not active"); + return; + } + + // 资源权限仍以后续服务层校验为准,这里只建立“已认证用户”身份。 + // principal 保持为 userId(既有约 40 处 Long.valueOf(auth.getName()) 依赖该形态), + // 已校验的 sessionId 通过 details 传递,供 /auth/logout 使用。 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(claims.userId(), null, Collections.emptyList()); + authentication.setDetails(new SessionAuthenticationDetails(request, claims.sessionId())); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (InvalidTokenException e) { + // 固定安全文本:不区分过期/类型错误/签名错误,也不回显任何 claim 原值。 + SecurityContextHolder.clearContext(); + } catch (Exception e) { + // 会话校验或数据库异常:失败关闭。 + SecurityContextHolder.clearContext(); + log.warn("JWT authentication rejected: internal check failed"); + } + } + @Nullable private String extractToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java index 7da354d..d820a50 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java @@ -1,84 +1,207 @@ package com.mydreamjob.auth; -import io.jsonwebtoken.*; +import com.mydreamjob.config.AppJwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.Date; -import java.util.Map; +import java.util.UUID; /** - * JWT 工具类 + * S02 Access/Refresh Token 签发与校验。 + * + *

核心变更:

+ *
    + *
  • Access 与 Refresh 使用**不同签名密钥**,交叉验签必然失败;
  • + *
  • 强制校验 {@code type}、{@code iss}、{@code aud},不再用“验签通过即有效”判断用途;
  • + *
  • 新 Token 不再包含手机号;
  • + *
  • {@code iat}/{@code exp} 一律来自调用方传入的 {@code capturedNow},本类不自行取时;
  • + *
  • 过期边界固定为 {@code exp <= capturedNow} 视为过期;
  • + *
  • 对外只返回强类型 record,不暴露 JJWT {@code Claims};
  • + *
  • 异常消息固定为安全文本,不含 Token 或 claim 原值。
  • + *
+ * + *

本类**不访问数据库**。

*/ @Slf4j @Component public class JwtTokenProvider { - private final SecretKey secretKey; + private static final String CLAIM_TYPE = "type"; + private static final String CLAIM_SID = "sid"; + private static final String CLAIM_ROLE = "role"; + private static final String TYPE_ACCESS = "access"; + private static final String TYPE_REFRESH = "refresh"; + + private final SecretKey accessKey; + private final SecretKey refreshKey; private final long expirationMs; private final long refreshExpirationMs; + private final String issuer; + private final String accessAudience; + private final String refreshAudience; - public JwtTokenProvider( - @Value("${app.jwt.secret}") String secret, - @Value("${app.jwt.expiration-ms:86400000}") long expirationMs, - @Value("${app.jwt.refresh-expiration-ms:604800000}") long refreshExpirationMs) { - this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); - this.expirationMs = expirationMs; - this.refreshExpirationMs = refreshExpirationMs; + public JwtTokenProvider(AppJwtProperties properties) { + this.accessKey = Keys.hmacShaKeyFor(properties.getAccessSecret().getBytes(StandardCharsets.UTF_8)); + this.refreshKey = Keys.hmacShaKeyFor(properties.getRefreshSecret().getBytes(StandardCharsets.UTF_8)); + this.expirationMs = properties.getExpirationMs(); + this.refreshExpirationMs = properties.getRefreshExpirationMs(); + this.issuer = properties.getIssuer(); + this.accessAudience = properties.getAccessAudience(); + this.refreshAudience = properties.getRefreshAudience(); } - public String generateAccessToken(Long userId, String phone, Map claims) { - return generateToken(userId, phone, claims, expirationMs); - } + // ── 签发 ──────────────────────────────────────────────────── - public String generateRefreshToken(Long userId, String phone) { - return generateToken(userId, phone, Map.of("type", "refresh"), refreshExpirationMs); + public IssuedToken generateAccessToken(Long userId, String role, String sessionId, + String tokenId, Instant capturedNow) { + Instant expiresAt = capturedNow.plusMillis(expirationMs); + String token = Jwts.builder() + .subject(String.valueOf(userId)) + .id(tokenId) + .claim(CLAIM_TYPE, TYPE_ACCESS) + .claim(CLAIM_SID, sessionId) + .claim(CLAIM_ROLE, role) + .issuer(issuer) + .audience().add(accessAudience).and() + .issuedAt(Date.from(capturedNow)) + .expiration(Date.from(expiresAt)) + .signWith(accessKey) + .compact(); + return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt); } - private String generateToken(Long userId, String phone, Map claims, long expirationMs) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + expirationMs); - - return Jwts.builder() + public IssuedToken generateRefreshToken(Long userId, String sessionId, + String tokenId, Instant capturedNow) { + Instant expiresAt = capturedNow.plusMillis(refreshExpirationMs); + String token = Jwts.builder() .subject(String.valueOf(userId)) - .claim("phone", phone) - .claims(claims) - .issuedAt(now) - .expiration(expiry) - .signWith(secretKey) + .id(tokenId) + .claim(CLAIM_TYPE, TYPE_REFRESH) + .claim(CLAIM_SID, sessionId) + .issuer(issuer) + .audience().add(refreshAudience).and() + .issuedAt(Date.from(capturedNow)) + .expiration(Date.from(expiresAt)) + .signWith(refreshKey) .compact(); + return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt); } - public Long parseUserId(String token) { - String subject = Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload() - .getSubject(); - return Long.parseLong(subject); + // ── 校验 ──────────────────────────────────────────────────── + + /** + * 校验并解析 Access Token。任何失败(签名、类型、issuer、audience、subject、 + * sid、jti、role、过期)统一抛出 {@link InvalidTokenException}。 + */ + public AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow) { + Claims claims = parse(rawToken, accessKey, TYPE_ACCESS, accessAudience, capturedNow); + Long userId = requirePositiveSubject(claims); + String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class)); + String jti = requireCanonicalUuid(claims.getId()); + String role = claims.get(CLAIM_ROLE, String.class); + if (role == null || role.isBlank()) { + throw new InvalidTokenException(); + } + return new AccessTokenClaims(userId, sid, jti, role, + toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration())); } - public boolean validateToken(String token) { + /** + * 校验并解析 Refresh Token。Refresh **不含** role,也不接受 role 参与判断。 + */ + public RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow) { + Claims claims = parse(rawToken, refreshKey, TYPE_REFRESH, refreshAudience, capturedNow); + Long userId = requirePositiveSubject(claims); + String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class)); + String jti = requireCanonicalUuid(claims.getId()); + return new RefreshTokenClaims(userId, sid, jti, + toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration())); + } + + private Claims parse(String rawToken, SecretKey key, String expectedType, + String expectedAudience, Instant capturedNow) { + if (rawToken == null || rawToken.isBlank()) { + throw new InvalidTokenException(); + } + Claims claims; try { - Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); - return true; - } catch (JwtException e) { - log.debug("JWT validation failed: {}", e.getMessage()); - return false; + claims = Jwts.parser() + .verifyWith(key) + // 过期判定由下面显式完成:这里把解析时钟固定在很早的时间, + // 避免依赖 jjwt 内部边界语义,从而严格执行本文档的契约。 + .clock(() -> Date.from(Instant.EPOCH)) + .require(CLAIM_TYPE, expectedType) + .requireIssuer(issuer) + .requireAudience(expectedAudience) + .build() + .parseSignedClaims(rawToken) + .getPayload(); + } catch (JwtException | IllegalArgumentException e) { + // 固定安全文本:不回显 Token、claim 原值或异常消息。 + throw new InvalidTokenException(); } + + // 时间契约(设计第七节):iat <= capturedNow < exp。 + // + // 过期判定:exp 缺失或 exp <= capturedNow 一律拒绝。 + Date expiration = claims.getExpiration(); + if (expiration == null || !expiration.toInstant().isAfter(capturedNow)) { + throw new InvalidTokenException(); + } + + // 签发时间判定:iat 缺失或 iat > capturedNow 一律拒绝。 + // 缺 iat 的 Token 无法界定有效窗口;iat 在未来说明 Token 被伪造或时钟被绕过。 + Date issuedAt = claims.getIssuedAt(); + if (issuedAt == null || issuedAt.toInstant().isAfter(capturedNow)) { + throw new InvalidTokenException(); + } + + return claims; + } + + private Long requirePositiveSubject(Claims claims) { + String subject = claims.getSubject(); + if (subject == null || subject.isBlank()) { + throw new InvalidTokenException(); + } + long userId; + try { + userId = Long.parseLong(subject); + } catch (NumberFormatException e) { + throw new InvalidTokenException(); + } + if (userId <= 0) { + throw new InvalidTokenException(); + } + return userId; } - public boolean isTokenExpired(String token) { + private String requireCanonicalUuid(String value) { + if (value == null || value.isBlank()) { + throw new InvalidTokenException(); + } + UUID parsed; try { - return Jwts.parser().verifyWith(secretKey).build() - .parseSignedClaims(token).getPayload().getExpiration().before(new Date()); - } catch (JwtException e) { - return true; + parsed = UUID.fromString(value); + } catch (IllegalArgumentException e) { + throw new InvalidTokenException(); } + // 只接受规范小写连字符形式,避免宽松解析放行异常写法。 + if (!parsed.toString().equals(value)) { + throw new InvalidTokenException(); + } + return value; + } + + private static Instant toInstant(Date date) { + return date == null ? null : date.toInstant(); } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java new file mode 100644 index 0000000..15cff0f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java @@ -0,0 +1,16 @@ +package com.mydreamjob.auth; + +import java.time.Instant; + +/** + * 已通过完整校验的 Refresh Token 声明(S02)。 + * + *

Refresh Token 不含 role,也不含手机号。

+ */ +public record RefreshTokenClaims( + Long userId, + String sessionId, + String tokenId, + Instant issuedAt, + Instant expiresAt) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java new file mode 100644 index 0000000..7924c0e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java @@ -0,0 +1,25 @@ +package com.mydreamjob.auth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +/** + * 携带已验证会话标识的认证详情(S02)。 + * + *

{@code sessionId} 只可能由 {@link JwtAuthenticationFilter} 在 Access Token + * 通过完整校验(签名、类型、issuer、audience、claim 与服务端会话状态)之后写入, + * 因此可作为 {@code /auth/logout} 的可信会话来源,不接受任何客户端参数。

+ */ +public class SessionAuthenticationDetails extends WebAuthenticationDetails { + + private final String sessionId; + + public SessionAuthenticationDetails(HttpServletRequest request, String sessionId) { + super(request); + this.sessionId = sessionId; + } + + public String getSessionId() { + return sessionId; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..59cbe7d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java @@ -0,0 +1,21 @@ +package com.mydreamjob.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * S02 刷新请求(白名单 DTO)。 + * + *

只允许 {@code refreshToken} 一个字段;Token 不接受 query/path 参数传递。

+ */ +@Data +@Schema(name = "RefreshTokenRequest", description = "刷新请求") +public class RefreshTokenRequest { + + @NotBlank(message = "refreshToken is required") + @Size(max = 4096, message = "refreshToken is too long") + @Schema(description = "登录或上一次刷新返回的 Refresh Token", requiredMode = Schema.RequiredMode.REQUIRED) + private String refreshToken; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java new file mode 100644 index 0000000..23b3724 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java @@ -0,0 +1,15 @@ +package com.mydreamjob.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * S02 刷新响应(白名单 DTO)。 + * + *

只包含新 Token 对与有效期;不包含 userId、手机号、sessionId 或任何数据库字段。

+ */ +@Schema(name = "TokenPairResponse", description = "刷新后的 Token 对") +public record TokenPairResponse( + @Schema(description = "新的 Access Token") String accessToken, + @Schema(description = "新的 Refresh Token") String refreshToken, + @Schema(description = "Access Token 有效期(秒)") long expiresIn) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/AppJwtProperties.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/AppJwtProperties.java new file mode 100644 index 0000000..0a86b9a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/AppJwtProperties.java @@ -0,0 +1,90 @@ +package com.mydreamjob.config; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * S02 Access/Refresh Token 隔离配置。 + * + *

Access 与 Refresh 使用独立签名密钥,Token 摘要另用独立密钥;三项密钥 + * 均不得缺失、不得为空、不得互相回退,且必须两两不同。

+ * + *

校验失败时抛出固定安全文本的异常,**不回显密钥值本身**。

+ */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.jwt") +public class AppJwtProperties { + + /** HMAC-SHA256 最小密钥长度(256 bit)。 */ + static final int MIN_SECRET_LENGTH = 32; + + /** + * 旧配置迁移项。S02 新签发的 Token 不使用该密钥,保留仅为兼容既有部署的环境变量, + * 因此不参与下面的长度与互异性校验。 + */ + private String secret; + + private String accessSecret; + private String refreshSecret; + private String tokenHashSecret; + + private long expirationMs = 86_400_000L; + private long refreshExpirationMs = 604_800_000L; + + private String issuer = "mydreamjob"; + private String accessAudience = "mydreamjob-api"; + private String refreshAudience = "mydreamjob-auth"; + + @PostConstruct + void validate() { + requireStrong("JWT_ACCESS_SECRET", accessSecret); + requireStrong("JWT_REFRESH_SECRET", refreshSecret); + requireStrong("JWT_TOKEN_HASH_SECRET", tokenHashSecret); + + // 三项必须两两不同:任一回退或被复用都会削弱隔离。 + requireDistinct("JWT_ACCESS_SECRET", accessSecret, "JWT_REFRESH_SECRET", refreshSecret); + requireDistinct("JWT_ACCESS_SECRET", accessSecret, "JWT_TOKEN_HASH_SECRET", tokenHashSecret); + requireDistinct("JWT_REFRESH_SECRET", refreshSecret, "JWT_TOKEN_HASH_SECRET", tokenHashSecret); + + requirePositive("JWT_EXPIRATION_MS", expirationMs); + requirePositive("JWT_REFRESH_EXPIRATION_MS", refreshExpirationMs); + + requireText("app.jwt.issuer", issuer); + requireText("app.jwt.access-audience", accessAudience); + requireText("app.jwt.refresh-audience", refreshAudience); + } + + private void requireStrong(String name, String value) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("invalid jwt configuration: " + name + " must be set"); + } + if (value.length() < MIN_SECRET_LENGTH) { + throw new IllegalStateException("invalid jwt configuration: " + name + + " must be at least " + MIN_SECRET_LENGTH + " characters"); + } + } + + private void requireDistinct(String nameA, String valueA, String nameB, String valueB) { + if (valueA.equals(valueB)) { + throw new IllegalStateException("invalid jwt configuration: " + nameA + + " and " + nameB + " must differ"); + } + } + + private void requirePositive(String name, long value) { + if (value <= 0) { + throw new IllegalStateException("invalid jwt configuration: " + name + " must be positive"); + } + } + + private void requireText(String name, String value) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("invalid jwt configuration: " + name + " must be set"); + } + } +} 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 4f89c37..038c657 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -74,8 +74,13 @@ public class SecurityConfig { .requestMatchers("/auth/sms/send").permitAll() .requestMatchers("/auth/login").permitAll() .requestMatchers("/auth/register").permitAll() + // S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。 + // permitAll 只表示可进入,不代表 Token 有效。 + .requestMatchers("/auth/refresh").permitAll() // 当前用户 .requestMatchers("/auth/me").authenticated() + // S02:登出必须已认证 + .requestMatchers("/auth/logout").authenticated() // 企业资料(需登录) .requestMatchers("/companies/me/**").authenticated() // V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验) 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 5d89002..7d24ce8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java @@ -1,12 +1,16 @@ package com.mydreamjob.controller; import com.mydreamjob.auth.AuthService; -import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.AuthSessionService; +import com.mydreamjob.auth.InvalidTokenException; import com.mydreamjob.auth.LoginRequest; import com.mydreamjob.auth.SmsVerificationService; +import com.mydreamjob.auth.dto.RefreshTokenRequest; +import com.mydreamjob.auth.dto.TokenPairResponse; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -16,18 +20,18 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import java.util.HashMap; import java.util.Map; /** * 认证相关接口 + * + *

S02:新增 {@code /auth/refresh} 与 {@code /auth/logout}。Controller 只做参数接收、 + * 身份解析和响应包装,不计算摘要、不解析 JWT、不访问 Repository,也不重复实现会话逻辑。

*/ -@Tag(name = "认证", description = "登录、验证码、用户信息") +@Tag(name = "认证", description = "登录、验证码、Token 刷新与登出、用户信息") @RestController @RequestMapping("/auth") @RequiredArgsConstructor @@ -36,7 +40,7 @@ public class AuthController { private final AuthService authService; private final SmsVerificationService smsVerificationService; - private final JwtTokenProvider jwtTokenProvider; + private final CurrentUserAccessor currentUserAccessor; @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @PostMapping("/sms/send") @@ -62,7 +66,7 @@ public class AuthController { } } - @Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 JWT token") + @Operation(summary = "Mock 登录", description = "使用手机号+验证码登录,返回 Access/Refresh Token 并创建服务端登录会话") @PostMapping("/login") public ResponseEntity>> mockLogin( @Valid @RequestBody LoginRequest body, @@ -91,18 +95,55 @@ public class AuthController { } } + @Operation(summary = "刷新 Token", + description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效") + @PostMapping("/refresh") + public ResponseEntity> refresh( + @Valid @RequestBody RefreshTokenRequest body, + HttpServletRequest request) { + try { + AuthSessionService.SessionTokens tokens = authService.refresh( + body.getRefreshToken(), + getRequestId(request), + request.getRemoteAddr(), + request.getHeader("User-Agent")); + return ResponseEntity.ok(ApiResponse.ok( + new TokenPairResponse(tokens.accessToken(), tokens.refreshToken(), + AuthService.EXPIRES_IN_SECONDS), + getRequestId(request))); + } catch (InvalidTokenException e) { + // 统一 401:不说明 Token 不存在、已消费、已撤销还是重放,也不回显任何 Token。 + return unauthorized(request); + } + } + + @Operation(summary = "登出", + description = "撤销当前登录会话;需要 Access Token,不接受任何请求体") + @PostMapping("/logout") + public ResponseEntity>> logout(HttpServletRequest request) { + // 身份与会话一律来自通过校验的 Access Token,不接受客户端传入 userId/sessionId。 + Long userId = currentUserAccessor.requireUserId(); + String sessionId = currentUserAccessor.requireSessionId(); + authService.logout(userId, sessionId, + getRequestId(request), + request.getRemoteAddr(), + request.getHeader("User-Agent")); + return ResponseEntity.ok(ApiResponse.ok( + Map.of("message", "logged out"), getRequestId(request))); + } + @Operation(summary = "当前用户信息", description = "需要 JWT 认证") @GetMapping("/me") public ResponseEntity>> me(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - return ResponseEntity.status(401).body( - ApiResponse.unauthorized("not authenticated", getRequestId(request))); - } - Long userId = Long.valueOf(auth.getName()); + Long userId = currentUserAccessor.requireUserId(); return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), getRequestId(request))); } + private ResponseEntity> unauthorized(HttpServletRequest request) { + return ResponseEntity.status(401).body( + ApiResponse.unauthorized("invalid or expired token", getRequestId(request))); + } + private String getRequestId(HttpServletRequest request) { return request.getHeader("X-Request-Id"); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java index 709ee60..3193e1c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java @@ -1,6 +1,7 @@ package com.mydreamjob.deletion; import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.AuthSessionService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.dataexport.PersonalDataExportCleanupService; @@ -11,6 +12,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; /** @@ -32,6 +35,8 @@ public class AccountDeletionCompletionWorker { private final AuditService auditService; private final AccountDeletionPhoneHasher phoneHasher; private final PersonalDataExportCleanupService dataExportCleanupService; + private final AuthSessionService authSessionService; + private final Clock clock; /** * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 @@ -44,7 +49,9 @@ public class AccountDeletionCompletionWorker { if (rec == null || rec.getStatus() != DeletionStatus.PENDING) { return false; } - LocalDateTime now = LocalDateTime.now(); + // S02:注销完成入口只取一次时间,状态迁移、脱敏与全局会话吊销共用。 + Instant capturedNow = clock.instant(); + LocalDateTime now = LocalDateTime.ofInstant(capturedNow, AuthSessionService.BUSINESS_ZONE); if (rec.getEffectiveAt() == null || rec.getEffectiveAt().isAfter(now)) { return false; } @@ -55,6 +62,10 @@ public class AccountDeletionCompletionWorker { rec.setCompletedAt(now); accountDeletionRepository.saveAndFlush(rec); // @Version 门控:并发时后到者抛异常 desensitize(rec.getUserId()); + // S02:注销完成必须撤销该用户全部登录会话(与状态迁移同一事务)。 + // 只按 rec.getUserId() 撤销,不接受任何客户端传入的 userId。 + authSessionService.revokeAllUserSessions( + rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow); // 注销完成:清理该用户的个人导出文件并作废未完成任务 dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); // 完成 + 脱敏审计各写一次 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java b/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java index a47bfa7..dbf839a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java @@ -1,5 +1,6 @@ package com.mydreamjob.security; +import com.mydreamjob.auth.SessionAuthenticationDetails; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.Authentication; @@ -33,6 +34,29 @@ public class CurrentUserAccessor { return requireUserId(SecurityContextHolder.getContext().getAuthentication()); } + /** + * 解析当前请求**已验证**的登录会话 ID(S02)。 + * + *

会话 ID 只可能由认证过滤器在 Access Token 通过完整校验后写入 + * {@code Authentication.details};客户端无法通过参数或请求体提供。 + * 取不到时一律按未登录处理。

+ */ + public String requireSessionId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || authentication instanceof AnonymousAuthenticationToken) { + throw unauthenticated(); + } + if (authentication.getDetails() instanceof SessionAuthenticationDetails details) { + String sessionId = details.getSessionId(); + if (sessionId != null && !sessionId.isBlank()) { + return sessionId; + } + } + throw unauthenticated(); + } + /** * 解析用户 ID。任何非法输入都抛出未登录异常,绝不抛出 NumberFormatException。 * diff --git a/mydreamjob-server/src/main/resources/application-dev.example.yml b/mydreamjob-server/src/main/resources/application-dev.example.yml index ac0f869..c8173b9 100644 --- a/mydreamjob-server/src/main/resources/application-dev.example.yml +++ b/mydreamjob-server/src/main/resources/application-dev.example.yml @@ -21,6 +21,9 @@ spring: app: jwt: secret: ${JWT_SECRET} + access-secret: ${JWT_ACCESS_SECRET} + refresh-secret: ${JWT_REFRESH_SECRET} + token-hash-secret: ${JWT_TOKEN_HASH_SECRET} expiration-ms: 86400000 refresh-expiration-ms: 604800000 diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index 13e8ac7..2cc9e32 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/mydreamjob-server/src/main/resources/application.yml @@ -74,15 +74,27 @@ spring: fail-on-unknown-properties: false # JWT +# S02:Access 与 Refresh 使用独立签名密钥,另设独立的 Token 摘要密钥。 +# 三项密钥均无默认值,缺失即启动失败(见 AppJwtProperties 校验)。 app: jwt: - secret: ${JWT_SECRET} + # 旧配置迁移项:S02 签发/校验的 Token **不使用**该密钥,不得回退到它。 + # 仍保留是因为 talentdiscovery.CandidateKeyGenerator 用它派生候选人展示键; + # 该组件在密钥为空时自行失败关闭。这里改为**非强制解析**,使未配置 JWT_SECRET + # 的环境也能启动(Token 安全性完全由下面三项独立密钥承担)。 + secret: ${JWT_SECRET:} + access-secret: ${JWT_ACCESS_SECRET} + refresh-secret: ${JWT_REFRESH_SECRET} + token-hash-secret: ${JWT_TOKEN_HASH_SECRET} expiration-ms: ${JWT_EXPIRATION_MS:86400000} + refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} + issuer: mydreamjob + access-audience: mydreamjob-api + refresh-audience: mydreamjob-auth resume: storage-type: ${RESUME_STORAGE_TYPE:local} storage-dir: ${RESUME_STORAGE_DIR:./resumes} export-dir: ${RESUME_EXPORT_DIR:./resume-exports} - refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} async: resume-export: diff --git a/mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql b/mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql new file mode 100644 index 0000000..0c001e9 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql @@ -0,0 +1,28 @@ +-- S02:Access/Refresh Token 隔离、轮换与吊销 +-- +-- 扩展 auth_tokens,使其能表达“登录会话 + Refresh Token 轮换 + 撤销”: +-- session_id 每次登录的会话 UUID +-- token_jti Refresh Token 的 jti(唯一) +-- parent_token_id 轮换前一条记录 +-- consumed_at 成功刷新时的消费时间 +-- revoked_at 撤销时间 +-- revocation_reason 固定白名单原因 +-- version 乐观锁/条件更新版本 +-- +-- 约束: +-- * 不修改既有列、不删除既有数据、不新增级联删除; +-- * 旧记录 session_id 为 NULL,永远不被新鉴权逻辑视为活动会话; +-- * token_hash 的业务含义固定为 Refresh Token 的 HMAC-SHA256 摘要; +-- * token_jti 允许 NULL(旧行),UNIQUE 在 MySQL 与 H2 下均允许多个 NULL。 + +ALTER TABLE auth_tokens ADD COLUMN session_id VARCHAR(36) NULL; +ALTER TABLE auth_tokens ADD COLUMN token_jti VARCHAR(36) NULL; +ALTER TABLE auth_tokens ADD COLUMN parent_token_id BIGINT NULL; +ALTER TABLE auth_tokens ADD COLUMN consumed_at DATETIME NULL; +ALTER TABLE auth_tokens ADD COLUMN revoked_at DATETIME NULL; +ALTER TABLE auth_tokens ADD COLUMN revocation_reason VARCHAR(40) NULL; +ALTER TABLE auth_tokens ADD COLUMN version BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE auth_tokens ADD INDEX idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at); +ALTER TABLE auth_tokens ADD INDEX idx_auth_tokens_user_session (user_id, session_id); +ALTER TABLE auth_tokens ADD CONSTRAINT uk_auth_tokens_token_jti UNIQUE (token_jti); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java index c79f873..62b1f77 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java @@ -76,8 +76,7 @@ class AbilityControllerTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java index 1469230..22c1a53 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java @@ -78,18 +78,15 @@ class ApplicationControllerTest { } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken( - COMPANY_USER_ID, "1", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_USER_ID, "COMPANY"); } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken( - STUDENT_USER_ID, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_USER_ID, "STUDENT"); } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken( - ADMIN_USER_ID, "1", Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(ADMIN_USER_ID, "ADMIN"); } private void mockCompanyExists() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java index 15281ea..d401545 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java @@ -86,8 +86,7 @@ class AssessmentControllerTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdvancingClock.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdvancingClock.java new file mode 100644 index 0000000..0e3b3e1 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdvancingClock.java @@ -0,0 +1,55 @@ +package com.mydreamjob.auth; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 测试专用时钟:第一次 {@link #instant()} 返回 T,之后返回一个**明显不同**的时间(T + 偏移)。 + * + *

用途:证明认证入口只读取一次 Clock。若某个入口读了两次,第二次取到的是 T+偏移, + * 于是 JWT {@code iat}/{@code exp}、数据库 {@code created_at}/{@code expired_at} + * 或审计时间就会出现两个不同的值,测试即可捕获。

+ * + *

固定使用项目业务时区 {@code Asia/Shanghai},不依赖系统默认时区。

+ */ +class AdvancingClock extends Clock { + + static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + private final Instant first; + private final Instant subsequent; + private final AtomicInteger calls = new AtomicInteger(); + + AdvancingClock(Instant first, Instant subsequent) { + this.first = first; + this.subsequent = subsequent; + } + + @Override + public ZoneId getZone() { + return ZONE; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + int call = calls.incrementAndGet(); + return call == 1 ? first : subsequent; + } + + /** 已读取次数。 */ + int callCount() { + return calls.get(); + } + + /** 每个入口开始前重置,便于分别断言。 */ + void reset() { + calls.set(0); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshConcurrencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshConcurrencyIntegrationTest.java new file mode 100644 index 0000000..eb462e3 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshConcurrencyIntegrationTest.java @@ -0,0 +1,319 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import com.mydreamjob.auth.AuthSessionService.SessionTokens; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S02 并发刷新与登出竞态(设计 D17,任务书 §十八)。 + * + *

真实 Service + Repository + 事务 + EntityManager,两个线程用 CountDownLatch + * 同时开始,不 Mock 条件更新。

+ * + *

独立复验整改后:重放被识别时要求该会话**活动 Token 数为 0**(而不是 ≤1), + * 且并发胜出方刚签发的 Access Token 也必须失效。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthRefreshConcurrencyIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9600); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthTokenRepository authTokenRepository; + + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("16" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 并发") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + if (userId != null) { + userRepository.deleteById(userId); + } + } + + private AuthSessionService.SessionTokens newSession() { + return authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + } + + /** 该会话下仍满足"活动"条件的 Refresh 记录数。 */ + private long activeCount(String sessionId) { + return authTokenRepository.countActiveSessionTokens(userId, sessionId, AuthSessionService.toLocal(Instant.now())); + } + + private record Attempt(boolean success, SessionTokens tokens, Throwable error) { + } + + private record AttemptOutcome(List attempts) { + long successes() { + return attempts.stream().filter(Attempt::success).count(); + } + + List failures() { + return attempts.stream().filter(a -> !a.success()).map(Attempt::error).toList(); + } + } + + private AttemptOutcome refreshConcurrently(String refreshToken) throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch ready = new CountDownLatch(2); + List> futures = new ArrayList<>(); + for (int i = 0; i < 2; i++) { + futures.add(pool.submit(() -> { + ready.countDown(); + start.await(10, TimeUnit.SECONDS); + try { + return new Attempt(true, authSessionService.refresh(refreshToken, Instant.now(), null), null); + } catch (Throwable t) { + return new Attempt(false, null, t); + } + })); + } + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + List results = new ArrayList<>(); + for (Future f : futures) { + results.add(f.get(30, TimeUnit.SECONDS)); + } + pool.shutdownNow(); + return new AttemptOutcome(results); + } + + private void assertOnlyBusinessFailure(AttemptOutcome outcome) { + outcome.failures().forEach(t -> assertThat(t) + .as("并发失败方只能是既定的业务拒绝,不得泄漏锁异常或 500") + .isInstanceOf(InvalidTokenException.class)); + } + + // ── 并发刷新:恰好一个成功 ─────────────────────────────────── + + @Test + void concurrentRefreshOnlyOneRequestSucceeds() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + + assertThat(outcome.successes()).as("同一 Refresh Token 最多一个刷新成功").isEqualTo(1); + assertOnlyBusinessFailure(outcome); + } + + // ── 重放处置:整个会话必须被撤销 ───────────────────────────── + + @Test + void concurrentRefreshReplayRevokesEntireSession() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + assertThat(outcome.successes()).isEqualTo(1); + assertOnlyBusinessFailure(outcome); + + assertThat(activeCount(session.sessionId())) + .as("重放处置后该会话不得残留任何活动 Refresh Token") + .isZero(); + assertThat(authSessionService.isSessionActive(userId, session.sessionId(), Instant.now())) + .as("该会话必须处于失效状态") + .isFalse(); + } + + @Test + void concurrentRefreshLeavesAtMostOneActiveSuccessor() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + assertOnlyBusinessFailure(outcome); + + List active = authTokenRepository + .findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()).stream() + .filter(t -> !Boolean.TRUE.equals(t.getRevoked())) + .filter(t -> t.getConsumedAt() == null) + .toList(); + assertThat(active).as("不得留下两个活动后继 Token(重放后应为 0)").isEmpty(); + } + + @Test + void concurrentRefreshInvalidatesWinningAccessTokenAfterReplay() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + assertOnlyBusinessFailure(outcome); + + // 并发胜出方刚签发的 Access Token 也必须失效 + String winningAccess = outcome.attempts().stream() + .filter(Attempt::success).map(a -> a.tokens().accessToken()).findFirst().orElseThrow(); + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + winningAccess)) + .andExpect(status().isUnauthorized()); + + // 原 Access Token 同样失效 + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + session.accessToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void concurrentReplayDoesNotRevokeOtherSession() throws Exception { + AuthSessionService.SessionTokens victim = newSession(); + AuthSessionService.SessionTokens other = newSession(); + + AttemptOutcome outcome = refreshConcurrently(victim.refreshToken()); + assertOnlyBusinessFailure(outcome); + assertThat(activeCount(victim.sessionId())).isZero(); + + assertThat(authSessionService.isSessionActive(userId, other.sessionId(), Instant.now())) + .as("其他设备会话不受影响").isTrue(); + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + other.accessToken())) + .andExpect(status().isOk()); + } + + @Test + void concurrentRefreshDoesNotLeakLockExceptionAs500() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + AttemptOutcome outcome = refreshConcurrently(session.refreshToken()); + + // 失败方不得出现锁异常、乐观锁异常或任何非业务异常 + outcome.failures().forEach(t -> assertThat(t) + .as("不得出现未处理的锁异常或 500 级异常") + .isNotInstanceOf(org.springframework.dao.DataAccessException.class) + .isInstanceOf(InvalidTokenException.class)); + } + + // ── 重放(串行)与其他会话 ─────────────────────────────────── + + @Test + void refreshReplayRevokesOnlyCurrentSession() throws Exception { + AuthSessionService.SessionTokens victim = newSession(); + AuthSessionService.SessionTokens other = newSession(); + assertThat(victim.sessionId()).isNotEqualTo(other.sessionId()); + + authSessionService.refresh(victim.refreshToken(), Instant.now(), null); + try { + authSessionService.refresh(victim.refreshToken(), Instant.now(), null); + } catch (InvalidTokenException expected) { + // 预期 + } + + assertThat(activeCount(victim.sessionId())).isZero(); + assertThat(activeCount(other.sessionId())).isPositive(); + } + + @Test + void differentSessionsRefreshIndependently() throws Exception { + AuthSessionService.SessionTokens a = newSession(); + AuthSessionService.SessionTokens b = newSession(); + + authSessionService.refresh(a.refreshToken(), Instant.now(), null); + authSessionService.refresh(b.refreshToken(), Instant.now(), null); + + assertThat(authSessionService.isSessionActive(userId, a.sessionId(), Instant.now())).isTrue(); + assertThat(authSessionService.isSessionActive(userId, b.sessionId(), Instant.now())).isTrue(); + } + + // ── 登出与刷新竞态 ────────────────────────────────────────── + + @Test + void logoutRacingWithRefreshLeavesNoActiveSuccessorWithoutCleanupLogout() throws Exception { + AuthSessionService.SessionTokens session = newSession(); + + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch ready = new CountDownLatch(2); + + Future refresher = pool.submit(() -> { + ready.countDown(); + try { + start.await(10, TimeUnit.SECONDS); + authSessionService.refresh(session.refreshToken(), Instant.now(), null); + return null; + } catch (InvalidTokenException expected) { + // 与登出竞争时唯一的预期业务异常 + return expected; + } catch (Throwable unexpected) { + return unexpected; + } + }); + Future logger = pool.submit(() -> { + ready.countDown(); + try { + start.await(10, TimeUnit.SECONDS); + authSessionService.logout(userId, session.sessionId(), Instant.now(), null); + return null; + } catch (Throwable unexpected) { + return unexpected; + } + }); + + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + Throwable refreshError = refresher.get(30, TimeUnit.SECONDS); + Throwable logoutError = logger.get(30, TimeUnit.SECONDS); + pool.shutdownNow(); + + // 只允许既定的业务异常;登出本身不应失败 + assertThat(logoutError).as("登出不应抛异常").isNull(); + if (refreshError != null) { + assertThat(refreshError) + .as("刷新失败时只能是 InvalidTokenException") + .isInstanceOf(InvalidTokenException.class); + } + + // 线程结束后直接检查最终状态:不得再补一次 logout(那会掩盖竞争缺陷) + assertThat(activeCount(session.sessionId())) + .as("登出与刷新竞争后不得残留活动 Token").isZero(); + assertThat(authSessionService.isSessionActive(userId, session.sessionId(), Instant.now())).isFalse(); + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + session.accessToken())) + .andExpect(status().isUnauthorized()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshControllerTest.java new file mode 100644 index 0000000..2af6f9f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshControllerTest.java @@ -0,0 +1,138 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.Test; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S02 刷新接口的入参校验(设计 9.2,任务书 §十四)。 + * + *

请求体只允许 {@code refreshToken},且必须有合理的必填与长度约束。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthRefreshControllerTest { + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + private static String body(String json) { + return json; + } + + @Test + void missingRefreshTokenIsRejected() throws Exception { + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(body("{}"))) + .andExpect(status().is4xxClientError()); + } + + @Test + void blankRefreshTokenIsRejected() throws Exception { + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(body("{\"refreshToken\":\" \"}"))) + .andExpect(status().is4xxClientError()); + } + + @Test + void oversizedRefreshTokenIsRejected() throws Exception { + String huge = "x".repeat(5000); + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(body("{\"refreshToken\":\"" + huge + "\"}"))) + .andExpect(status().is4xxClientError()); + } + + @Test + void refreshEndpointIsReachableWithoutAuthentication() throws Exception { + // permitAll 表示可进入 Controller;是否成功仍取决于 Token 校验。 + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(body("{\"refreshToken\":\"not-a-valid-token\"}"))) + .andExpect(status().isUnauthorized()); + } + + @Test + void logoutRequiresAuthentication() throws Exception { + mockMvc.perform(post("/auth/logout")) + .andExpect(status().isUnauthorized()); + } + + // ── OpenAPI 契约(S02 新增两个 operation)──────────────────── + + @Test + void openApiExposesExactlyTheTwoNewAuthOperations() throws Exception { + String json = mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .get("/v3/api-docs")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + com.fasterxml.jackson.databind.JsonNode root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); + com.fasterxml.jackson.databind.JsonNode paths = root.path("paths"); + com.fasterxml.jackson.databind.JsonNode components = root.path("components").path("schemas"); + + org.assertj.core.api.Assertions.assertThat(paths.has("/auth/refresh")).isTrue(); + org.assertj.core.api.Assertions.assertThat(paths.has("/auth/logout")).isTrue(); + + com.fasterxml.jackson.databind.JsonNode refresh = paths.path("/auth/refresh").path("post"); + com.fasterxml.jackson.databind.JsonNode logout = paths.path("/auth/logout").path("post"); + org.assertj.core.api.Assertions.assertThat(refresh.isMissingNode()).isFalse(); + org.assertj.core.api.Assertions.assertThat(logout.isMissingNode()).isFalse(); + + // 刷新请求体只允许 refreshToken + com.fasterxml.jackson.databind.JsonNode bodySchema = deref( + refresh.path("requestBody").path("content").path("application/json").path("schema"), components); + java.util.List properties = new java.util.ArrayList<>(); + bodySchema.path("properties").fieldNames().forEachRemaining(properties::add); + org.assertj.core.api.Assertions.assertThat(properties) + .as("刷新请求体只允许 refreshToken").containsExactly("refreshToken"); + + // 登出无请求体 + org.assertj.core.api.Assertions.assertThat(logout.has("requestBody")) + .as("登出不得声明 requestBody").isFalse(); + + // 刷新响应不得泄漏数据库字段 + String refreshJson = refresh.toString(); + for (String forbidden : new String[]{"tokenHash", "refreshTokenHash", "sessionId", "phone", "secret"}) { + org.assertj.core.api.Assertions.assertThat(refreshJson) + .as("刷新响应 schema 不得包含 " + forbidden).doesNotContain(forbidden); + } + + // 登录原有字段不得删除 + com.fasterxml.jackson.databind.JsonNode login = paths.path("/auth/login").path("post"); + org.assertj.core.api.Assertions.assertThat(login.isMissingNode()).isFalse(); + + // 开发辅助:-Ds02.openapi.snapshot=<目录> 时导出快照 + String snapshotDir = System.getProperty("s02.openapi.snapshot"); + if (snapshotDir != null && !snapshotDir.isBlank()) { + java.nio.file.Files.write( + java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), + json.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + } + + private static com.fasterxml.jackson.databind.JsonNode deref(com.fasterxml.jackson.databind.JsonNode node, + com.fasterxml.jackson.databind.JsonNode components) { + if (node.has("$ref")) { + String ref = node.path("$ref").asText(); + String name = ref.substring(ref.lastIndexOf('/') + 1); + return components.path(name); + } + return node; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshHttpIntegrationTest.java new file mode 100644 index 0000000..6a6b38d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshHttpIntegrationTest.java @@ -0,0 +1,212 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S02 刷新接口的 HTTP 语义(设计 D08 / D09 / D11,任务书 §十四 与 §十九 HTTP 组)。 + * + *

覆盖:匿名携带有效 Refresh 刷新成功、轮换后旧 Token 永久失效、 + * 二次使用触发重放处置并撤销当前会话、以及错误响应不泄漏。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class AuthRefreshHttpIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9400); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthTokenRepository authTokenRepository; + + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 刷新") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + if (userId != null) { + userRepository.deleteById(userId); + } + } + + private AuthSessionService.SessionTokens newSession() { + return authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + } + + private String refreshBody(String refreshToken) { + return "{\"refreshToken\":\"" + refreshToken + "\"}"; + } + + // ── 正常轮换 ──────────────────────────────────────────────── + + @Test + void anonymousRefreshWithValidTokenRotatesPair() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()) + .andExpect(jsonPath("$.data.expiresIn").value(86400)) + // 响应不得回显数据库字段或会话标识 + .andExpect(content().string(not(containsString("sessionId")))) + .andExpect(content().string(not(containsString("tokenHash")))); + } + + @Test + void rotatedAccessTokenWorksAndOldRefreshIsConsumed() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + String body = mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + String newAccess = body.replaceAll(".*\"accessToken\":\"([^\"]+)\".*", "$1"); + + // 新的 Access Token 可用 + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + newAccess)) + .andExpect(status().isOk()); + + // 旧 Refresh Token 已被消费 + AuthToken consumed = authTokenRepository.findTopByTokenHashOrderByIdDesc( + new AuthTokenHasher(hashProperties()).hash(first.refreshToken())).orElseThrow(); + org.junit.jupiter.api.Assertions.assertNotNull(consumed.getConsumedAt()); + } + + // ── 二次使用 / 重放 ──────────────────────────────────────── + + @Test + void sameRefreshTokenCannotBeConsumedTwice() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isOk()); + + // 二次使用同一 Refresh Token → 401,且不说明已消费/已撤销 + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("consumed")))) + .andExpect(content().string(not(containsString("revoked")))) + .andExpect(content().string(not(containsString("sessionId")))); + } + + @Test + void replayRevokesCurrentSessionSoOldAccessStopsWorking() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isOk()); + + // 重放:撤销当前会话 + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.refreshToken()))) + .andExpect(status().isUnauthorized()); + + // 该会话的原 Access Token 随即失效 + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + first.accessToken())) + .andExpect(status().isUnauthorized()); + } + + // ── 非 Refresh Token 一律拒绝 ─────────────────────────────── + + @Test + void accessTokenAndGarbageAreRejectedAtRefresh() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody(first.accessToken()))) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(refreshBody("not-a-token"))) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"\"}")) + .andExpect(status().is4xxClientError()); + } + + @Test + void refreshTokenCannotBePassedAsQueryParameter() throws Exception { + AuthSessionService.SessionTokens first = newSession(); + + // Token 只能出现在请求体;作为 query 参数不产生任何效果。 + mockMvc.perform(post("/auth/refresh?refreshToken=" + first.refreshToken()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().is4xxClientError()); + } + + private com.mydreamjob.config.AppJwtProperties hashProperties() { + com.mydreamjob.config.AppJwtProperties p = new com.mydreamjob.config.AppJwtProperties(); + p.setTokenHashSecret("test-token-hash-secret-key-for-integration-tests-only-not-for-production"); + return p; + } +} 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 afc76f2..53db2d4 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java @@ -1,5 +1,6 @@ package com.mydreamjob.auth; +import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; @@ -14,21 +15,33 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.Optional; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +/** + * S02 {@link AuthService} 单元测试。 + * + *

登录链路改为「验证码校验 → 用户解析 → 创建服务端会话」,因此 Mockito 目标 + * 由旧的 {@code JwtTokenProvider} 换成 {@link AuthSessionService}; + * 原有断言语义(响应字段、自动建号、注销墓碑、验证码用途透传)保持不变。

+ */ @ExtendWith(MockitoExtension.class) class AuthServiceTest { + private static final Instant FIXED_NOW = Instant.parse("2026-09-14T02:00:00Z"); + @Mock private UserRepository userRepository; @Mock - private JwtTokenProvider jwtTokenProvider; + private AuthSessionService authSessionService; @Mock private SmsVerificationService smsVerificationService; @@ -42,9 +55,18 @@ class AuthServiceTest { @Mock private AccountDeletionPhoneHasher accountDeletionPhoneHasher; + @Mock + private Clock clock; + @InjectMocks private AuthService authService; + private void stubSessionIssuance() { + when(clock.instant()).thenReturn(FIXED_NOW); + when(authSessionService.createSession(any(), any(), any())) + .thenReturn(new AuthSessionService.SessionTokens("access-token", "refresh-token", "sid-1")); + } + @Test void mockLogin_shouldReturnTokenWhenCodeValid() { User user = User.builder() @@ -53,17 +75,19 @@ class AuthServiceTest { when(smsVerificationService.verifyCode("13800000001", "123456", "login")).thenReturn(true); when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); - when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) - .thenReturn("access-token"); - when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) - .thenReturn("refresh-token"); + stubSessionIssuance(); Map result = authService.mockLogin("13800000001", "123456", "login", "req-1", "10.0.0.1", "JUnit"); assertEquals("access-token", result.get("accessToken")); assertEquals("refresh-token", result.get("refreshToken")); assertEquals(1L, result.get("userId")); - verify(auditService, times(1)).logLogin(1L, "10.0.0.1", "JUnit", "req-1"); + // expiresIn 契约保持不变(设计 D04) + assertEquals(86400L, result.get("expiresIn")); + // 登录审计仍写入,且时间来自入口单次捕获的 capturedNow + verify(auditService, times(1)).log(eq(1L), eq("LOGIN"), eq("USER"), eq(1L), + eq("User logged in"), any(AuditContext.class), + eq(LocalDateTime.ofInstant(FIXED_NOW, AuthSessionService.BUSINESS_ZONE))); } @Test @@ -75,10 +99,7 @@ class AuthServiceTest { u.setId(99L); return u; }); - when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) - .thenReturn("access-token"); - when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) - .thenReturn("refresh-token"); + stubSessionIssuance(); Map result = authService.mockLogin("13800000099", "123456", "login", null, null, null); @@ -92,10 +113,7 @@ class AuthServiceTest { .thenReturn(true); User user = User.builder().id(1L).phone("13800000001").role(User.Role.STUDENT).active(true).verified(true).build(); when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); - when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) - .thenReturn("token"); - when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString())) - .thenReturn("refresh"); + stubSessionIssuance(); authService.mockLogin("13800000001", "123456", "password-reset", null, null, null); verify(smsVerificationService, times(1)).verifyCode("13800000001", "123456", "password-reset"); @@ -108,6 +126,8 @@ class AuthServiceTest { assertThrows(BusinessException.class, () -> authService.mockLogin("13800000001", "000000", "login", null, null, null)); + // 验证码失败不得创建任何会话 + verify(authSessionService, never()).createSession(any(), any(), any()); } @Test @@ -123,6 +143,7 @@ class AuthServiceTest { assertEquals(ErrorCode.ACCOUNT_DELETED.getCode(), ex.getErrorCodeInt()); // 不得自动创建新用户 verify(userRepository, never()).save(any()); + verify(authSessionService, never()).createSession(any(), any(), any()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionPersistenceIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionPersistenceIntegrationTest.java new file mode 100644 index 0000000..74b0a73 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionPersistenceIntegrationTest.java @@ -0,0 +1,219 @@ +package com.mydreamjob.auth; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 持久化契约(设计第七节,任务书 §九)。 + * + *

覆盖:V42 迁移字段、新记录字段正确、token_jti 唯一、默认 version=0、 + * 乐观锁冲突、旧格式行仍可存在、数据库不保存 Token 原文。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AuthSessionPersistenceIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9900); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthTokenRepository authTokenRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @PersistenceContext + private EntityManager entityManager; + + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("14" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 持久化") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + if (userId != null) { + userRepository.deleteById(userId); + } + } + + // ── 迁移字段 ──────────────────────────────────────────────── + + @Test + void v42ColumnsExist() { + List columns = jdbcTemplate.queryForList( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE UPPER(TABLE_NAME) = 'AUTH_TOKENS'", + String.class); + + assertThat(columns.stream().map(String::toLowerCase).toList()).contains( + "session_id", "token_jti", "parent_token_id", + "consumed_at", "revoked_at", "revocation_reason", "version"); + } + + @Test + void newSessionRecordHasExpectedFields() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + + List rows = authTokenRepository.findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()); + assertThat(rows).hasSize(1); + + AuthToken row = rows.get(0); + assertThat(row.getSessionId()).isEqualTo(session.sessionId()); + assertThat(row.getTokenJti()).isNotBlank(); + assertThat(row.getTokenType()).isEqualTo(AuthToken.TYPE_REFRESH); + assertThat(row.getRevoked()).isFalse(); + assertThat(row.getConsumedAt()).isNull(); + assertThat(row.getRevokedAt()).isNull(); + assertThat(row.getVersion()).as("新记录默认 version=0").isZero(); + assertThat(row.getExpiredAt()).isAfter(LocalDateTime.now()); + } + + @Test + void databaseDoesNotStoreRawJwt() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + + List hashes = jdbcTemplate.queryForList( + "SELECT token_hash FROM auth_tokens WHERE session_id = ?", String.class, session.sessionId()); + + assertThat(hashes).hasSize(1); + String stored = hashes.get(0); + assertThat(stored).as("必须是固定长度摘要").hasSize(64); + assertThat(stored).matches("[0-9a-f]{64}"); + // 原文的两个部分都不得出现 + assertThat(stored).doesNotContain(session.refreshToken()); + assertThat(session.refreshToken()).doesNotContain(stored); + } + + @Test + void refreshJtiIsUnique() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthToken existing = authTokenRepository + .findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()).get(0); + + assertThatThrownBy(() -> jdbcTemplate.update( + "INSERT INTO auth_tokens (user_id, token_hash, token_type, expired_at, revoked, session_id, token_jti, version, created_at)" + + " VALUES (?, ?, 'REFRESH', ?, 0, ?, ?, 0, CURRENT_TIMESTAMP)", + userId, "a".repeat(64), LocalDateTime.now().plusDays(1), + "other-session", existing.getTokenJti())) + .as("token_jti 必须唯一") + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + } + + @Test + void legacyRowsWithoutSessionIdRemainAllowed() { + // 迁移前写入的旧格式行(session_id IS NULL)必须仍然可以存在 + jdbcTemplate.update( + "INSERT INTO auth_tokens (user_id, token_hash, token_type, expired_at, revoked, created_at)" + + " VALUES (?, ?, 'ACCESS', ?, 0, CURRENT_TIMESTAMP)", + userId, "b".repeat(64), LocalDateTime.now().plusDays(1)); + + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM auth_tokens WHERE user_id = ? AND session_id IS NULL", + Integer.class, userId)).isPositive(); + + // 且旧行永远不会被新鉴权逻辑视为活动会话 + assertThat(authSessionService.isSessionActive(userId, null, Instant.now())).isFalse(); + } + + // ── 乐观锁 / 条件更新 ─────────────────────────────────────── + + @Test + @Transactional + void consumeIfCurrentRejectsStaleVersion() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthToken row = authTokenRepository + .findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()).get(0); + + Long staleVersion = row.getVersion(); + LocalDateTime now = LocalDateTime.now(); + + // 第一次消费成功并把 version 递增 + assertThat(authTokenRepository.consumeIfCurrent(row.getId(), staleVersion, now, now)).isEqualTo(1); + // 用旧 version 再次消费必须失败(条件更新失效) + assertThat(authTokenRepository.consumeIfCurrent(row.getId(), staleVersion, now, now)).isZero(); + } + + @Test + @Transactional + void consumeIfCurrentRejectsRevokedConsumedOrExpired() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthToken row = authTokenRepository + .findByUserIdAndSessionIdOrderByIdAsc(userId, session.sessionId()).get(0); + LocalDateTime now = LocalDateTime.now(); + + // 过期后不可消费 + assertThat(authTokenRepository.consumeIfCurrent( + row.getId(), row.getVersion(), now, row.getExpiredAt().plusSeconds(1))).isZero(); + + // 已撤销不可消费 + authTokenRepository.revokeSession(userId, session.sessionId(), now, AuthSessionService.REASON_LOGOUT); + AuthToken reloaded = authTokenRepository.findById(row.getId()).orElseThrow(); + assertThat(authTokenRepository.consumeIfCurrent( + reloaded.getId(), reloaded.getVersion(), now, now)).isZero(); + } + + @Test + @Transactional + void conditionUpdateReturnsAffectedRowsAndIsChecked() { + AuthSessionService.SessionTokens session = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + + int first = authTokenRepository.revokeSession( + userId, session.sessionId(), LocalDateTime.now(), AuthSessionService.REASON_LOGOUT); + int second = authTokenRepository.revokeSession( + userId, session.sessionId(), LocalDateTime.now(), AuthSessionService.REASON_LOGOUT); + + assertThat(first).isEqualTo(1); + assertThat(second).as("重复撤销不再影响任何行").isZero(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionRevocationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionRevocationIntegrationTest.java new file mode 100644 index 0000000..cc64a96 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionRevocationIntegrationTest.java @@ -0,0 +1,200 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionCompletionWorker; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 会话撤销语义(设计 D11 / D12,任务书 §十五 与 §十九 生命周期组)。 + * + *

覆盖:登出只撤销当前设备、多设备隔离、重复撤销幂等、 + * 注销完成撤销全部会话、其他用户不受影响。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AuthSessionRevocationIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9800); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AccountDeletionRepository accountDeletionRepository; + + @Autowired + private AccountDeletionCompletionWorker accountDeletionCompletionWorker; + + private Long userId; + private Long otherUserId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = createUser("S02 撤销"); + otherUserId = createUser("S02 他人"); + } + + @AfterEach + void tearDown() { + accountDeletionRepository.findByUserId(userId).ifPresent(accountDeletionRepository::delete); + accountDeletionRepository.findByUserId(otherUserId).ifPresent(accountDeletionRepository::delete); + userRepository.deleteById(otherUserId); + userRepository.deleteById(userId); + } + + private Long createUser(String nickname) { + return userRepository.save(User.builder() + .phone("15" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname(nickname) + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + private AuthSessionService.SessionTokens sessionFor(Long uid) { + return authSessionService.createSession(userRepository.findById(uid).orElseThrow(), Instant.now(), null); + } + + // ── 单设备登出 ────────────────────────────────────────────── + + @Test + void logoutRevokesOnlyCurrentSession() { + AuthSessionService.SessionTokens deviceA = sessionFor(userId); + AuthSessionService.SessionTokens deviceB = sessionFor(userId); + assertThat(deviceA.sessionId()).isNotEqualTo(deviceB.sessionId()); + + authSessionService.logout(userId, deviceA.sessionId(), Instant.now(), null); + + assertThat(authSessionService.isSessionActive(userId, deviceA.sessionId(), Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(userId, deviceB.sessionId(), Instant.now())) + .as("其他设备会话不得被误伤").isTrue(); + } + + @Test + void repeatedLogoutIsIdempotent() { + AuthSessionService.SessionTokens device = sessionFor(userId); + + authSessionService.logout(userId, device.sessionId(), Instant.now(), null); + // 第二次登出不得抛异常 + authSessionService.logout(userId, device.sessionId(), Instant.now(), null); + + assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())).isFalse(); + } + + @Test + void logoutDoesNotAffectOtherUsers() { + AuthSessionService.SessionTokens mine = sessionFor(userId); + AuthSessionService.SessionTokens theirs = sessionFor(otherUserId); + + authSessionService.logout(userId, mine.sessionId(), Instant.now(), null); + + assertThat(authSessionService.isSessionActive(otherUserId, theirs.sessionId(), Instant.now())) + .as("其他用户会话不受影响").isTrue(); + } + + // ── 注销完成撤销全部会话 ──────────────────────────────────── + + @Test + void accountDeletionCompletionRevokesAllSessions() { + AuthSessionService.SessionTokens deviceA = sessionFor(userId); + AuthSessionService.SessionTokens deviceB = sessionFor(userId); + AuthSessionService.SessionTokens other = sessionFor(otherUserId); + + requestDeletionPastCooling(userId); + assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isTrue(); + + assertThat(authSessionService.isSessionActive(userId, deviceA.sessionId(), Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(userId, deviceB.sessionId(), Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(otherUserId, other.sessionId(), Instant.now())) + .as("其他用户会话不受影响").isTrue(); + } + + @Test + void pendingDeletionDoesNotForceLogout() { + AuthSessionService.SessionTokens device = sessionFor(userId); + + // 尚在冷静期(未到期)的 PENDING 不应撤销会话 + accountDeletionRepository.save(AccountDeletion.builder() + .userId(userId).status(DeletionStatus.PENDING) + .requestedAt(LocalDateTime.now()) + .effectiveAt(LocalDateTime.now().plusDays(30)) + .build()); + assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isFalse(); + + assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())) + .as("注销申请 PENDING 时不强制退出").isTrue(); + } + + @Test + void cancelledDeletionDoesNotRestoreRevokedSessions() { + AuthSessionService.SessionTokens device = sessionFor(userId); + + requestDeletionPastCooling(userId); + assertThat(accountDeletionCompletionWorker.completeIfExpired(userId)).isTrue(); + assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())).isFalse(); + + // 回退注销状态(等价于取消)不得让已撤销的会话复活 + AccountDeletion rec = accountDeletionRepository.findByUserId(userId).orElseThrow(); + rec.setStatus(DeletionStatus.NONE); + accountDeletionRepository.saveAndFlush(rec); + + assertThat(authSessionService.isSessionActive(userId, device.sessionId(), Instant.now())) + .as("取消注销不恢复旧 session").isFalse(); + } + + @Test + void revokeAllUserSessionsReportsAffectedRows() { + sessionFor(userId); + sessionFor(userId); + sessionFor(userId); + + int affected = authSessionService.revokeAllUserSessions( + userId, AuthSessionService.REASON_USER_DISABLED, Instant.now()); + + assertThat(affected).as("应撤销该用户全部活动会话").isGreaterThanOrEqualTo(3); + // 再次吊销:已无活动会话,受影响行数为 0(幂等) + assertThat(authSessionService.revokeAllUserSessions( + userId, AuthSessionService.REASON_USER_DISABLED, Instant.now())).isZero(); + } + + private void requestDeletionPastCooling(Long uid) { + accountDeletionRepository.save(AccountDeletion.builder() + .userId(uid).status(DeletionStatus.PENDING) + .requestedAt(LocalDateTime.now().minusDays(31)) + .effectiveAt(LocalDateTime.now().minusDays(1)) + .build()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java new file mode 100644 index 0000000..0512796 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java @@ -0,0 +1,118 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.audit.AuditService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +/** + * S02 会话服务的单元级语义(失败关闭、权限判定)。 + * + *

并发与持久化由集成测试覆盖;这里只验证拒绝路径不产生副作用。

+ */ +@ExtendWith(MockitoExtension.class) +class AuthSessionServiceTest { + + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + @Mock + private AuthTokenRepository authTokenRepository; + + @Mock + private AuthTokenHasher authTokenHasher; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @Mock + private UserRepository userRepository; + + @Mock + private AuditService auditService; + + @Mock + private PlatformTransactionManager transactionManager; + + @InjectMocks + private AuthSessionService authSessionService; + + private User activeUser; + + @BeforeEach + void setUp() { + activeUser = User.builder().id(1L).active(true).role(User.Role.STUDENT).build(); + } + + @Test + void isSessionActiveReturnsFalseForNullArguments() { + assertThat(authSessionService.isSessionActive(null, "sid", java.time.Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(1L, null, java.time.Instant.now())).isFalse(); + assertThat(authSessionService.isSessionActive(1L, " ", java.time.Instant.now())).isFalse(); + } + + @Test + void isSessionActiveReturnsFalseWhenUserMissingOrInactive() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + assertThat(authSessionService.isSessionActive(1L, "sid", java.time.Instant.now())).isFalse(); + + User inactive = User.builder().id(2L).active(false).role(User.Role.STUDENT).build(); + when(userRepository.findById(2L)).thenReturn(Optional.of(inactive)); + assertThat(authSessionService.isSessionActive(2L, "sid", java.time.Instant.now())).isFalse(); + + User nullActive = User.builder().id(3L).active(null).role(User.Role.STUDENT).build(); + when(userRepository.findById(3L)).thenReturn(Optional.of(nullActive)); + assertThat(authSessionService.isSessionActive(3L, "sid", java.time.Instant.now())).isFalse(); + } + + @Test + void isSessionActiveFailsClosedWhenDatabaseErrors() { + when(userRepository.findById(1L)).thenReturn(Optional.of(activeUser)); + when(authTokenRepository.countActiveSessionTokens(anyLong(), anyString(), any(LocalDateTime.class))) + .thenThrow(new DataAccessResourceFailureException("db down")); + + assertThat(authSessionService.isSessionActive(1L, "sid", java.time.Instant.now())) + .as("数据库异常必须失败关闭").isFalse(); + } + + @Test + void isSessionActiveTrueOnlyWhenActiveRecordExists() { + when(userRepository.findById(1L)).thenReturn(Optional.of(activeUser)); + when(authTokenRepository.countActiveSessionTokens(eq(1L), eq("sid"), any(LocalDateTime.class))) + .thenReturn(1L); + + assertThat(authSessionService.isSessionActive(1L, "sid", java.time.Instant.now())).isTrue(); + } + + @Test + void revokeAllUserSessionsReturnsZeroForNullUser() { + assertThat(authSessionService.revokeAllUserSessions( + null, AuthSessionService.REASON_USER_DISABLED, java.time.Instant.now())).isZero(); + } + + @Test + void logoutRejectsBlankSessionId() { + org.junit.jupiter.api.Assertions.assertThrows(InvalidTokenException.class, + () -> authSessionService.logout(1L, " ", java.time.Instant.now(), null)); + } + + @Test + void sessionRefIsIrreversibleAndFixedLength() { + when(authTokenHasher.hash(anyString())).thenReturn("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + String ref = authSessionService.sessionRef("session-1"); + assertThat(ref).hasSize(16); + assertThat(ref).matches("[0-9a-f]{16}"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenClockConsistencyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenClockConsistencyTest.java new file mode 100644 index 0000000..7b84c3a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenClockConsistencyTest.java @@ -0,0 +1,254 @@ +package com.mydreamjob.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.*; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 单次时间捕获(设计 D13,任务书 §十七)。 + * + *

使用 {@link AdvancingClock}:第一次读返回 T,之后返回明显不同的时间。 + * 只要某个入口读了两次 Clock,断言就会失败。每个入口(登录/刷新/登出) + * 分别验证:调用次数恰为 1,且 JWT、数据库与审计时间全部来自 T。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AuthTokenClockConsistencyTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9500); + private static final Instant T = Instant.parse("2026-01-02T03:04:05Z"); + /** 第二次及以后读取返回的时间:跨月,确保一旦被使用就能被识别。 */ + private static final Instant T_LATER = Instant.parse("2026-02-05T07:08:09Z"); + private static final ZoneId ZONE = AdvancingClock.ZONE; + + @TestConfiguration + static class AdvancingClockConfiguration { + @Bean + @Primary + AdvancingClock advancingClock() { + return new AdvancingClock(T, T_LATER); + } + } + + @MockBean + private RedisTemplate redisTemplate; + + @MockBean + private SmsVerificationService smsVerificationService; + + @Autowired + private AdvancingClock clock; + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${app.jwt.expiration-ms}") + private long accessTtlMs; + + @Value("${app.jwt.refresh-expiration-ms}") + private long refreshTtlMs; + + @Value("${app.jwt.access-secret}") + private String accessSecret; + + @Value("${app.jwt.refresh-secret}") + private String refreshSecret; + + private Long userId; + private String phone; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(smsVerificationService.verifyCode(any(), any(), any())).thenReturn(true); + + phone = "12" + String.format("%09d", PHONE_SEQ.incrementAndGet()); + userId = userRepository.save(User.builder() + .phone(phone) + .nickname("S02 时钟") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + clock.reset(); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM audit_logs WHERE action LIKE 'AUTH_%' AND user_id = ?", userId); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", userId); + if (userId != null) { + userRepository.deleteById(userId); + } + } + + // ── 工具 ──────────────────────────────────────────────────── + + private Claims rawClaims(String token, String secret) { + SecretKey key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + return Jwts.parser().verifyWith(key).clock(() -> java.util.Date.from(Instant.EPOCH)) + .build().parseSignedClaims(token).getPayload(); + } + + private LocalDateTime at(Instant instant) { + return LocalDateTime.ofInstant(instant, ZONE); + } + + private Timestamp queryTimestamp(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Timestamp.class, args); + } + + @SuppressWarnings("unchecked") + private Map login() { + return authService.mockLogin(phone, "123456", "login", "req-clock", null, null); + } + + // ── 登录 ──────────────────────────────────────────────────── + + @Test + void loginCapturesClockExactlyOnce() { + clock.reset(); + Map result = login(); + + assertThat(clock.callCount()).as("登录入口只能读取一次 Clock").isEqualTo(1); + + String access = (String) result.get("accessToken"); + String refresh = (String) result.get("refreshToken"); + + Claims accessClaims = rawClaims(access, accessSecret); + assertThat(accessClaims.getIssuedAt().toInstant()).isEqualTo(T); + assertThat(accessClaims.getExpiration().toInstant()).isEqualTo(T.plusMillis(accessTtlMs)); + + Claims refreshClaims = rawClaims(refresh, refreshSecret); + assertThat(refreshClaims.getIssuedAt().toInstant()).isEqualTo(T); + assertThat(refreshClaims.getExpiration().toInstant()).isEqualTo(T.plusMillis(refreshTtlMs)); + + String sessionId = refreshClaims.get("sid", String.class); + Timestamp createdAt = queryTimestamp( + "SELECT created_at FROM auth_tokens WHERE session_id = ?", sessionId); + Timestamp expiredAt = queryTimestamp( + "SELECT expired_at FROM auth_tokens WHERE session_id = ?", sessionId); + + assertThat(createdAt.toLocalDateTime()) + .as("auth_tokens.created_at 必须来自 capturedNow") + .isEqualTo(at(T)); + assertThat(expiredAt.toLocalDateTime()).isEqualTo(at(T.plusMillis(refreshTtlMs))); + + Timestamp auditCreatedAt = queryTimestamp( + "SELECT created_at FROM audit_logs WHERE action = 'AUTH_SESSION_CREATED' AND user_id = ?", userId); + assertThat(auditCreatedAt.toLocalDateTime()).isEqualTo(at(T)); + } + + // ── 刷新 ──────────────────────────────────────────────────── + + @Test + void refreshCapturesClockExactlyOnce() { + Map loginResult = login(); + String refreshToken = (String) loginResult.get("refreshToken"); + String sessionId = rawClaims(refreshToken, refreshSecret).get("sid", String.class); + + clock.reset(); + AuthSessionService.SessionTokens rotated = authService.refresh(refreshToken, "req-refresh", null, null); + + assertThat(clock.callCount()).as("刷新入口只能读取一次 Clock").isEqualTo(1); + + Claims rotatedClaims = rawClaims(rotated.accessToken(), accessSecret); + assertThat(rotatedClaims.getIssuedAt().toInstant()).isEqualTo(T); + assertThat(rotatedClaims.getExpiration().toInstant()).isEqualTo(T.plusMillis(accessTtlMs)); + + Timestamp consumedAt = queryTimestamp(""" + SELECT consumed_at FROM auth_tokens + WHERE session_id = ? AND consumed_at IS NOT NULL + """, sessionId); + assertThat(consumedAt.toLocalDateTime()).as("消费时间来自 capturedNow").isEqualTo(at(T)); + + Timestamp successorCreatedAt = queryTimestamp(""" + SELECT created_at FROM auth_tokens + WHERE session_id = ? AND consumed_at IS NULL + """, sessionId); + assertThat(successorCreatedAt.toLocalDateTime()).as("后继记录 created_at 来自 capturedNow").isEqualTo(at(T)); + + Timestamp auditCreatedAt = queryTimestamp( + "SELECT created_at FROM audit_logs WHERE action = 'AUTH_TOKEN_REFRESHED' AND user_id = ?", userId); + assertThat(auditCreatedAt.toLocalDateTime()).isEqualTo(at(T)); + } + + // ── 登出 ──────────────────────────────────────────────────── + + @Test + void logoutCapturesClockExactlyOnce() { + Map loginResult = login(); + String refreshToken = (String) loginResult.get("refreshToken"); + String sessionId = rawClaims(refreshToken, refreshSecret).get("sid", String.class); + + clock.reset(); + authService.logout(userId, sessionId, "req-logout", null, null); + + assertThat(clock.callCount()).as("登出入口只能读取一次 Clock").isEqualTo(1); + + Timestamp revokedAt = queryTimestamp( + "SELECT revoked_at FROM auth_tokens WHERE session_id = ?", sessionId); + assertThat(revokedAt.toLocalDateTime()).as("撤销时间来自 capturedNow").isEqualTo(at(T)); + + Timestamp auditCreatedAt = queryTimestamp( + "SELECT created_at FROM audit_logs WHERE action = 'AUTH_SESSION_REVOKED' AND user_id = ?", userId); + assertThat(auditCreatedAt.toLocalDateTime()).isEqualTo(at(T)); + } + + // ── 第二次读取的值不得混入 ────────────────────────────────── + + @Test + void laterClockValueNeverLeaksIntoPersistedStateOrAudit() { + clock.reset(); + login(); + + String laterText = at(T_LATER).toString(); + for (String column : new String[]{"created_at", "expired_at"}) { + String value = jdbcTemplate.queryForObject( + "SELECT CAST(" + column + " AS VARCHAR) FROM auth_tokens WHERE user_id = ?", String.class, userId); + assertThat(value).as(column + " 不得混入第二次读取的时钟值").doesNotContain(laterText); + } + String auditValue = jdbcTemplate.queryForObject( + "SELECT CAST(created_at AS VARCHAR) FROM audit_logs WHERE user_id = ? ORDER BY id DESC LIMIT 1", + String.class, userId); + assertThat(auditValue).as("审计时间不得混入第二次读取的时钟值").doesNotContain(laterText); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenHasherTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenHasherTest.java new file mode 100644 index 0000000..bedb521 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenHasherTest.java @@ -0,0 +1,69 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AppJwtProperties; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * S02 Refresh Token 摘要(设计 D07 / 第八节)。 + * + *

只验证确定性、长度与常量时间比较的语义;不依赖 Spring 上下文。

+ */ +class AuthTokenHasherTest { + + private static final String HASH_SECRET = "test-token-hash-secret-key-for-unit-tests-only-not-for-production"; + private static final String OTHER_SECRET = "another-token-hash-secret-key-for-unit-tests-only-not-for-prod"; + + private static AuthTokenHasher hasher(String secret) { + AppJwtProperties p = new AppJwtProperties(); + p.setAccessSecret("test-access-secret-key-for-unit-tests-only-not-for-production"); + p.setRefreshSecret("test-refresh-secret-key-for-unit-tests-only-not-for-production"); + p.setTokenHashSecret(secret); + return new AuthTokenHasher(p); + } + + @Test + void hashIsStableForSameInput() { + AuthTokenHasher hasher = hasher(HASH_SECRET); + String a = hasher.hash("refresh-token-value"); + String b = hasher.hash("refresh-token-value"); + assertEquals(a, b, "同一输入必须得到同一摘要"); + } + + @Test + void hashIsFixedLengthLowercaseHex() { + String hash = hasher(HASH_SECRET).hash("refresh-token-value"); + assertEquals(64, hash.length(), "HmacSHA256 十六进制摘要固定 64 字符"); + assertTrue(hash.matches("[0-9a-f]{64}"), "必须是规范小写十六进制"); + } + + @Test + void differentInputsProduceDifferentHashes() { + AuthTokenHasher hasher = hasher(HASH_SECRET); + assertNotEquals(hasher.hash("token-a"), hasher.hash("token-b")); + } + + @Test + void differentSecretsProduceDifferentHashes() { + String a = hasher(HASH_SECRET).hash("same-input"); + String b = hasher(OTHER_SECRET).hash("same-input"); + assertNotEquals(a, b, "摘要必须依赖密钥,不能是无密钥裸摘要"); + } + + @Test + void nullAndEmptyAreHandledDeterministically() { + AuthTokenHasher hasher = hasher(HASH_SECRET); + assertEquals(hasher.hash(""), hasher.hash(null)); + } + + @Test + void matchesIsConstantTimeComparisonSemantics() { + AuthTokenHasher hasher = hasher(HASH_SECRET); + String hash = hasher.hash("value"); + assertTrue(hasher.matches(hash, hash)); + assertFalse(hasher.matches(hash, hasher.hash("other"))); + assertFalse(hasher.matches(hash, null)); + assertFalse(hasher.matches(null, hash)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenRedactionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenRedactionTest.java new file mode 100644 index 0000000..84278b5 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenRedactionTest.java @@ -0,0 +1,180 @@ +package com.mydreamjob.auth; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 日志与审计脱敏(设计第十二节,任务书 §十六)。 + * + *

日志断言捕获**真实 Logback 输出**(不是 Mockito verify logger); + * 审计断言读取真实 audit_logs 行并校验 details 白名单。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +class AuthTokenRedactionTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9700); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthTokenHasher authTokenHasher; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long userId; + private ListAppender appender; + private Logger appLogger; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("13" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 脱敏") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + + appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); + appender = new ListAppender<>(); + appender.start(); + appLogger.addAppender(appender); + appLogger.setLevel(Level.TRACE); + } + + @AfterEach + void tearDown() { + if (appLogger != null && appender != null) { + appLogger.detachAppender(appender); + appender.stop(); + } + jdbcTemplate.update("DELETE FROM audit_logs WHERE action LIKE 'AUTH_%' AND user_id = ?", userId); + if (userId != null) { + userRepository.deleteById(userId); + } + } + + private String capturedLog() { + return appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .collect(Collectors.joining("\n")); + } + + @Test + void loginRefreshAndLogoutNeverLogTokensOrHashes() { + AuthSessionService.SessionTokens first = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthSessionService.SessionTokens rotated = + authSessionService.refresh(first.refreshToken(), Instant.now(), null); + authSessionService.logout(userId, rotated.sessionId(), Instant.now(), null); + + // 重放路径同样不得泄漏 + try { + authSessionService.refresh(first.refreshToken(), Instant.now(), null); + } catch (InvalidTokenException expected) { + // 预期 + } + + String log = capturedLog(); + for (String secret : List.of(first.accessToken(), first.refreshToken(), + rotated.accessToken(), rotated.refreshToken())) { + assertThat(log).as("日志不得包含 Token 原文").doesNotContain(secret); + } + String sessionHash = authTokenHasher.hash("sid:" + first.sessionId()); + assertThat(log).as("日志不得包含会话摘要").doesNotContain(sessionHash); + assertThat(log).doesNotContain("Bearer "); + assertThat(log).doesNotContain("Authorization"); + } + + @Test + void auditDetailsUseWhitelistOnly() { + AuthSessionService.SessionTokens first = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + AuthSessionService.SessionTokens rotated = + authSessionService.refresh(first.refreshToken(), Instant.now(), null); + authSessionService.logout(userId, rotated.sessionId(), Instant.now(), null); + try { + authSessionService.refresh(first.refreshToken(), Instant.now(), null); + } catch (InvalidTokenException expected) { + // 预期 + } + + List> rows = jdbcTemplate.queryForList( + "SELECT action, details FROM audit_logs WHERE user_id = ? AND action LIKE 'AUTH_%'", userId); + + assertThat(rows).as("应写入会话相关审计").isNotEmpty(); + for (Map row : rows) { + String action = (String) row.get("action"); + assertThat(action).isIn( + "AUTH_SESSION_CREATED", "AUTH_TOKEN_REFRESHED", + "AUTH_SESSION_REVOKED", "AUTH_REFRESH_REUSE_DETECTED"); + + String details = String.valueOf(row.get("details")); + assertThat(details).as("审计 details 不得包含 Token/摘要/手机号") + .doesNotContain("accessToken") + .doesNotContain("refreshToken") + .doesNotContain("Bearer") + .doesNotContain("tokenHash") + .doesNotContain(first.refreshToken()) + .doesNotContain(first.accessToken()); + // 只允许 sessionRef / reason 两个键 + assertThat(details).contains("\"sessionRef\""); + } + } + + @Test + void auditNeverContainsRawTokens() { + AuthSessionService.SessionTokens tokens = + authSessionService.createSession(userRepository.findById(userId).orElseThrow(), Instant.now(), null); + + List allDetails = jdbcTemplate.queryForList( + "SELECT details FROM audit_logs WHERE user_id = ?", String.class, userId); + + for (String details : allDetails) { + assertThat(details).doesNotContain(tokens.accessToken()); + assertThat(details).doesNotContain(tokens.refreshToken()); + // 数据库里的摘要也不得出现在审计中 + assertThat(details).doesNotContain(authTokenHasher.hash(tokens.refreshToken())); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java index 8424676..347a276 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java @@ -392,7 +392,7 @@ class AuthenticationBoundaryIntegrationTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } private String signedTokenWithSubject(String subject) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtAuthenticationTypeIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtAuthenticationTypeIntegrationTest.java new file mode 100644 index 0000000..e0a41fa --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtAuthenticationTypeIntegrationTest.java @@ -0,0 +1,270 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.company.Company; +import com.mydreamjob.company.CompanyRepository; +import com.mydreamjob.job.Job; +import com.mydreamjob.job.JobRepository; +import com.mydreamjob.job.JobStatus; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.ValueOperations; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * S02 认证过滤器的类型隔离与失败关闭(设计 D01/D14,任务书 §五 与 §十三)。 + * + *

真实 SecurityFilterChain + 真实 H2。核心断言: + * Refresh Token 不能访问普通业务接口,Access Token 不能用于刷新, + * 无 type / 未知 type / 非法 claim 一律 401,且响应不泄漏内部信息。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class JwtAuthenticationTypeIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9200); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CompanyRepository companyRepository; + + @Autowired + private JobRepository jobRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @org.springframework.beans.factory.annotation.Value("${app.jwt.access-secret}") + private String accessSecret; + + @org.springframework.beans.factory.annotation.Value("${app.jwt.refresh-secret}") + private String refreshSecret; + + private Long userId; + private Long jobId; + private Long companyId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + + userId = userRepository.save(User.builder() + .phone("18" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 类型隔离") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + + companyId = companyRepository.save(Company.builder() + .ownerUserId(userId).name("S02 公司").build()).getId(); + jobId = jobRepository.save(Job.builder() + .companyId(companyId).title("S02 岗位").description("d").city("杭州") + .employmentType("FULL_TIME").status(JobStatus.PUBLISHED.name()).build()).getId(); + } + + @AfterEach + void tearDown() { + jobRepository.deleteAll(jobRepository.findAll().stream() + .filter(j -> companyId != null && companyId.equals(j.getCompanyId())).toList()); + if (companyId != null) { + companyRepository.deleteById(companyId); + } + if (userId != null) { + userRepository.deleteById(userId); + } + } + + private AuthSessionService.SessionTokens newSession() { + User user = userRepository.findById(userId).orElseThrow(); + return authSessionService.createSession(user, Instant.now(), null); + } + + // ── 核心缺陷:Refresh Token 访问普通接口 ───────────────────── + + @Test + void refreshTokenCannotAuthenticateOrdinaryEndpoint() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.refreshToken())) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("Bearer")))) + .andExpect(content().string(not(containsString("com.mydreamjob")))); + } + + @Test + void accessTokenCanAuthenticateOrdinaryEndpoint() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isOk()); + } + + // ── Access 不能用于刷新 ───────────────────────────────────── + + @Test + void accessTokenCannotBeUsedAtRefreshEndpoint() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + + mockMvc.perform(post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"" + tokens.accessToken() + "\"}")) + .andExpect(status().isUnauthorized()); + } + + // ── 无 type / 未知 type / 非法 claim ──────────────────────── + + @Test + void legacyTokenWithoutTypeIsRejected() throws Exception { + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + legacyToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void unknownTypeIsRejected() throws Exception { + String token = Jwts.builder() + .subject(String.valueOf(userId)) + .id(UUID.randomUUID().toString()) + .claim("type", "ACCESS") + .claim("sid", UUID.randomUUID().toString()) + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 3_600_000L)) + .signWith(key(accessSecret)) + .compact(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + } + + @Test + void nonPositiveSubjectIsRejectedWithout500() throws Exception { + String token = Jwts.builder() + .subject("-1") + .id(UUID.randomUUID().toString()) + .claim("type", "access") + .claim("sid", UUID.randomUUID().toString()) + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 3_600_000L)) + .signWith(key(accessSecret)) + .compact(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("NumberFormatException")))) + .andExpect(content().string(not(containsString("Exception")))); + } + + @Test + void malformedTokenIsRejectedWithoutLeakingInternals() throws Exception { + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer not.a.jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(content().string(not(containsString("io.jsonwebtoken")))) + .andExpect(content().string(not(containsString("com.mydreamjob")))); + } + + // ── 撤销与禁用 ────────────────────────────────────────────── + + @Test + void revokedSessionCannotAuthenticate() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isOk()); + + authSessionService.logout(userId, tokens.sessionId(), Instant.now(), null); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void inactiveUserCannotAuthenticate() throws Exception { + AuthSessionService.SessionTokens tokens = newSession(); + + User user = userRepository.findById(userId).orElseThrow(); + user.setActive(false); + userRepository.saveAndFlush(user); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + tokens.accessToken())) + .andExpect(status().isUnauthorized()); + } + + @Test + void tokenForUnknownUserIsRejected() throws Exception { + // 签名合法、claim 齐备,但 sub 指向不存在的用户:服务端会话校验必须失败关闭。 + long unknownUserId = 999_999_999L; + String token = jwtTokenProvider.generateAccessToken( + unknownUserId, "STUDENT", UUID.randomUUID().toString(), UUID.randomUUID().toString(), Instant.now()) + .token(); + + mockMvc.perform(get("/auth/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + } + + // ── 工具 ──────────────────────────────────────────────────── + + private static SecretKey key(String secret) { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** 迁移前签发的旧格式 Token:无 type、无 sid、无 jti、无 iss/aud。 */ + private String legacyToken() { + return Jwts.builder() + .subject(String.valueOf(userId)) + .claim("phone", "13800000000") + .claim("role", "STUDENT") + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 3_600_000L)) + .signWith(key(accessSecret)) + .compact(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtSecretOptionalStartupIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtSecretOptionalStartupIntegrationTest.java new file mode 100644 index 0000000..0d0a337 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtSecretOptionalStartupIntegrationTest.java @@ -0,0 +1,88 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AppJwtProperties; +import org.junit.jupiter.api.Test; +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.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * S02 复核项:只配置三个新密钥、**不配置旧 JWT_SECRET** 时应用仍可启动(任务书 §五)。 + * + *

旧 {@code app.jwt.secret} 对 S02 的 Token 已无任何作用,但仍被 + * {@code talentdiscovery.CandidateKeyGenerator} 使用(该组件在密钥为空时自行失败关闭), + * 因此保留属性但改为非强制解析。本测试证明: + * 1) 旧密钥为空时全量上下文仍能启动;2) 三项 S02 密钥正确绑定; + * 3) {@code application.yml} 中的旧密钥占位符带默认值,不再阻塞启动。

+ */ +@SpringBootTest(properties = { + "app.jwt.secret=", + "app.jwt.access-secret=test-access-secret-key-for-integration-tests-only-not-for-production", + "app.jwt.refresh-secret=test-refresh-secret-key-for-integration-tests-only-not-for-production", + "app.jwt.token-hash-secret=test-token-hash-secret-key-for-integration-tests-only-not-for-production", +}) +@org.springframework.test.context.ActiveProfiles("integration-test") +class JwtSecretOptionalStartupIntegrationTest { + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private AppJwtProperties jwtProperties; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private AuthSessionService authSessionService; + + @Test + void contextStartsWithOnlyTheThreeNewSecrets() { + assertThat(applicationContext).isNotNull(); + assertThat(jwtTokenProvider).isNotNull(); + assertThat(authSessionService).isNotNull(); + + assertThat(jwtProperties.getSecret()) + .as("旧密钥为空也必须能启动").isNullOrEmpty(); + assertThat(jwtProperties.getAccessSecret()).isNotBlank(); + assertThat(jwtProperties.getRefreshSecret()).isNotBlank(); + assertThat(jwtProperties.getTokenHashSecret()).isNotBlank(); + assertThat(jwtProperties.getAccessSecret()) + .isNotEqualTo(jwtProperties.getRefreshSecret()); + } + + @Test + void accessAndRefreshNeverFallBackToLegacySecret() throws Exception { + // 旧密钥为空时,S02 的两类 Token 仍必须能正常签发与校验 —— 证明没有回退到它。 + Instant now = Instant.now(); + String sid = java.util.UUID.randomUUID().toString(); + IssuedToken access = jwtTokenProvider.generateAccessToken( + 1L, "STUDENT", sid, java.util.UUID.randomUUID().toString(), now); + IssuedToken refresh = jwtTokenProvider.generateRefreshToken( + 1L, sid, java.util.UUID.randomUUID().toString(), now); + + assertThat(jwtTokenProvider.parseAndValidateAccessToken(access.token(), now).userId()).isEqualTo(1L); + assertThat(jwtTokenProvider.parseAndValidateRefreshToken(refresh.token(), now).userId()).isEqualTo(1L); + } + + @Test + void applicationYmlNoLongerForcesLegacySecret() throws Exception { + Path yml = Path.of("src/main/resources/application.yml"); + String content = Files.readString(yml); + assertThat(content) + .as("旧密钥占位符必须带默认值,否则未配置 JWT_SECRET 的环境无法启动") + .contains("secret: ${JWT_SECRET:}"); + assertThat(content).doesNotContain("secret: ${JWT_SECRET}\n"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java index 027fd71..8206512 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java @@ -1,54 +1,85 @@ package com.mydreamjob.auth; -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; +import com.mydreamjob.config.AppJwtProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.nio.charset.StandardCharsets; -import java.util.Map; +import java.time.Instant; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; +/** + * S02 {@link JwtTokenProvider} 基础行为。 + * + *

原用例(生成、校验、过期、非法 Token)在 S02 新 API 下重写, + * 断言语义保持不变:合法 Token 可解析出用户、过期与非法 Token 一律拒绝。

+ * + *

Access/Refresh 的类型与密钥隔离矩阵见 {@code JwtTokenTypeIsolationTest}。

+ */ class JwtTokenProviderTest { - private JwtTokenProvider jwtTokenProvider; + private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z"); + + private JwtTokenProvider provider; @BeforeEach void setUp() { - String secret = "test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security"; - jwtTokenProvider = new JwtTokenProvider(secret, 3600000L, 86400000L); + provider = new JwtTokenProvider(properties(3_600_000L, 86_400_000L)); + } + + static AppJwtProperties properties(long accessMs, long refreshMs) { + AppJwtProperties p = new AppJwtProperties(); + p.setAccessSecret("test-access-secret-key-for-unit-tests-only-not-for-production"); + p.setRefreshSecret("test-refresh-secret-key-for-unit-tests-only-not-for-production"); + p.setTokenHashSecret("test-token-hash-secret-key-for-unit-tests-only-not-for-production"); + p.setExpirationMs(accessMs); + p.setRefreshExpirationMs(refreshMs); + p.setIssuer("mydreamjob"); + p.setAccessAudience("mydreamjob-api"); + p.setRefreshAudience("mydreamjob-auth"); + return p; + } + + static String uuid() { + return UUID.randomUUID().toString(); } @Test void testTokenGenerationAndValidation() { - String token = jwtTokenProvider.generateAccessToken(1L, "13800000001", Map.of("role", "STUDENT")); - assertNotNull(token); - assertTrue(jwtTokenProvider.validateToken(token)); - assertEquals(1L, jwtTokenProvider.parseUserId(token)); + IssuedToken issued = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + + assertNotNull(issued.token()); + AccessTokenClaims claims = provider.parseAndValidateAccessToken(issued.token(), NOW); + assertEquals(1L, claims.userId()); + assertEquals("STUDENT", claims.role()); + assertEquals(issued.sessionId(), claims.sessionId()); + assertEquals(issued.tokenId(), claims.tokenId()); } @Test void testRefreshToken() { - String token = jwtTokenProvider.generateRefreshToken(2L, "13800000002"); - assertNotNull(token); - assertTrue(jwtTokenProvider.validateToken(token)); - assertEquals(2L, jwtTokenProvider.parseUserId(token)); + IssuedToken issued = provider.generateRefreshToken(2L, uuid(), uuid(), NOW); + + assertNotNull(issued.token()); + RefreshTokenClaims claims = provider.parseAndValidateRefreshToken(issued.token(), NOW); + assertEquals(2L, claims.userId()); + assertEquals(issued.tokenId(), claims.tokenId()); } @Test void testExpiredToken() { - JwtTokenProvider shortLived = new JwtTokenProvider( - "test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security", 1L, 86400000L); - try { - Thread.sleep(10); - } catch (InterruptedException ignored) {} - String token = shortLived.generateAccessToken(1L, "13800000001", null); - assertTrue(shortLived.isTokenExpired(token)); + // exp = NOW + 1ms;在 NOW + 2ms 校验即过期(边界为 exp <= capturedNow)。 + JwtTokenProvider shortLived = new JwtTokenProvider(properties(1L, 86_400_000L)); + IssuedToken issued = shortLived.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + + assertThrows(InvalidTokenException.class, () -> + shortLived.parseAndValidateAccessToken(issued.token(), NOW.plusMillis(2))); } @Test void testInvalidToken() { - assertFalse(jwtTokenProvider.validateToken("invalid-token")); + assertThrows(InvalidTokenException.class, () -> + provider.parseAndValidateAccessToken("invalid-token", NOW)); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenTypeIsolationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenTypeIsolationTest.java new file mode 100644 index 0000000..621371f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenTypeIsolationTest.java @@ -0,0 +1,260 @@ +package com.mydreamjob.auth; + +import com.mydreamjob.config.AppJwtProperties; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * S02 Token 类型与密钥隔离(设计 D01 / D02 / D06,任务书 §五 与 §十九)。 + * + *

覆盖:类型 claim、独立签名密钥、issuer/audience、subject/sid/jti 校验、 + * 过期边界,以及新 Token 不再包含手机号。

+ */ +class JwtTokenTypeIsolationTest { + + private static final String ACCESS_SECRET = "test-access-secret-key-for-unit-tests-only-not-for-production"; + private static final String REFRESH_SECRET = "test-refresh-secret-key-for-unit-tests-only-not-for-production"; + private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z"); + + private JwtTokenProvider provider; + + @BeforeEach + void setUp() { + provider = new JwtTokenProvider(JwtTokenProviderTest.properties(3_600_000L, 86_400_000L)); + } + + private static String uuid() { + return UUID.randomUUID().toString(); + } + + // ── 类型隔离 ──────────────────────────────────────────────── + + @Test + void accessTokenCanOnlyBeParsedAsAccess() { + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + + AccessTokenClaims claims = provider.parseAndValidateAccessToken(access.token(), NOW); + assertEquals(1L, claims.userId()); + // accessTokenCannotBeUsedAtRefreshEndpoint(Provider 层) + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateRefreshToken(access.token(), NOW)); + } + + @Test + void refreshTokenCanOnlyBeParsedAsRefresh() { + IssuedToken refresh = provider.generateRefreshToken(1L, uuid(), uuid(), NOW); + + RefreshTokenClaims claims = provider.parseAndValidateRefreshToken(refresh.token(), NOW); + assertEquals(1L, claims.userId()); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(refresh.token(), NOW)); + } + + @Test + void accessAndRefreshTokensCannotCrossVerify() { + // 即使忽略 type,两类 Token 也因使用不同密钥而无法交叉验签(第二道隔离)。 + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + IssuedToken refresh = provider.generateRefreshToken(1L, uuid(), uuid(), NOW); + + String strippedAccess = resign(access.token(), ACCESS_SECRET, b -> b.claim("type", "refresh")); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateRefreshToken(strippedAccess, NOW)); + + String strippedRefresh = resign(refresh.token(), REFRESH_SECRET, b -> b.claim("type", "access")); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(strippedRefresh, NOW)); + } + + // ── 旧 Token / 未知 type ──────────────────────────────────── + + @Test + void legacyTokenWithoutTypeIsRejected() { + String legacy = Jwts.builder() + .subject("1") + .id(uuid()) + .claim("sid", uuid()) + .claim("role", "STUDENT") + .issuedAt(Date.from(NOW)) + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(secretKey(ACCESS_SECRET)) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(legacy, NOW)); + } + + @Test + void unknownTypeIsRejected() { + String unknown = Jwts.builder() + .subject("1") + .id(uuid()) + .claim("type", "ACCESS") // 大小写不匹配,必须区分大小写 + .claim("sid", uuid()) + .claim("role", "STUDENT") + .issuedAt(Date.from(NOW)) + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(secretKey(ACCESS_SECRET)) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(unknown, NOW)); + } + + // ── issuer / audience ─────────────────────────────────────── + + @Test + void wrongIssuerIsRejected() { + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + String wrongIssuer = resign(access.token(), ACCESS_SECRET, b -> b.issuer("somebody-else")); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(wrongIssuer, NOW)); + } + + @Test + void wrongAudienceIsRejected() { + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + String wrongAudience = resign(access.token(), ACCESS_SECRET, + b -> b.audience().clear().add("wrong-audience").and()); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(wrongAudience, NOW)); + } + + // ── claim 合法性 ──────────────────────────────────────────── + + @Test + void nonPositiveOrNonNumericSubjectIsRejected() { + for (String subject : new String[]{"0", "-5", "abc", ""}) { + String token = baseAccess(subject, uuid(), uuid()); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(token, NOW), + "subject 非法必须拒绝: " + subject); + } + } + + @Test + void nonUuidSidOrJtiIsRejected() { + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(baseAccess("1", "not-a-uuid", uuid()), NOW)); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(baseAccess("1", uuid(), "not-a-uuid"), NOW)); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(baseAccess("1", null, uuid()), NOW)); + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(baseAccess("1", uuid(), null), NOW)); + } + + @Test + void accessTokenWithoutRoleIsRejected() { + String token = Jwts.builder() + .subject("1") + .id(uuid()) + .claim("type", "access") + .claim("sid", uuid()) + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(Date.from(NOW)) + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(secretKey(ACCESS_SECRET)) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(token, NOW)); + } + + // ── 过期边界 ──────────────────────────────────────────────── + + @Test + void expiryBoundaryIsExpLessOrEqualCapturedNow() { + JwtTokenProvider p = new JwtTokenProvider(JwtTokenProviderTest.properties(60_000L, 60_000L)); + IssuedToken access = p.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + Instant expiresAt = access.expiresAt(); + + // 边界前一刻仍有效 + assertEquals(1L, p.parseAndValidateAccessToken(access.token(), expiresAt.minusMillis(1)).userId()); + // 恰好到期即视为过期 + assertThrows(InvalidTokenException.class, + () -> p.parseAndValidateAccessToken(access.token(), expiresAt)); + assertThrows(InvalidTokenException.class, + () -> p.parseAndValidateAccessToken(access.token(), expiresAt.plusMillis(1))); + } + + // ── 不含手机号 ────────────────────────────────────────────── + + @Test + void newTokensDoNotContainPhone() { + IssuedToken access = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); + IssuedToken refresh = provider.generateRefreshToken(1L, uuid(), uuid(), NOW); + + for (var entry : java.util.Map.of(access.token(), ACCESS_SECRET, refresh.token(), REFRESH_SECRET).entrySet()) { + var payload = rawParse(entry.getKey(), entry.getValue()); + assertFalse(payload.containsKey("phone"), "S02 新 Token 不得包含手机号"); + assertFalse(payload.containsKey("email")); + assertFalse(payload.containsKey("nickname")); + assertFalse(payload.containsKey("resumeUrl")); + } + } + + // ── 工具 ──────────────────────────────────────────────────── + + private static SecretKey secretKey(String secret) { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** 以合法 access token 的 claim 为底,替换 subject/sid/jti 后重新签名。 */ + private static String baseAccess(String subject, String sid, String jti) { + var b = Jwts.builder() + .claim("type", "access") + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(Date.from(NOW)) + .expiration(Date.from(NOW.plusSeconds(3600))); + if (subject != null) { + b.subject(subject); + } + if (sid != null) { + b.claim("sid", sid); + } + if (jti != null) { + b.id(jti); + } + return b.signWith(secretKey(ACCESS_SECRET)).compact(); + } + + /** 复制一个已签名 token 的 claim 并改写后重新签名(模拟攻击者无密钥伪造的等价物)。 */ + private static String resign(String token, String secret, + java.util.function.UnaryOperator mutate) { + var claims = rawParse(token, secret); + var builder = Jwts.builder() + .subject(claims.getSubject()) + .id(claims.getId()) + .issuedAt(claims.getIssuedAt()) + .expiration(claims.getExpiration()); + for (var e : claims.entrySet()) { + builder.claim(e.getKey(), e.getValue()); + } + return mutate.apply(builder).signWith(secretKey(secret)).compact(); + } + + /** + * 原始解析:仅用于测试构造与断言,时钟固定在很早的时间以免被真实时间判定过期。 + */ + private static io.jsonwebtoken.Claims rawParse(String token, String secret) { + return Jwts.parser() + .verifyWith(secretKey(secret)) + .clock(() -> Date.from(Instant.EPOCH)) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02InactiveUserSessionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02InactiveUserSessionTest.java new file mode 100644 index 0000000..4f5ba42 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02InactiveUserSessionTest.java @@ -0,0 +1,121 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * S02 复核项:不可用(active=false)用户不得获得会话或 Token(任务书 §五)。 + * + *

要求**失败关闭**:不能先签发 Token 再依赖认证过滤器拒绝。

+ */ +@SpringBootTest +@ActiveProfiles("integration-test") +class S02InactiveUserSessionTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9300); + + @MockBean + private RedisTemplate redisTemplate; + + @MockBean + private SmsVerificationService smsVerificationService; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long inactiveUserId; + private String phone; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations ops = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(ops); + when(redisTemplate.hasKey(anyString())).thenReturn(false); + when(smsVerificationService.verifyCode(any(), any(), any())).thenReturn(true); + + phone = "11" + String.format("%09d", PHONE_SEQ.incrementAndGet()); + inactiveUserId = userRepository.save(User.builder() + .phone(phone) + .nickname("S02 停用用户") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(false) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + jdbcTemplate.update("DELETE FROM audit_logs WHERE user_id = ?", inactiveUserId); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", inactiveUserId); + if (inactiveUserId != null) { + userRepository.deleteById(inactiveUserId); + } + } + + private long tokenRows() { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Long.class, inactiveUserId); + } + + @Test + void inactiveUserCannotCreateSession() { + User inactive = userRepository.findById(inactiveUserId).orElseThrow(); + + assertThatThrownBy(() -> authSessionService.createSession(inactive, Instant.now(), null)) + .as("停用用户不得创建会话") + .isInstanceOf(InvalidTokenException.class); + + assertThat(tokenRows()).as("不得写入任何 auth_tokens 记录").isZero(); + } + + @Test + void inactiveUserLoginDoesNotReturnTokenPair() { + assertThatThrownBy(() -> authService.mockLogin(phone, "123456", "login", "req", null, null)) + .as("停用用户登录必须失败关闭"); + + assertThat(tokenRows()).as("登录失败不得留下 Token 记录").isZero(); + } + + @Test + void inactiveUserLoginDoesNotPersistAuthToken() { + try { + Map result = authService.mockLogin(phone, "123456", "login", "req", null, null); + // 若未来语义变化为返回结果,则绝不允许携带 Token + assertThat(result).doesNotContainKeys("accessToken", "refreshToken"); + } catch (RuntimeException expected) { + // 预期路径:失败关闭 + } + assertThat(tokenRows()).isZero(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentClockAcceptanceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentClockAcceptanceTest.java new file mode 100644 index 0000000..5fc8cfd --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentClockAcceptanceTest.java @@ -0,0 +1,86 @@ +package com.mydreamjob.auth; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** Independent S02 acceptance reproduction for persisted timestamp consistency. */ +@SpringBootTest +@ActiveProfiles("integration-test") +class S02IndependentClockAcceptanceTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(9800); + private static final Instant CAPTURED_NOW = Instant.parse("2026-01-02T03:04:05Z"); + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private UserRepository userRepository; + + @Autowired + private AuthSessionService authSessionService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long userId; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations operations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(operations); + + userId = userRepository.save(User.builder() + .phone("12" + String.format("%09d", PHONE_SEQ.incrementAndGet())) + .nickname("S02 independent clock") + .role(User.Role.STUDENT) + .roles(Set.of(User.Role.STUDENT)) + .active(true) + .verified(true) + .build()).getId(); + } + + @AfterEach + void tearDown() { + if (userId != null) { + jdbcTemplate.update("DELETE FROM audit_logs WHERE user_id = ?", userId); + jdbcTemplate.update("DELETE FROM auth_tokens WHERE user_id = ?", userId); + userRepository.deleteById(userId); + } + } + + @Test + void persistedTokenCreatedAtMustUseCapturedNow() { + AuthSessionService.SessionTokens tokens = authSessionService.createSession( + userRepository.findById(userId).orElseThrow(), CAPTURED_NOW, null); + + Timestamp createdAt = jdbcTemplate.queryForObject( + "SELECT created_at FROM auth_tokens WHERE session_id = ?", + Timestamp.class, + tokens.sessionId()); + + assertThat(createdAt.toLocalDateTime()) + .isEqualTo(LocalDateTime.ofInstant(CAPTURED_NOW, ZONE)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentTokenValidationAcceptanceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentTokenValidationAcceptanceTest.java new file mode 100644 index 0000000..43bd8da --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentTokenValidationAcceptanceTest.java @@ -0,0 +1,65 @@ +package com.mydreamjob.auth; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** Independent S02 acceptance reproductions; not part of the implementation test suite. */ +class S02IndependentTokenValidationAcceptanceTest { + + private static final String ACCESS_SECRET = + "test-access-secret-key-for-unit-tests-only-not-for-production"; + private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z"); + + private JwtTokenProvider provider; + + @BeforeEach + void setUp() { + provider = new JwtTokenProvider(JwtTokenProviderTest.properties(3_600_000L, 86_400_000L)); + } + + @Test + void accessTokenWithoutIssuedAtMustBeRejected() { + String token = Jwts.builder() + .subject("1") + .id(UUID.randomUUID().toString()) + .claim("type", "access") + .claim("sid", UUID.randomUUID().toString()) + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(Keys.hmacShaKeyFor(ACCESS_SECRET.getBytes(StandardCharsets.UTF_8))) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(token, NOW)); + } + + @Test + void accessTokenIssuedInFutureMustBeRejected() { + String token = Jwts.builder() + .subject("1") + .id(UUID.randomUUID().toString()) + .claim("type", "access") + .claim("sid", UUID.randomUUID().toString()) + .claim("role", "STUDENT") + .issuer("mydreamjob") + .audience().add("mydreamjob-api").and() + .issuedAt(Date.from(NOW.plusSeconds(60))) + .expiration(Date.from(NOW.plusSeconds(3600))) + .signWith(Keys.hmacShaKeyFor(ACCESS_SECRET.getBytes(StandardCharsets.UTF_8))) + .compact(); + + assertThrows(InvalidTokenException.class, + () -> provider.parseAndValidateAccessToken(token, NOW)); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java new file mode 100644 index 0000000..d397a90 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java @@ -0,0 +1,176 @@ +package com.mydreamjob.auth.support; + +import com.mydreamjob.auth.AuthTokenHasher; +import com.mydreamjob.auth.IssuedToken; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.config.AppJwtProperties; +import org.springframework.jdbc.core.JdbcTemplate; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 测试专用:签发**可通过 S02 过滤器**的 Access Token。 + * + *

S02 之后认证过滤器除验签外还要求:{@code type=access}、由 access 密钥签发、 + * 且对应一个真实存在的活动登录会话与可用用户。既有测试大量直接调用 + * {@code jwtTokenProvider.generateAccessToken(userId, phone, claims)}, + * 在新规则下会全部 401;本类让它们只需替换辅助方法体。

+ * + *

为什么要按数据库登记

+ *

Spring 会按类缓存测试上下文,而本仓库允许用 {@code @TestPropertySource} 覆盖 + * {@code spring.datasource.url} 隔离各自的 H2 库(例如 {@code CareerProgressControllerTest} + * 使用 {@code integrationdb_careerprogress})。因此夹具必须记住**每一个**出现过的测试库, + * 并在签发时把会话行写入全部已知库 —— 这样无论当前测试读的是哪个库,都能看到会话。 + * 相比「只记第一个」或「记最后一个」,这是唯一与上下文缓存顺序无关的做法。

+ * + *

夹具用固定有效期构造自己的 {@link JwtTokenProvider},不读取任何注入的 Clock, + * 因此不受 {@code @MockBean Clock} 影响。

+ * + *

仅存在于 src/test,不会进入生产制品。

+ */ +public final class TestAuthTokens { + + /** 与生产一致的固定业务时区。 */ + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + /** + * 夹具签发时间:固定为足够早的过去时刻。 + * + *

S02 的解析契约是 {@code iat <= capturedNow < exp},而部分既有测试会把 + * {@code Clock} 固定在一个较早的时间(例如消息撤回的 HTTP 集成测试)。 + * 若夹具用"真实当前时间"签发,这些测试下就会命中"iat 在未来"而被拒绝。 + * 因此夹具统一使用一个远早于所有测试时钟的 iat,配合超长有效期。

+ */ + private static final Instant FIXTURE_BASE = Instant.parse("2020-01-01T00:00:00Z"); + + /** 夹具有效期:30 年,覆盖 2020–2050 之间的任何测试时钟。 */ + private static final long FIXTURE_TTL_MS = 30L * 365 * 24 * 60 * 60 * 1000; + + /** 已登记的测试库:JDBC URL -> 写入器。 */ + private static final Map LIBRARIES = new ConcurrentHashMap<>(); + + private TestAuthTokens() { + } + + private record Library(JdbcTemplate jdbc, JwtTokenProvider provider, AuthTokenHasher hasher) { + } + + /** + * 登记一个测试上下文使用的数据库。由 + * {@link TestAuthTokensAutoConfiguration} 在每个上下文启动时调用;同一 URL 只登记一次。 + */ + static void register(DataSource dataSource, AppJwtProperties source) { + String url = jdbcUrl(dataSource); + if (url == null) { + return; + } + LIBRARIES.computeIfAbsent(url, key -> { + AppJwtProperties fixture = new AppJwtProperties(); + fixture.setAccessSecret(source.getAccessSecret()); + fixture.setRefreshSecret(source.getRefreshSecret()); + fixture.setTokenHashSecret(source.getTokenHashSecret()); + fixture.setIssuer(source.getIssuer()); + fixture.setAccessAudience(source.getAccessAudience()); + fixture.setRefreshAudience(source.getRefreshAudience()); + fixture.setExpirationMs(FIXTURE_TTL_MS); + fixture.setRefreshExpirationMs(FIXTURE_TTL_MS); + return new Library(new JdbcTemplate(dataSource), + new JwtTokenProvider(fixture), new AuthTokenHasher(fixture)); + }); + } + + /** 返回可直接放入 Authorization 头的完整值。 */ + public static String bearer(Long userId, String role) { + return "Bearer " + accessToken(userId, role); + } + + /** 常用角色重载。 */ + public static String bearer(Long userId, Enum role) { + return bearer(userId, role == null ? "STUDENT" : role.name()); + } + + /** 返回 Access Token 原文。 */ + public static synchronized String accessToken(Long userId, String role) { + if (LIBRARIES.isEmpty()) { + throw new IllegalStateException( + "TestAuthTokens is not initialized: no test datasource was registered"); + } + String effectiveRole = (role == null || role.isBlank()) ? "STUDENT" : role; + Library mint = LIBRARIES.values().iterator().next(); + + String sessionId = UUID.randomUUID().toString(); + IssuedToken access = mint.provider() + .generateAccessToken(userId, effectiveRole, sessionId, UUID.randomUUID().toString(), FIXTURE_BASE); + IssuedToken refresh = mint.provider() + .generateRefreshToken(userId, sessionId, UUID.randomUUID().toString(), FIXTURE_BASE); + + String refreshHash = mint.hasher().hash(refresh.token()); + LocalDateTime expiresAt = LocalDateTime.ofInstant(refresh.expiresAt(), BUSINESS_ZONE); + LocalDateTime now = LocalDateTime.now(BUSINESS_ZONE); + + // 写入所有已知测试库:当前测试读哪个库都能看到该会话。 + boolean written = false; + for (Map.Entry entry : LIBRARIES.entrySet()) { + try { + writeSession(entry.getValue().jdbc(), userId, effectiveRole, refreshHash, + refresh.tokenId(), expiresAt, sessionId, now); + written = true; + } catch (RuntimeException e) { + // 已失效的上下文(连接池关闭等)自行登记移除,不影响其余库。 + LIBRARIES.remove(entry.getKey()); + } + } + if (!written) { + throw new IllegalStateException( + "TestAuthTokens could not write a session into any registered test datasource"); + } + return access.token(); + } + + private static void writeSession(JdbcTemplate jdbc, Long userId, String role, String refreshHash, + String refreshJti, LocalDateTime expiresAt, + String sessionId, LocalDateTime now) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, userId); + if (count == null || count == 0) { + // 既有测试常用固定 userId(如 100)但从不建用户行;S02 过滤器会校验用户存在且 active。 + // phone 使用非数字前缀,避免与真实测试号的唯一约束冲突。 + jdbc.update( + "INSERT INTO users (id, phone, nickname, role, is_active, is_verified) VALUES (?, ?, ?, ?, 1, 1)", + userId, "t" + userId, "test-user-" + userId, role); + } + jdbc.update(""" + INSERT INTO auth_tokens + (user_id, token_hash, token_type, expired_at, revoked, + session_id, token_jti, version, created_at) + VALUES (?, ?, 'REFRESH', ?, 0, ?, ?, 0, CURRENT_TIMESTAMP) + """, + userId, refreshHash, Timestamp.valueOf(expiresAt), sessionId, refreshJti); + + Integer visible = jdbc.queryForObject(""" + SELECT COUNT(*) FROM auth_tokens + WHERE user_id = ? AND session_id = ? AND revoked = 0 + AND consumed_at IS NULL AND expired_at > ? + """, + Integer.class, userId, sessionId, Timestamp.valueOf(now)); + if (visible == null || visible == 0) { + throw new IllegalStateException("fixture session not visible"); + } + } + + private static String jdbcUrl(DataSource dataSource) { + try (Connection connection = dataSource.getConnection()) { + return connection.getMetaData().getURL(); + } catch (SQLException e) { + return null; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokensAutoConfiguration.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokensAutoConfiguration.java new file mode 100644 index 0000000..8549b69 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokensAutoConfiguration.java @@ -0,0 +1,38 @@ +package com.mydreamjob.auth.support; + +import com.mydreamjob.config.AppJwtProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +import javax.sql.DataSource; + +/** + * 测试专用自动配置:把上下文里的 {@link DataSource} 与 JWT 配置登记给 {@link TestAuthTokens}。 + * + *

使用 {@code @AutoConfiguration}(而非 {@code spring.factories} 的 + * {@code TestExecutionListener}):后者会替换 Spring 的默认执行监听器, + * 导致依赖注入等基础能力失效。

+ * + *

{@code @WebMvcTest} 切片没有 DataSource 与 JWT 配置,通过 + * {@code ObjectProvider} 安全跳过(不用 {@code @ConditionalOnBean}: + * 它在自动配置阶段顺序敏感,可能先于 DataSourceAutoConfiguration 求值)。

+ */ +@AutoConfiguration +public class TestAuthTokensAutoConfiguration { + + @Bean + public TestAuthTokensInstaller testAuthTokensInstaller( + org.springframework.beans.factory.ObjectProvider dataSource, + org.springframework.beans.factory.ObjectProvider jwtProperties) { + DataSource ds = dataSource.getIfAvailable(); + AppJwtProperties props = jwtProperties.getIfAvailable(); + if (ds != null && props != null) { + TestAuthTokens.register(ds, props); + } + return new TestAuthTokensInstaller(); + } + + /** 仅用于确保自动配置被加载的占位 Bean。 */ + public static final class TestAuthTokensInstaller { + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java index 8012fa3..a922bfb 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java @@ -58,11 +58,11 @@ class ApplicationMatchControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(COMPANY_ID, "3", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_ID, "COMPANY"); } private ApplicationMatchRecord record() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java index cfa24c4..b808f09 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java @@ -56,18 +56,15 @@ class CandidateScoreCardControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", - Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", - Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY"); } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", - Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN"); } private CandidateScoreCardResponse buildScoreCard(String status, int sampleCount) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java index eb3255b..09cc9bc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java @@ -53,15 +53,15 @@ class CandidateScoringControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY"); } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN"); } private CandidateScoringOverviewResponse buildOverview() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java index f589e34..d3b2481 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java @@ -58,7 +58,7 @@ class CompanyScoringDashboardControllerTest { } private String token(long uid, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(uid, "1", Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(uid, role); } @Test void overview_company_should200_andMasked() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java index 6b1d008..032d725 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java @@ -50,7 +50,7 @@ class CandidateScoringGovernanceControllerTest { } private String token(long uid, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(uid, "1", Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(uid, role); } private AppealResponse buildAppeal(Long id, String status) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java index e9d2ff7..c750f5e 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java @@ -182,8 +182,7 @@ class CareerProgressControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── 权限矩阵 ────────────────────────────────────────────── 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 5668cc8..99b94e9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java @@ -1,5 +1,6 @@ package com.mydreamjob.company; +import com.mydreamjob.auth.AuthSessionService; import com.mydreamjob.auth.JwtTokenProvider; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -15,7 +16,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @WebMvcTest(CompanyController.class) @AutoConfigureMockMvc(addFilters = false) -@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class}) +@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, + AuthSessionService.class, java.time.Clock.class}) class CompanyControllerMvcTest { @Autowired diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java index 381047b..1936aaa 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java @@ -180,8 +180,7 @@ class CompetitionRankingControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── 权限矩阵 ────────────────────────────────────────────── @@ -321,7 +320,6 @@ class CompetitionRankingControllerTest { } private String tokenWithRole(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } } \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java index 89f628d..9c35b59 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java @@ -94,8 +94,7 @@ class PersonalDataExportControllerTest { } private String token(long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } private PersonalDataExportTask pendingTask(Long userId) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java index 842fbaa..d33eb79 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java @@ -82,8 +82,7 @@ class AccountDeletionControllerTest { } private String token(long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } @Test @@ -243,10 +242,11 @@ class AccountDeletionControllerTest { assertEquals(DeletionStatus.COMPLETED, deletionRepository.findByUserId(uid).orElseThrow().getStatus()); - // 第二次 GET 仍应为 COMPLETED,不能出现"第一次 PENDING、第二次 COMPLETED"的不一致 + // S02 起:注销完成会脱敏(active=false)并撤销该用户全部会话, + // 因此用同一账号再次请求必须被拒绝为 401,而不再是 COMPLETED 的 200。 + // 这不影响本用例的一致性目标 —— 状态一致仍由上方的数据库断言保证。 mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("COMPLETED")); + .andExpect(status().isUnauthorized()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java index 6218e09..97574da 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java @@ -76,8 +76,7 @@ class DeletionGuardInterceptorTest { } private String token(long uid) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - uid, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(uid, "STUDENT"); } private void setStatus(long uid, DeletionStatus status, LocalDateTime effectiveAt) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java index 29cd558..5c33203 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java @@ -43,7 +43,7 @@ class CandidateFeedbackControllerTest { } private String token() { - return "Bearer " + jwtTokenProvider.generateAccessToken(10L, "1", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY"); } @Test void createFeedback_shouldReturn200() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java index 73f373e..9fd5bd5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java @@ -51,7 +51,7 @@ class GlobalExceptionHandlerInterviewScoreTest { ValueOperations ops = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.hasKey(any())).thenReturn(false); - token = "Bearer " + jwtTokenProvider.generateAccessToken(10L, "1", Map.of("roles", "COMPANY")); + token = com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY"); } private String body() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java index 32732ce..b3391f7 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java @@ -125,8 +125,7 @@ class InterviewScoreControllerTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } private String bodyJson(Integer t, Integer c, Integer ps, Integer rf, Integer pf) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java index ef60f34..5bc68e1 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java @@ -45,7 +45,7 @@ class MessageRecallControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java index e248bf3..6657174 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java @@ -118,8 +118,7 @@ class MessageRecallHttpIntegrationTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", - java.util.Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java index f8d1db3..9329046 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java @@ -47,7 +47,7 @@ class MessagingControllerTest { } private String token() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } @Test void listConversations_shouldReturn200() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java index 9dfb5ad..dcef124 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java @@ -42,7 +42,7 @@ class NotificationControllerTest { } private String token() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } @Test void list_shouldReturn200() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java index c7a6030..383e514 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java @@ -61,11 +61,11 @@ class OfferControllerTest { } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(COMPANY_USER_ID, "1", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_USER_ID, "COMPANY"); } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private Map offerView() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java index 4cf54f1..18a984c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java @@ -75,8 +75,7 @@ class PrivacySettingsControllerTest { } private String studentToken(long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── GET:STUDENT 成功 + 无记录默认值 ────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java index 6bdd31d..f568787 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java @@ -82,8 +82,7 @@ class ProfileControllerTest { } private String bearerToken(long userId, String phone) { - return "Bearer " + jwtTokenProvider.generateAccessToken( - userId, phone, Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } private String studentToken(long userId) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java index 789e18f..54dfa7c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java @@ -58,7 +58,7 @@ class ResumeVersionControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private Map versionView() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java index 4943ead..da9cc6c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java @@ -71,7 +71,7 @@ class ResumeVersionPermissionControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } // ── 另一学生访问 → 403 ──────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java index 66dbab4..5756220 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java @@ -59,11 +59,11 @@ class AdminReviewControllerTest { } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(ADMIN_ID, "1", Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(ADMIN_ID, "ADMIN"); } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java index e51c954..83bb7ba 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java @@ -33,8 +33,7 @@ class CompanyReviewManagementControllerTest { @MockBean private RedisTemplate redisTemplate; private String token(long id, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(id, role.equals("COMPANY") ? "1" : "2", - Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(id, role); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java index 8559a8e..4dfb82d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java @@ -45,8 +45,7 @@ class JobReviewControllerTest { } private String token() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", - java.util.Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } @Test void createReview_shouldReturn200() throws Exception { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java index 320fd0b..ec92de9 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java @@ -59,11 +59,11 @@ class JobReviewDimensionControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(10L, "3", Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY"); } private String reviewBody(boolean withDims) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java index 8f08945..7078b5a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java @@ -51,18 +51,15 @@ class StudentReviewWeightControllerTest { } private String studentToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", - Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT"); } private String companyToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", - Map.of("roles", "COMPANY")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY"); } private String adminToken() { - return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", - Map.of("roles", "ADMIN")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN"); } // ── 模板列表 ────────────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java index 0add3d9..14b00d5 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java @@ -103,8 +103,7 @@ class AdminRiskControllerTest { } private String token(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } private ScoringRiskEvent newEvent(String status, long version) { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java index 4e8aa9a..8955356 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java @@ -101,13 +101,11 @@ class AdminReportControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } private String tokenWithRole(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java index 8c46a23..ba82b8d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java @@ -88,8 +88,7 @@ class BlockControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java index 46df0df..0bf67bf 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java @@ -123,13 +123,11 @@ class ReportControllerTest { } private String token(Long userId) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", "STUDENT")); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT"); } private String tokenWithRole(Long userId, User.Role role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, - String.valueOf(userId), Map.of("roles", role.name())); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name()); } // ── 用户举报 ──────────────────────────────────────────────── diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java index 42a7b48..d08bbcc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java @@ -211,7 +211,6 @@ class FairnessMonitoringControllerTest { } private String token(long userId, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, - Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java index 191e3ea..1331da6 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java @@ -301,7 +301,6 @@ class FairnessMonitoringHttpIntegrationTest { } private String token(long userId, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, - Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role); } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java index bfef8e9..b0898d8 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java @@ -57,8 +57,7 @@ class TalentDiscoveryControllerTest { } private String token(long userId, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, - java.util.Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java index 132f2f2..837cf16 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java @@ -56,6 +56,6 @@ class TalentInvitationControllerTest { } private String token(Long userId, String role) { - return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", role)); + return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role); } } diff --git a/mydreamjob-server/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/mydreamjob-server/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..64e551a --- /dev/null +++ b/mydreamjob-server/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.mydreamjob.auth.support.TestAuthTokensAutoConfiguration diff --git a/mydreamjob-server/src/test/resources/application-integration-test.yml b/mydreamjob-server/src/test/resources/application-integration-test.yml index 1588536..410b092 100644 --- a/mydreamjob-server/src/test/resources/application-integration-test.yml +++ b/mydreamjob-server/src/test/resources/application-integration-test.yml @@ -55,6 +55,9 @@ app: file-ttl-hours: 24 jwt: secret: test-jwt-secret-key-for-integration-tests-only-not-for-production + access-secret: test-access-secret-key-for-integration-tests-only-not-for-production + refresh-secret: test-refresh-secret-key-for-integration-tests-only-not-for-production + token-hash-secret: test-token-hash-secret-key-for-integration-tests-only-not-for-production expiration-ms: 3600000 refresh-expiration-ms: 86400000 sms: diff --git a/mydreamjob-server/src/test/resources/application-test.yml b/mydreamjob-server/src/test/resources/application-test.yml index 7666c4b..779d78e 100644 --- a/mydreamjob-server/src/test/resources/application-test.yml +++ b/mydreamjob-server/src/test/resources/application-test.yml @@ -22,6 +22,9 @@ spring: app: jwt: secret: test-jwt-secret-key-for-unit-tests-only-not-for-production + access-secret: test-access-secret-key-for-unit-tests-only-not-for-production + refresh-secret: test-refresh-secret-key-for-unit-tests-only-not-for-production + token-hash-secret: test-token-hash-secret-key-for-unit-tests-only-not-for-production expiration-ms: 3600000 refresh-expiration-ms: 604800000