Browse Source

feat: isolate and revoke authentication token sessions

feat/s02-token-isolation
Zhang Shun 1 month ago
parent
commit
f399060edb
  1. 65
      docs/api-v0.1.md
  2. 2
      docs/backend-development-roadmap.md
  3. 34
      docs/frontend-integration.md
  4. 199
      docs/openapi-rc.json
  5. 2
      docs/phase1-scope-and-acceptance.md
  6. 674
      docs/s02-auth-token-isolation-design.md
  7. 724
      docs/s02-auth-token-isolation-implementation-instruction.md
  8. 202
      docs/s02-independent-mysql-acceptance-20260914.md
  9. 47
      docs/verify_openapi.py
  10. 5
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java
  11. 46
      mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java
  12. 17
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AccessTokenClaims.java
  13. 71
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  14. 332
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
  15. 48
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java
  16. 71
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java
  17. 99
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java
  18. 16
      mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java
  19. 17
      mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java
  20. 59
      mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java
  21. 213
      mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java
  22. 16
      mydreamjob-server/src/main/java/com/mydreamjob/auth/RefreshTokenClaims.java
  23. 25
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SessionAuthenticationDetails.java
  24. 21
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/RefreshTokenRequest.java
  25. 15
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/TokenPairResponse.java
  26. 90
      mydreamjob-server/src/main/java/com/mydreamjob/config/AppJwtProperties.java
  27. 5
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  28. 67
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  29. 13
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  30. 24
      mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java
  31. 3
      mydreamjob-server/src/main/resources/application-dev.example.yml
  32. 16
      mydreamjob-server/src/main/resources/application.yml
  33. 28
      mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql
  34. 3
      mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java
  35. 9
      mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java
  36. 3
      mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java
  37. 55
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AdvancingClock.java
  38. 319
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshConcurrencyIntegrationTest.java
  39. 138
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshControllerTest.java
  40. 212
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthRefreshHttpIntegrationTest.java
  41. 51
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java
  42. 219
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionPersistenceIntegrationTest.java
  43. 200
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionRevocationIntegrationTest.java
  44. 118
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthSessionServiceTest.java
  45. 254
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenClockConsistencyTest.java
  46. 69
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenHasherTest.java
  47. 180
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthTokenRedactionTest.java
  48. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java
  49. 270
      mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtAuthenticationTypeIntegrationTest.java
  50. 88
      mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtSecretOptionalStartupIntegrationTest.java
  51. 77
      mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java
  52. 260
      mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenTypeIsolationTest.java
  53. 121
      mydreamjob-server/src/test/java/com/mydreamjob/auth/S02InactiveUserSessionTest.java
  54. 86
      mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentClockAcceptanceTest.java
  55. 65
      mydreamjob-server/src/test/java/com/mydreamjob/auth/S02IndependentTokenValidationAcceptanceTest.java
  56. 176
      mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java
  57. 38
      mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokensAutoConfiguration.java
  58. 4
      mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java
  59. 9
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java
  60. 6
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java
  61. 2
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java
  62. 2
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java
  63. 3
      mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java
  64. 4
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java
  65. 6
      mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java
  66. 3
      mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java
  67. 10
      mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java
  68. 3
      mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java
  69. 2
      mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java
  70. 2
      mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java
  71. 3
      mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java
  72. 2
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java
  73. 3
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java
  74. 2
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java
  75. 2
      mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java
  76. 4
      mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java
  77. 3
      mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java
  78. 3
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java
  79. 2
      mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java
  80. 2
      mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java
  81. 4
      mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java
  82. 3
      mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java
  83. 3
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java
  84. 4
      mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java
  85. 9
      mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java
  86. 3
      mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java
  87. 6
      mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java
  88. 3
      mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java
  89. 6
      mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java
  90. 3
      mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java
  91. 3
      mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java
  92. 3
      mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java
  93. 2
      mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java
  94. 1
      mydreamjob-server/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  95. 3
      mydreamjob-server/src/test/resources/application-integration-test.yml
  96. 3
      mydreamjob-server/src/test/resources/application-test.yml

65
docs/api-v0.1.md

@ -169,12 +169,65 @@ http://localhost:8080/swagger-ui.html
http://localhost:8080/v3/api-docs http://localhost:8080/v3/api-docs
``` ```
## 已知限制 (Week 1) ### 4. 刷新 Token(S02)
```
POST /api/v1/auth/refresh
Content-Type: application/json
{ "refreshToken": "<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": "<request-id>"
}
```
### 5. 登出(S02)
```
POST /api/v1/auth/logout
Authorization: Bearer <accessToken>
```
- 必须已认证;**无请求体**。
- 身份与会话来自 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 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用” 1. 短信验证码为 Mock 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用”
2. 无企业注册/认证流程 2. 无企业注册/认证流程
3. 无岗位、投递、聊天、评分等业务功能 3. 无密码修改/重置功能
4. 无 Token 刷新接口(Week 2+) 4. 未提供查看全部登录设备、远程踢出单个设备、Token introspection 等接口
5. 无密码修改/重置功能 5. RabbitMQ 未实现消息监听器(仅队列定义)
6. RabbitMQ 未实现消息监听器(仅队列定义) 6. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN
7. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN

2
docs/backend-development-roadmap.md

@ -11,6 +11,8 @@
| V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 | | V43 | 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(cf39737) | N01 已关闭版本内小样本补集泄露;30/300/3000 查询预算通过,EXPLAIN 扩容风险保留观察 |
| 评分规则解释与合理性 | N02 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | 保持分数、权重、排名和企业评分契约不变 | | 评分规则解释与合理性 | N02 独立代码复验、真实 MySQL/Docker 验收及 Git 检查点已完成(dbd27e6) | 保持分数、权重、排名和企业评分契约不变 |
| 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 | | 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 |
| S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 |
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

34
docs/frontend-integration.md

@ -59,9 +59,9 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型 4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型
5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}` 5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}`
## 5. Token 使用方式 ## 5. Token 使用方式(S02)
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`。 登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`(有效期 7 天)。
```json ```json
{ {
@ -81,6 +81,36 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
Authorization: Bearer <accessToken> Authorization: Bearer <accessToken>
``` ```
### 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": "<refreshToken>" }
POST /api/v1/auth/logout header: Authorization: Bearer <accessToken> (无 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. 请求头 ## 3. 请求头
| Header | 必需 | 说明 | | Header | 必需 | 说明 |

199
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": { "components": {
@ -11881,19 +11935,30 @@
}, },
"limitations": { "limitations": {
"type": "array", "type": "array",
"items": { "type": "string" } "items": {
"type": "string"
}
}, },
"evidenceLevel": { "evidenceLevel": {
"type": "string", "type": "string",
"enum": ["DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY"] "enum": [
"DIRECT_RULE",
"SELF_REPORTED",
"BEHAVIOR_PROXY",
"UNVALIDATED_PROXY"
]
}, },
"ruleContributions": { "ruleContributions": {
"type": "array", "type": "array",
"items": { "$ref": "#/components/schemas/CandidateScoringRuleContribution" } "items": {
"$ref": "#/components/schemas/CandidateScoringRuleContribution"
}
}, },
"improvementActions": { "improvementActions": {
"type": "array", "type": "array",
"items": { "type": "string" } "items": {
"type": "string"
}
}, },
"explanationVersion": { "explanationVersion": {
"type": "string" "type": "string"
@ -15301,41 +15366,91 @@
"CandidateScoringCoverage": { "CandidateScoringCoverage": {
"type": "object", "type": "object",
"properties": { "properties": {
"computableDimensions": { "type": "integer", "format": "int32" }, "computableDimensions": {
"totalDimensions": { "type": "integer", "format": "int32" }, "type": "integer",
"includedDimensions": { "type": "array", "items": { "type": "string" } }, "format": "int32"
},
"totalDimensions": {
"type": "integer",
"format": "int32"
},
"includedDimensions": {
"type": "array",
"items": {
"type": "string"
}
},
"excludedDimensions": { "excludedDimensions": {
"type": "array", "type": "array",
"items": { "$ref": "#/components/schemas/CandidateScoringExcludedDimension" } "items": {
"$ref": "#/components/schemas/CandidateScoringExcludedDimension"
}
} }
} }
}, },
"CandidateScoringExcludedDimension": { "CandidateScoringExcludedDimension": {
"type": "object", "type": "object",
"properties": { "properties": {
"dimension": { "type": "string" }, "dimension": {
"status": { "type": "string" }, "type": "string"
"reason": { "type": "string" } },
"status": {
"type": "string"
},
"reason": {
"type": "string"
}
} }
}, },
"CandidateScoringRuleContribution": { "CandidateScoringRuleContribution": {
"type": "object", "type": "object",
"properties": { "properties": {
"ruleCode": { "type": "string" }, "ruleCode": {
"outcome": { "type": "string", "enum": ["APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"] }, "type": "string"
"contribution": { "type": "number", "nullable": true }, },
"maxContribution": { "type": "number", "nullable": true }, "outcome": {
"label": { "type": "string" }, "type": "string",
"observation": { "type": "string" }, "enum": [
"explanation": { "type": "string" } "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": { "CandidateScoringValidity": {
"type": "object", "type": "object",
"properties": { "properties": {
"completedAt": { "type": "string", "format": "date-time" }, "completedAt": {
"validUntil": { "type": "string", "format": "date-time" }, "type": "string",
"expired": { "type": "boolean" } "format": "date-time"
},
"validUntil": {
"type": "string",
"format": "date-time"
},
"expired": {
"type": "boolean"
}
} }
}, },
"StudentProfileResponse": { "StudentProfileResponse": {
@ -15632,6 +15747,48 @@
"format": "date-time" "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": { "securitySchemes": {

2
docs/phase1-scope-and-acceptance.md

@ -29,6 +29,8 @@
| C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 | | C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 |
| C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 | | C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 |
| C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 | | 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. 一期必须补齐的八个功能组 ## 3. 一期必须补齐的八个功能组

674
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": "<refresh-token>"
}
```
成功响应:
```json
{
"code": 1000,
"data": {
"accessToken": "<new-access-token>",
"refreshToken": "<new-refresh-token>",
"expiresIn": 86400
},
"requestId": "<request-id>"
}
```
要求:
- 请求体只允许 `refreshToken`;
- 不能为空,设置合理长度上限;
- 不接受查询参数或路径参数中的 Token;
- 旧 Refresh Token 刷新成功后立即失效。
### 9.3 登出
`POST /auth/logout`
- 使用 `Authorization: Bearer <access-token>`;
- 无 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 验收通过的替代条件。

724
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。

202
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黑盒全部通过,可以进入精确暂存检查点。

47
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) 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'] 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 assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths
print(' N07 recall path/schema validated; existing messaging operations preserved') 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()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) 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(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") print(" Account deletion endpoints present (3 operations)")

5
mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditLog.java

@ -50,7 +50,10 @@ public class AuditLog {
@Column(name = "request_id", length = 32) @Column(name = "request_id", length = 32)
private String requestId; private String requestId;
@CreatedDate // S02:不再使用 @CreatedDate。
// Spring Data 审计会在持久化时用“当前时间”覆盖显式设置的值,而 S02 认证入口
// (登录/刷新/登出/重放处置/全局吊销)要求审计时间与入口单次捕获的 capturedNow 同源。
// 所有写入方(AuditService、SecurityAuditService)本就显式设置 createdAt,移除后行为不变。
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }

46
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, public void log(Long userId, String action, String entityType, Long entityId,
String details, String ipAddress, String userAgent, String requestId) { String details, String ipAddress, String userAgent, String requestId) {
logWithContext(userId, action, entityType, entityId, details, 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, public void log(Long userId, String action, String entityType, Long entityId,
String details, AuditContext context) { String details, AuditContext context) {
logWithContext(userId, action, entityType, entityId, details, context); logWithContext(userId, action, entityType, entityId, details, context, LocalDateTime.now());
}
/**
* 记录一个审计事件(显式时间戳版本,S02)。
*
* <p>供 S02 认证入口使用:登录、刷新、登出、重放处置与全局吊销在各自入口
* 只捕获一次 {@code capturedNow},审计时间必须与之同源,不得再次读时。</p>
*/
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, private void logWithContext(Long userId, String action, String entityType, Long entityId,
String details, AuditContext ctx) { String details, AuditContext ctx, LocalDateTime createdAt) {
try { try {
// 脱敏:details 中不得包含 code/password/token/secret 等关键词的值 // 脱敏:details 中不得包含 code/password/token/secret 等关键词的值
String safeDetails = sanitize(details); String safeDetails = sanitize(details);
@ -47,7 +59,7 @@ public class AuditService {
.ipAddress(ctx != null ? ctx.getIpAddress() : null) .ipAddress(ctx != null ? ctx.getIpAddress() : null)
.userAgent(ctx != null ? ctx.getUserAgent() : null) .userAgent(ctx != null ? ctx.getUserAgent() : null)
.requestId(ctx != null ? ctx.getRequestId() : null) .requestId(ctx != null ? ctx.getRequestId() : null)
.createdAt(LocalDateTime.now()) .createdAt(createdAt)
.build(); .build();
auditLogRepository.save(auditLog); auditLogRepository.save(auditLog);
} catch (Exception e) { } catch (Exception e) {
@ -73,6 +85,32 @@ public class AuditService {
log(userId, "LOGOUT", "USER", userId, "User logged out", ctx); 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);
}
/** /**
* 脱敏:不记录可能的敏感字段值 * 脱敏:不记录可能的敏感字段值
*/ */

17
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)。
*
* <p>强类型只读视图:调用方不再接触 JJWT 的 Claims 对象,也不依赖 claim 名。</p>
*/
public record AccessTokenClaims(
Long userId,
String sessionId,
String tokenId,
String role,
Instant issuedAt,
Instant expiresAt) {
}

71
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java

@ -1,5 +1,6 @@
package com.mydreamjob.auth; package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
@ -12,25 +13,34 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.*; import java.util.*;
/** /**
* Mock 认证服务 - 仅用于开发/测试环境登录原型 * 认证服务(S02):手机号验证码登录,并编排刷新与登出。
*
* <p>登录成功后创建服务端登录会话;Access/Refresh 由各自的密钥签发,
* 数据库只保存 Refresh Token 的 HMAC 摘要。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthService { public class AuthService {
/** 登录与刷新响应中的 expiresIn 秒数,保持既有契约不变(设计 D04)。 */
public static final long EXPIRES_IN_SECONDS = 86400L;
private final UserRepository userRepository; private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final SmsVerificationService smsVerificationService; private final SmsVerificationService smsVerificationService;
private final AuditService auditService; private final AuditService auditService;
private final AccountDeletionRepository accountDeletionRepository; private final AccountDeletionRepository accountDeletionRepository;
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService;
private final Clock clock;
/** /**
* Mock 登录:验证短信验证码后自动创建/登录用户 * Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。
*/ */
@Transactional @Transactional
public Map<String, Object> mockLogin(String phone, String code, String purpose, public Map<String, Object> mockLogin(String phone, String code, String purpose,
@ -64,28 +74,55 @@ public class AuthService {
} }
// 已注销(COMPLETED)用户禁止登录 // 已注销(COMPLETED)用户禁止登录
java.util.Optional<AccountDeletion> deletion = accountDeletionRepository.findByUserId(user.getId()); Optional<AccountDeletion> deletion = accountDeletionRepository.findByUserId(user.getId());
if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) { if (deletion.isPresent() && deletion.get().getStatus() == DeletionStatus.COMPLETED) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETED); throw new BusinessException(ErrorCode.ACCOUNT_DELETED);
} }
// 生成 token // 认证入口只取一次时间,后续 JWT iat/exp、数据库时间与审计全部同源。
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name())); Instant capturedNow = clock.instant();
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone()); 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<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("userId", user.getId()); result.put("userId", user.getId());
result.put("phone", user.getPhone()); result.put("phone", user.getPhone());
result.put("nickname", user.getNickname()); result.put("nickname", user.getNickname());
result.put("role", user.getRole()); result.put("role", user.getRole());
result.put("accessToken", accessToken); result.put("accessToken", tokens.accessToken());
result.put("refreshToken", refreshToken); result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", 86400); result.put("expiresIn", EXPIRES_IN_SECONDS);
return result; 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()); result.put("createdAt", user.getCreatedAt());
return result; 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);
}
} }

332
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、撤销会话。
*
* <p>并发正确性完全由数据库条件更新保证({@link AuthTokenRepository#consumeIfCurrent}),
* 不使用任何 JVM 本地锁。</p>
*
* <p>时间一律由调用方(认证入口)传入的 {@code capturedNow} 决定,本类不自行取时。</p>
*/
@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。
*
* <p>必须运行在调用方事务内:会话记录持久化失败时,整个登录事务失败,不得返回 Token。</p>
*/
@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。
*
* <p><strong>关于账号注销</strong>:这里刻意只检查 {@code active},不单独判定注销记录的
* COMPLETED 状态。账号注销完成时 {@code AccountDeletionCompletionWorker} 会
* 1) 脱敏并将用户置为 inactive,2) 撤销该用户全部会话 —— 两道处理叠加后,
* 真实注销用户的旧 Token 无论走哪条路径都会失败。把 COMPLETED 的 409/9303 语义
* 保留给 {@code DeletionGuardInterceptor},可避免改变既有注销端点契约。</p>
*
* <p>任何数据库/运行时异常一律**失败关闭**,不建立认证。</p>
*/
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,全部在同一事务内。
*
* <p>识别为重放时**不抛异常**,而是返回 {@link RefreshAttempt.Replayed} ——
* 否则事务回滚会连带丢失撤销所需的会话信息。</p>
*/
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)。
*
* <p>撤销失败不做吞掉处理:先做有限次重试(无 sleep),仍失败则让异常向外传播 ——
* 宁可暴露基础设施故障,也不能留下一个可继续使用的会话。</p>
*/
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> 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);
}
}

48
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java

@ -5,13 +5,22 @@ import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* JWT Token 实体(用于 token 吊销和刷新) * 登录会话中的 Refresh Token 记录(S02)。
*
* <p>字段含义(S02 固定):</p>
* <ul>
* <li>{@code tokenHash} —— **Refresh Token 原文的 HMAC-SHA256 摘要**,绝不保存原文;</li>
* <li>{@code tokenType} —— 新记录固定 {@code REFRESH};</li>
* <li>{@code expiredAt} —— Refresh Token 到期时间;</li>
* <li>{@code revoked} —— 兼容既有布尔语义,与 {@code revokedAt} 同步维护;</li>
* <li>{@code refreshTokenHash} —— 旧字段保留,S02 新逻辑不依赖。</li>
* </ul>
*
* <p>旧数据({@code sessionId IS NULL})永远不被新鉴权逻辑视为活动会话。</p>
*/ */
@Data @Data
@Builder @Builder
@ -19,9 +28,11 @@ import java.time.LocalDateTime;
@AllArgsConstructor @AllArgsConstructor
@Entity @Entity
@Table(name = "auth_tokens") @Table(name = "auth_tokens")
@EntityListeners(AuditingEntityListener.class)
public class AuthToken { public class AuthToken {
/** Token 类型:S02 起 auth_tokens 只保存 Refresh 记录。 */
public static final String TYPE_REFRESH = "REFRESH";
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@ -34,7 +45,7 @@ public class AuthToken {
@Column(name = "token_type", nullable = false, length = 20) @Column(name = "token_type", nullable = false, length = 20)
@Builder.Default @Builder.Default
private String tokenType = "ACCESS"; private String tokenType = TYPE_REFRESH;
@Column(name = "expired_at", nullable = false) @Column(name = "expired_at", nullable = false)
private LocalDateTime expiredAt; private LocalDateTime expiredAt;
@ -46,7 +57,32 @@ public class AuthToken {
@Column(name = "refresh_token_hash", length = 255) @Column(name = "refresh_token_hash", length = 255)
private String refreshTokenHash; 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) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }

71
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)。
*
* <p>数据库只保存 {@code HMAC-SHA256(token-hash-secret, rawToken)} 的十六进制表示,
* 不保存 Token 原文,也不使用可逆加密或无密钥的裸摘要。</p>
*
* <p>本类**不记录**输入、输出或异常原值。比较使用常量时间比较。</p>
*/
@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);
}
}

99
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java

@ -1,12 +1,21 @@
package com.mydreamjob.auth; package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository; 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 org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* auth_tokens 数据访问(S02)。
*
* <p>并发消费与撤销**一律通过带条件的原子 UPDATE** 完成,并严格检查受影响行数;
* 禁止以 {@code findById -> set -> save} 作为并发保证。</p>
*/
@Repository @Repository
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> { public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
@ -17,4 +26,94 @@ public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findByRefreshTokenHash(String refreshTokenHash); Optional<AuthToken> findByRefreshTokenHash(String refreshTokenHash);
List<AuthToken> findByExpiredAtBefore(LocalDateTime expiredAt); List<AuthToken> findByExpiredAtBefore(LocalDateTime expiredAt);
// ── S02 会话查询 ────────────────────────────────────────────
/**
* 按摘要取候选 Refresh 记录(同摘要理论上唯一,取最新一条以防历史重复)。
*/
Optional<AuthToken> findTopByTokenHashOrderByIdDesc(String tokenHash);
/**
* 会话是否活动:同一 user + session 下存在未消费、未撤销且未过期的 Refresh 记录。
*
* <p>旧数据 {@code sessionId IS NULL} 永远不会命中。</p>
*/
@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<AuthToken> 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);
} }

16
mydreamjob-server/src/main/java/com/mydreamjob/auth/InvalidTokenException.java

@ -0,0 +1,16 @@
package com.mydreamjob.auth;
/**
* Token 校验失败(S02)。
*
* <p>异常消息固定为安全文本,**绝不包含 Token 原文、claim 原值或摘要**。
* 对外一律表现为统一的 401,不区分“不存在”“已消费”“已撤销”或“重放”。</p>
*/
public class InvalidTokenException extends RuntimeException {
private static final long serialVersionUID = 1L;
public InvalidTokenException() {
super("invalid token");
}
}

17
mydreamjob-server/src/main/java/com/mydreamjob/auth/IssuedToken.java

@ -0,0 +1,17 @@
package com.mydreamjob.auth;
import java.time.Instant;
/**
* 一次 Token 签发的结果(S02)。
*
* <p>{@code token} 为原始 JWT 原文,只在内存中短暂存在;调用方不得将其写入
* 数据库、日志、审计或错误响应。</p>
*/
public record IssuedToken(
String token,
String sessionId,
String tokenId,
Instant issuedAt,
Instant expiresAt) {
}

59
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.lang.Nullable;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Collections; import java.util.Collections;
/** /**
* JWT 认证过滤器 * S02 认证过滤器:只接受 Access Token。
*
* <p>与修复前的关键差异:</p>
* <ul>
* <li>只调用 {@link JwtTokenProvider#parseAndValidateAccessToken},
* Refresh Token、无 {@code type}、未知 {@code type}、错误 issuer/audience 一律拒绝;</li>
* <li>验签之后还必须通过服务端会话校验(会话存在、未消费、未撤销、未过期,
* 且用户 active、注销未完成);</li>
* <li>任何失败都不建立部分认证,并清理 SecurityContext;</li>
* <li>数据库或会话校验异常时**失败关闭**,绝不放行;</li>
* <li>日志只用固定安全文本,不使用异常 {@code getMessage()},不回显 Token、subject、sid、jti。</li>
* </ul>
*/ */
@Slf4j @Slf4j
@Component @Component
@ -26,31 +38,46 @@ import java.util.Collections;
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider; private final JwtTokenProvider tokenProvider;
private final AuthSessionService authSessionService;
private final Clock clock;
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request); String token = extractToken(request);
if (token != null && tokenProvider.validateToken(token)) { if (token != null) {
authenticate(request, token);
}
filterChain.doFilter(request, response);
}
private void authenticate(HttpServletRequest request, String token) {
try { try {
Long userId = tokenProvider.parseUserId(token); Instant capturedNow = clock.instant();
if (userId == null || userId <= 0) { AccessTokenClaims claims = tokenProvider.parseAndValidateAccessToken(token, capturedNow);
// 非法主体一律不建立认证;不回显 subject
log.warn("JWT authentication rejected: invalid subject"); if (!authSessionService.isSessionActive(claims.userId(), claims.sessionId(), capturedNow)) {
} else { SecurityContextHolder.clearContext();
log.warn("JWT authentication rejected: session not active");
return;
}
// 资源权限仍以后续服务层校验为准,这里只建立“已认证用户”身份。
// principal 保持为 userId(既有约 40 处 Long.valueOf(auth.getName()) 依赖该形态),
// 已校验的 sessionId 通过 details 传递,供 /auth/logout 使用。
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); new UsernamePasswordAuthenticationToken(claims.userId(), null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authentication.setDetails(new SessionAuthenticationDetails(request, claims.sessionId()));
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user {} from JWT", userId); } catch (InvalidTokenException e) {
} // 固定安全文本:不区分过期/类型错误/签名错误,也不回显任何 claim 原值。
SecurityContextHolder.clearContext();
} catch (Exception e) { } catch (Exception e) {
// 固定安全文本:异常消息可能包含非法 principal 原文,一律不回显 // 会话校验或数据库异常:失败关闭。
log.warn("JWT authentication rejected: unparsable subject"); SecurityContextHolder.clearContext();
log.warn("JWT authentication rejected: internal check failed");
} }
} }
filterChain.doFilter(request, response);
}
@Nullable @Nullable
private String extractToken(HttpServletRequest request) { private String extractToken(HttpServletRequest request) {

213
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java

@ -1,84 +1,207 @@
package com.mydreamjob.auth; 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 io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.crypto.SecretKey; import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date; import java.util.Date;
import java.util.Map; import java.util.UUID;
/** /**
* JWT 工具类 * S02 Access/Refresh Token 签发与校验。
*
* <p>核心变更:</p>
* <ul>
* <li>Access 与 Refresh 使用**不同签名密钥**,交叉验签必然失败;</li>
* <li>强制校验 {@code type}、{@code iss}、{@code aud},不再用“验签通过即有效”判断用途;</li>
* <li>新 Token 不再包含手机号;</li>
* <li>{@code iat}/{@code exp} 一律来自调用方传入的 {@code capturedNow},本类不自行取时;</li>
* <li>过期边界固定为 {@code exp <= capturedNow} 视为过期;</li>
* <li>对外只返回强类型 record,不暴露 JJWT {@code Claims};</li>
* <li>异常消息固定为安全文本,不含 Token 或 claim 原值。</li>
* </ul>
*
* <p>本类**不访问数据库**。</p>
*/ */
@Slf4j @Slf4j
@Component @Component
public class JwtTokenProvider { 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 expirationMs;
private final long refreshExpirationMs; private final long refreshExpirationMs;
private final String issuer;
private final String accessAudience;
private final String refreshAudience;
public JwtTokenProvider( public JwtTokenProvider(AppJwtProperties properties) {
@Value("${app.jwt.secret}") String secret, this.accessKey = Keys.hmacShaKeyFor(properties.getAccessSecret().getBytes(StandardCharsets.UTF_8));
@Value("${app.jwt.expiration-ms:86400000}") long expirationMs, this.refreshKey = Keys.hmacShaKeyFor(properties.getRefreshSecret().getBytes(StandardCharsets.UTF_8));
@Value("${app.jwt.refresh-expiration-ms:604800000}") long refreshExpirationMs) { this.expirationMs = properties.getExpirationMs();
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.refreshExpirationMs = properties.getRefreshExpirationMs();
this.expirationMs = expirationMs; this.issuer = properties.getIssuer();
this.refreshExpirationMs = refreshExpirationMs; this.accessAudience = properties.getAccessAudience();
this.refreshAudience = properties.getRefreshAudience();
} }
public String generateAccessToken(Long userId, String phone, Map<String, Object> claims) { // ── 签发 ────────────────────────────────────────────────────
return generateToken(userId, phone, claims, expirationMs);
}
public String generateRefreshToken(Long userId, String phone) { public IssuedToken generateAccessToken(Long userId, String role, String sessionId,
return generateToken(userId, phone, Map.of("type", "refresh"), refreshExpirationMs); 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<String, Object> claims, long expirationMs) { public IssuedToken generateRefreshToken(Long userId, String sessionId,
Date now = new Date(); String tokenId, Instant capturedNow) {
Date expiry = new Date(now.getTime() + expirationMs); Instant expiresAt = capturedNow.plusMillis(refreshExpirationMs);
String token = Jwts.builder()
return Jwts.builder()
.subject(String.valueOf(userId)) .subject(String.valueOf(userId))
.claim("phone", phone) .id(tokenId)
.claims(claims) .claim(CLAIM_TYPE, TYPE_REFRESH)
.issuedAt(now) .claim(CLAIM_SID, sessionId)
.expiration(expiry) .issuer(issuer)
.signWith(secretKey) .audience().add(refreshAudience).and()
.issuedAt(Date.from(capturedNow))
.expiration(Date.from(expiresAt))
.signWith(refreshKey)
.compact(); .compact();
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt);
}
// ── 校验 ────────────────────────────────────────────────────
/**
* 校验并解析 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()));
}
/**
* 校验并解析 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()));
} }
public Long parseUserId(String token) { private Claims parse(String rawToken, SecretKey key, String expectedType,
String subject = Jwts.parser() String expectedAudience, Instant capturedNow) {
.verifyWith(secretKey) if (rawToken == null || rawToken.isBlank()) {
throw new InvalidTokenException();
}
Claims claims;
try {
claims = Jwts.parser()
.verifyWith(key)
// 过期判定由下面显式完成:这里把解析时钟固定在很早的时间,
// 避免依赖 jjwt 内部边界语义,从而严格执行本文档的契约。
.clock(() -> Date.from(Instant.EPOCH))
.require(CLAIM_TYPE, expectedType)
.requireIssuer(issuer)
.requireAudience(expectedAudience)
.build() .build()
.parseSignedClaims(token) .parseSignedClaims(rawToken)
.getPayload() .getPayload();
.getSubject(); } catch (JwtException | IllegalArgumentException e) {
return Long.parseLong(subject); // 固定安全文本:不回显 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;
} }
public boolean validateToken(String token) { private Long requirePositiveSubject(Claims claims) {
String subject = claims.getSubject();
if (subject == null || subject.isBlank()) {
throw new InvalidTokenException();
}
long userId;
try { try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); userId = Long.parseLong(subject);
return true; } catch (NumberFormatException e) {
} catch (JwtException e) { throw new InvalidTokenException();
log.debug("JWT validation failed: {}", e.getMessage());
return false;
} }
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 { try {
return Jwts.parser().verifyWith(secretKey).build() parsed = UUID.fromString(value);
.parseSignedClaims(token).getPayload().getExpiration().before(new Date()); } catch (IllegalArgumentException e) {
} catch (JwtException e) { throw new InvalidTokenException();
return true;
} }
// 只接受规范小写连字符形式,避免宽松解析放行异常写法。
if (!parsed.toString().equals(value)) {
throw new InvalidTokenException();
}
return value;
}
private static Instant toInstant(Date date) {
return date == null ? null : date.toInstant();
} }
} }

16
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)。
*
* <p>Refresh Token 不含 role,也不含手机号。</p>
*/
public record RefreshTokenClaims(
Long userId,
String sessionId,
String tokenId,
Instant issuedAt,
Instant expiresAt) {
}

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

21
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)。
*
* <p>只允许 {@code refreshToken} 一个字段;Token 不接受 query/path 参数传递。</p>
*/
@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;
}

15
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)。
*
* <p>只包含新 Token 对与有效期;不包含 userId、手机号、sessionId 或任何数据库字段。</p>
*/
@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) {
}

90
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 隔离配置。
*
* <p>Access 与 Refresh 使用独立签名密钥,Token 摘要另用独立密钥;三项密钥
* 均不得缺失、不得为空、不得互相回退,且必须两两不同。</p>
*
* <p>校验失败时抛出固定安全文本的异常,**不回显密钥值本身**。</p>
*/
@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");
}
}
}

5
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -74,8 +74,13 @@ public class SecurityConfig {
.requestMatchers("/auth/sms/send").permitAll() .requestMatchers("/auth/sms/send").permitAll()
.requestMatchers("/auth/login").permitAll() .requestMatchers("/auth/login").permitAll()
.requestMatchers("/auth/register").permitAll() .requestMatchers("/auth/register").permitAll()
// S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。
// permitAll 只表示可进入,不代表 Token 有效。
.requestMatchers("/auth/refresh").permitAll()
// 当前用户 // 当前用户
.requestMatchers("/auth/me").authenticated() .requestMatchers("/auth/me").authenticated()
// S02:登出必须已认证
.requestMatchers("/auth/logout").authenticated()
// 企业资料(需登录) // 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated() .requestMatchers("/companies/me/**").authenticated()
// V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验) // V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验)

67
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java

@ -1,12 +1,16 @@
package com.mydreamjob.controller; package com.mydreamjob.controller;
import com.mydreamjob.auth.AuthService; 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.LoginRequest;
import com.mydreamjob.auth.SmsVerificationService; 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.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -16,18 +20,18 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; 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.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* 认证相关接口 * 认证相关接口
*
* <p>S02:新增 {@code /auth/refresh} 与 {@code /auth/logout}。Controller 只做参数接收、
* 身份解析和响应包装,不计算摘要、不解析 JWT、不访问 Repository,也不重复实现会话逻辑。</p>
*/ */
@Tag(name = "认证", description = "登录、验证码、用户信息") @Tag(name = "认证", description = "登录、验证码、Token 刷新与登出、用户信息")
@RestController @RestController
@RequestMapping("/auth") @RequestMapping("/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
@ -36,7 +40,7 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final SmsVerificationService smsVerificationService; private final SmsVerificationService smsVerificationService;
private final JwtTokenProvider jwtTokenProvider; private final CurrentUserAccessor currentUserAccessor;
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
@PostMapping("/sms/send") @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") @PostMapping("/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> mockLogin( public ResponseEntity<ApiResponse<Map<String, Object>>> mockLogin(
@Valid @RequestBody LoginRequest body, @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<ApiResponse<TokenPairResponse>> 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<ApiResponse<Map<String, String>>> 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 认证") @Operation(summary = "当前用户信息", description = "需要 JWT 认证")
@GetMapping("/me") @GetMapping("/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> me(HttpServletRequest request) { public ResponseEntity<ApiResponse<Map<String, Object>>> me(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Long userId = currentUserAccessor.requireUserId();
if (auth == null || !auth.isAuthenticated()) {
return ResponseEntity.status(401).body(
ApiResponse.unauthorized("not authenticated", getRequestId(request)));
}
Long userId = Long.valueOf(auth.getName());
return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(authService.getCurrentUser(userId), getRequestId(request)));
} }
private ResponseEntity<ApiResponse<TokenPairResponse>> unauthorized(HttpServletRequest request) {
return ResponseEntity.status(401).body(
ApiResponse.unauthorized("invalid or expired token", getRequestId(request)));
}
private String getRequestId(HttpServletRequest request) { private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id"); return request.getHeader("X-Request-Id");
} }

13
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java

@ -1,6 +1,7 @@
package com.mydreamjob.deletion; package com.mydreamjob.deletion;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.User; import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository; import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.dataexport.PersonalDataExportCleanupService; 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.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
@ -32,6 +35,8 @@ public class AccountDeletionCompletionWorker {
private final AuditService auditService; private final AuditService auditService;
private final AccountDeletionPhoneHasher phoneHasher; private final AccountDeletionPhoneHasher phoneHasher;
private final PersonalDataExportCleanupService dataExportCleanupService; private final PersonalDataExportCleanupService dataExportCleanupService;
private final AuthSessionService authSessionService;
private final Clock clock;
/** /**
* 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。
@ -44,7 +49,9 @@ public class AccountDeletionCompletionWorker {
if (rec == null || rec.getStatus() != DeletionStatus.PENDING) { if (rec == null || rec.getStatus() != DeletionStatus.PENDING) {
return false; 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)) { if (rec.getEffectiveAt() == null || rec.getEffectiveAt().isAfter(now)) {
return false; return false;
} }
@ -55,6 +62,10 @@ public class AccountDeletionCompletionWorker {
rec.setCompletedAt(now); rec.setCompletedAt(now);
accountDeletionRepository.saveAndFlush(rec); // @Version 门控:并发时后到者抛异常 accountDeletionRepository.saveAndFlush(rec); // @Version 门控:并发时后到者抛异常
desensitize(rec.getUserId()); desensitize(rec.getUserId());
// S02:注销完成必须撤销该用户全部登录会话(与状态迁移同一事务)。
// 只按 rec.getUserId() 撤销,不接受任何客户端传入的 userId。
authSessionService.revokeAllUserSessions(
rec.getUserId(), AuthSessionService.REASON_ACCOUNT_DELETED, capturedNow);
// 注销完成:清理该用户的个人导出文件并作废未完成任务 // 注销完成:清理该用户的个人导出文件并作废未完成任务
dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
// 完成 + 脱敏审计各写一次 // 完成 + 脱敏审计各写一次

24
mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java

@ -1,5 +1,6 @@
package com.mydreamjob.security; package com.mydreamjob.security;
import com.mydreamjob.auth.SessionAuthenticationDetails;
import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@ -33,6 +34,29 @@ public class CurrentUserAccessor {
return requireUserId(SecurityContextHolder.getContext().getAuthentication()); return requireUserId(SecurityContextHolder.getContext().getAuthentication());
} }
/**
* 解析当前请求**已验证**的登录会话 ID(S02)。
*
* <p>会话 ID 只可能由认证过滤器在 Access Token 通过完整校验后写入
* {@code Authentication.details};客户端无法通过参数或请求体提供。
* 取不到时一律按未登录处理。</p>
*/
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。 * 解析用户 ID。任何非法输入都抛出未登录异常,绝不抛出 NumberFormatException。
* *

3
mydreamjob-server/src/main/resources/application-dev.example.yml

@ -21,6 +21,9 @@ spring:
app: app:
jwt: jwt:
secret: ${JWT_SECRET} secret: ${JWT_SECRET}
access-secret: ${JWT_ACCESS_SECRET}
refresh-secret: ${JWT_REFRESH_SECRET}
token-hash-secret: ${JWT_TOKEN_HASH_SECRET}
expiration-ms: 86400000 expiration-ms: 86400000
refresh-expiration-ms: 604800000 refresh-expiration-ms: 604800000

16
mydreamjob-server/src/main/resources/application.yml

@ -74,15 +74,27 @@ spring:
fail-on-unknown-properties: false fail-on-unknown-properties: false
# JWT # JWT
# S02:Access 与 Refresh 使用独立签名密钥,另设独立的 Token 摘要密钥。
# 三项密钥均无默认值,缺失即启动失败(见 AppJwtProperties 校验)。
app: app:
jwt: 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} 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: resume:
storage-type: ${RESUME_STORAGE_TYPE:local} storage-type: ${RESUME_STORAGE_TYPE:local}
storage-dir: ${RESUME_STORAGE_DIR:./resumes} storage-dir: ${RESUME_STORAGE_DIR:./resumes}
export-dir: ${RESUME_EXPORT_DIR:./resume-exports} export-dir: ${RESUME_EXPORT_DIR:./resume-exports}
refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000}
async: async:
resume-export: resume-export:

28
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);

3
mydreamjob-server/src/test/java/com/mydreamjob/ability/AbilityControllerTest.java

@ -76,8 +76,7 @@ class AbilityControllerTest {
} }
private String token(Long userId, User.Role role) { private String token(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
String.valueOf(userId), Map.of("roles", role.name()));
} }
@Test @Test

9
mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationControllerTest.java

@ -78,18 +78,15 @@ class ApplicationControllerTest {
} }
private String companyToken() { private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken( return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_USER_ID, "COMPANY");
COMPANY_USER_ID, "1", Map.of("roles", "COMPANY"));
} }
private String studentToken() { private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken( return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_USER_ID, "STUDENT");
STUDENT_USER_ID, "1", Map.of("roles", "STUDENT"));
} }
private String adminToken() { private String adminToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken( return com.mydreamjob.auth.support.TestAuthTokens.bearer(ADMIN_USER_ID, "ADMIN");
ADMIN_USER_ID, "1", Map.of("roles", "ADMIN"));
} }
private void mockCompanyExists() { private void mockCompanyExists() {

3
mydreamjob-server/src/test/java/com/mydreamjob/assessment/AssessmentControllerTest.java

@ -86,8 +86,7 @@ class AssessmentControllerTest {
} }
private String token(Long userId, User.Role role) { private String token(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
String.valueOf(userId), Map.of("roles", role.name()));
} }
@Test @Test

55
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 + 偏移)。
*
* <p>用途:证明认证入口只读取一次 Clock。若某个入口读了两次,第二次取到的是 T+偏移,
* 于是 JWT {@code iat}/{@code exp}、数据库 {@code created_at}/{@code expired_at}
* 或审计时间就会出现两个不同的值,测试即可捕获。</p>
*
* <p>固定使用项目业务时区 {@code Asia/Shanghai},不依赖系统默认时区。</p>
*/
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);
}
}

319
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,任务书 §十八)。
*
* <p>真实 Service + Repository + 事务 + EntityManager,两个线程用 CountDownLatch
* 同时开始,不 Mock 条件更新。</p>
*
* <p>独立复验整改后:重放被识别时要求该会话**活动 Token 数为 0**(而不是 ≤1),
* 且并发胜出方刚签发的 Access Token 也必须失效。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class AuthRefreshConcurrencyIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(9600);
@MockBean
private RedisTemplate<String, Object> 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<String, Object> 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<Attempt> attempts) {
long successes() {
return attempts.stream().filter(Attempt::success).count();
}
List<Throwable> 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<Future<Attempt>> 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<Attempt> results = new ArrayList<>();
for (Future<Attempt> 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<AuthToken> 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<Throwable> 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<Throwable> 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());
}
}

138
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,任务书 §十四)。
*
* <p>请求体只允许 {@code refreshToken},且必须有合理的必填与长度约束。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class AuthRefreshControllerTest {
@MockBean
private RedisTemplate<String, Object> 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<String> 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;
}
}

212
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 组)。
*
* <p>覆盖:匿名携带有效 Refresh 刷新成功、轮换后旧 Token 永久失效、
* 二次使用触发重放处置并撤销当前会话、以及错误响应不泄漏。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class AuthRefreshHttpIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(9400);
@MockBean
private RedisTemplate<String, Object> 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<String, Object> 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;
}
}

51
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java

@ -1,5 +1,6 @@
package com.mydreamjob.auth; package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
@ -14,21 +15,33 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; 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.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
/**
* S02 {@link AuthService} 单元测试。
*
* <p>登录链路改为「验证码校验 → 用户解析 → 创建服务端会话」,因此 Mockito 目标
* 由旧的 {@code JwtTokenProvider} 换成 {@link AuthSessionService};
* 原有断言语义(响应字段、自动建号、注销墓碑、验证码用途透传)保持不变。</p>
*/
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class AuthServiceTest { class AuthServiceTest {
private static final Instant FIXED_NOW = Instant.parse("2026-09-14T02:00:00Z");
@Mock @Mock
private UserRepository userRepository; private UserRepository userRepository;
@Mock @Mock
private JwtTokenProvider jwtTokenProvider; private AuthSessionService authSessionService;
@Mock @Mock
private SmsVerificationService smsVerificationService; private SmsVerificationService smsVerificationService;
@ -42,9 +55,18 @@ class AuthServiceTest {
@Mock @Mock
private AccountDeletionPhoneHasher accountDeletionPhoneHasher; private AccountDeletionPhoneHasher accountDeletionPhoneHasher;
@Mock
private Clock clock;
@InjectMocks @InjectMocks
private AuthService authService; 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 @Test
void mockLogin_shouldReturnTokenWhenCodeValid() { void mockLogin_shouldReturnTokenWhenCodeValid() {
User user = User.builder() User user = User.builder()
@ -53,17 +75,19 @@ class AuthServiceTest {
when(smsVerificationService.verifyCode("13800000001", "123456", "login")).thenReturn(true); when(smsVerificationService.verifyCode("13800000001", "123456", "login")).thenReturn(true);
when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user)); when(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user));
when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) stubSessionIssuance();
.thenReturn("access-token");
when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString()))
.thenReturn("refresh-token");
Map<String, Object> result = authService.mockLogin("13800000001", "123456", "login", "req-1", "10.0.0.1", "JUnit"); Map<String, Object> result = authService.mockLogin("13800000001", "123456", "login", "req-1", "10.0.0.1", "JUnit");
assertEquals("access-token", result.get("accessToken")); assertEquals("access-token", result.get("accessToken"));
assertEquals("refresh-token", result.get("refreshToken")); assertEquals("refresh-token", result.get("refreshToken"));
assertEquals(1L, result.get("userId")); 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 @Test
@ -75,10 +99,7 @@ class AuthServiceTest {
u.setId(99L); u.setId(99L);
return u; return u;
}); });
when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) stubSessionIssuance();
.thenReturn("access-token");
when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString()))
.thenReturn("refresh-token");
Map<String, Object> result = authService.mockLogin("13800000099", "123456", "login", null, null, null); Map<String, Object> result = authService.mockLogin("13800000099", "123456", "login", null, null, null);
@ -92,10 +113,7 @@ class AuthServiceTest {
.thenReturn(true); .thenReturn(true);
User user = User.builder().id(1L).phone("13800000001").role(User.Role.STUDENT).active(true).verified(true).build(); 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(userRepository.findByPhone("13800000001")).thenReturn(Optional.of(user));
when(jwtTokenProvider.generateAccessToken(anyLong(), anyString(), anyMap())) stubSessionIssuance();
.thenReturn("token");
when(jwtTokenProvider.generateRefreshToken(anyLong(), anyString()))
.thenReturn("refresh");
authService.mockLogin("13800000001", "123456", "password-reset", null, null, null); authService.mockLogin("13800000001", "123456", "password-reset", null, null, null);
verify(smsVerificationService, times(1)).verifyCode("13800000001", "123456", "password-reset"); verify(smsVerificationService, times(1)).verifyCode("13800000001", "123456", "password-reset");
@ -108,6 +126,8 @@ class AuthServiceTest {
assertThrows(BusinessException.class, assertThrows(BusinessException.class,
() -> authService.mockLogin("13800000001", "000000", "login", null, null, null)); () -> authService.mockLogin("13800000001", "000000", "login", null, null, null));
// 验证码失败不得创建任何会话
verify(authSessionService, never()).createSession(any(), any(), any());
} }
@Test @Test
@ -123,6 +143,7 @@ class AuthServiceTest {
assertEquals(ErrorCode.ACCOUNT_DELETED.getCode(), ex.getErrorCodeInt()); assertEquals(ErrorCode.ACCOUNT_DELETED.getCode(), ex.getErrorCodeInt());
// 不得自动创建新用户 // 不得自动创建新用户
verify(userRepository, never()).save(any()); verify(userRepository, never()).save(any());
verify(authSessionService, never()).createSession(any(), any(), any());
} }
@Test @Test

219
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 持久化契约(设计第七节,任务书 §九)。
*
* <p>覆盖:V42 迁移字段、新记录字段正确、token_jti 唯一、默认 version=0、
* 乐观锁冲突、旧格式行仍可存在、数据库不保存 Token 原文。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AuthSessionPersistenceIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(9900);
@MockBean
private RedisTemplate<String, Object> 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<String, Object> 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<String> 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<AuthToken> 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<String> 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();
}
}

200
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,任务书 §十五 与 §十九 生命周期组)。
*
* <p>覆盖:登出只撤销当前设备、多设备隔离、重复撤销幂等、
* 注销完成撤销全部会话、其他用户不受影响。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AuthSessionRevocationIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(9800);
@MockBean
private RedisTemplate<String, Object> 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<String, Object> 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());
}
}

118
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 会话服务的单元级语义(失败关闭、权限判定)。
*
* <p>并发与持久化由集成测试覆盖;这里只验证拒绝路径不产生副作用。</p>
*/
@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}");
}
}

254
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,任务书 §十七)。
*
* <p>使用 {@link AdvancingClock}:第一次读返回 T,之后返回明显不同的时间。
* 只要某个入口读了两次 Clock,断言就会失败。每个入口(登录/刷新/登出)
* 分别验证:调用次数恰为 1,且 JWT、数据库与审计时间全部来自 T。</p>
*/
@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<String, Object> 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<String, Object> 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<String, Object> login() {
return authService.mockLogin(phone, "123456", "login", "req-clock", null, null);
}
// ── 登录 ────────────────────────────────────────────────────
@Test
void loginCapturesClockExactlyOnce() {
clock.reset();
Map<String, Object> 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<String, Object> 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<String, Object> 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);
}
}

69
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 / 第八节)。
*
* <p>只验证确定性、长度与常量时间比较的语义;不依赖 Spring 上下文。</p>
*/
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));
}
}

180
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 日志与审计脱敏(设计第十二节,任务书 §十六)。
*
* <p>日志断言捕获**真实 Logback 输出**(不是 Mockito verify logger);
* 审计断言读取真实 audit_logs 行并校验 details 白名单。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class AuthTokenRedactionTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(9700);
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserRepository userRepository;
@Autowired
private AuthSessionService authSessionService;
@Autowired
private AuthTokenHasher authTokenHasher;
@Autowired
private JdbcTemplate jdbcTemplate;
private Long userId;
private ListAppender<ILoggingEvent> appender;
private Logger appLogger;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> 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<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT action, details FROM audit_logs WHERE user_id = ? AND action LIKE 'AUTH_%'", userId);
assertThat(rows).as("应写入会话相关审计").isNotEmpty();
for (Map<String, Object> 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<String> 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()));
}
}
}

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java

@ -392,7 +392,7 @@ class AuthenticationBoundaryIntegrationTest {
} }
private String token(Long userId) { 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) { private String signedTokenWithSubject(String subject) {

270
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,任务书 §五 与 §十三)。
*
* <p>真实 SecurityFilterChain + 真实 H2。核心断言:
* <strong>Refresh Token 不能访问普通业务接口</strong>,Access Token 不能用于刷新,
* 无 type / 未知 type / 非法 claim 一律 401,且响应不泄漏内部信息。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class JwtAuthenticationTypeIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(9200);
@MockBean
private RedisTemplate<String, Object> 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<String, Object> 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();
}
}

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

77
mydreamjob-server/src/test/java/com/mydreamjob/auth/JwtTokenProviderTest.java

@ -1,54 +1,85 @@
package com.mydreamjob.auth; package com.mydreamjob.auth;
import io.jsonwebtoken.*; import com.mydreamjob.config.AppJwtProperties;
import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets; import java.time.Instant;
import java.util.Map; import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
/**
* S02 {@link JwtTokenProvider} 基础行为。
*
* <p>原用例(生成、校验、过期、非法 Token)在 S02 新 API 下重写,
* 断言语义保持不变:合法 Token 可解析出用户、过期与非法 Token 一律拒绝。</p>
*
* <p>Access/Refresh 的类型与密钥隔离矩阵见 {@code JwtTokenTypeIsolationTest}。</p>
*/
class JwtTokenProviderTest { class JwtTokenProviderTest {
private JwtTokenProvider jwtTokenProvider; private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z");
private JwtTokenProvider provider;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
String secret = "test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security"; provider = new JwtTokenProvider(properties(3_600_000L, 86_400_000L));
jwtTokenProvider = new JwtTokenProvider(secret, 3600000L, 86400000L); }
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 @Test
void testTokenGenerationAndValidation() { void testTokenGenerationAndValidation() {
String token = jwtTokenProvider.generateAccessToken(1L, "13800000001", Map.of("role", "STUDENT")); IssuedToken issued = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW);
assertNotNull(token);
assertTrue(jwtTokenProvider.validateToken(token)); assertNotNull(issued.token());
assertEquals(1L, jwtTokenProvider.parseUserId(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 @Test
void testRefreshToken() { void testRefreshToken() {
String token = jwtTokenProvider.generateRefreshToken(2L, "13800000002"); IssuedToken issued = provider.generateRefreshToken(2L, uuid(), uuid(), NOW);
assertNotNull(token);
assertTrue(jwtTokenProvider.validateToken(token)); assertNotNull(issued.token());
assertEquals(2L, jwtTokenProvider.parseUserId(token)); RefreshTokenClaims claims = provider.parseAndValidateRefreshToken(issued.token(), NOW);
assertEquals(2L, claims.userId());
assertEquals(issued.tokenId(), claims.tokenId());
} }
@Test @Test
void testExpiredToken() { void testExpiredToken() {
JwtTokenProvider shortLived = new JwtTokenProvider( // exp = NOW + 1ms;在 NOW + 2ms 校验即过期(边界为 exp <= capturedNow)。
"test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security", 1L, 86400000L); JwtTokenProvider shortLived = new JwtTokenProvider(properties(1L, 86_400_000L));
try { IssuedToken issued = shortLived.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW);
Thread.sleep(10);
} catch (InterruptedException ignored) {} assertThrows(InvalidTokenException.class, () ->
String token = shortLived.generateAccessToken(1L, "13800000001", null); shortLived.parseAndValidateAccessToken(issued.token(), NOW.plusMillis(2)));
assertTrue(shortLived.isTokenExpired(token));
} }
@Test @Test
void testInvalidToken() { void testInvalidToken() {
assertFalse(jwtTokenProvider.validateToken("invalid-token")); assertThrows(InvalidTokenException.class, () ->
provider.parseAndValidateAccessToken("invalid-token", NOW));
} }
} }

260
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,任务书 §五 与 §十九)。
*
* <p>覆盖:类型 claim、独立签名密钥、issuer/audience、subject/sid/jti 校验、
* 过期边界,以及新 Token 不再包含手机号。</p>
*/
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<io.jsonwebtoken.JwtBuilder> 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();
}
}

121
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(任务书 §五)。
*
* <p>要求**失败关闭**:不能先签发 Token 再依赖认证过滤器拒绝。</p>
*/
@SpringBootTest
@ActiveProfiles("integration-test")
class S02InactiveUserSessionTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(9300);
@MockBean
private RedisTemplate<String, Object> 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<String, Object> 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<String, Object> result = authService.mockLogin(phone, "123456", "login", "req", null, null);
// 若未来语义变化为返回结果,则绝不允许携带 Token
assertThat(result).doesNotContainKeys("accessToken", "refreshToken");
} catch (RuntimeException expected) {
// 预期路径:失败关闭
}
assertThat(tokenRows()).isZero();
}
}

86
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<String, Object> redisTemplate;
@Autowired
private UserRepository userRepository;
@Autowired
private AuthSessionService authSessionService;
@Autowired
private JdbcTemplate jdbcTemplate;
private Long userId;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> 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));
}
}

65
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));
}
}

176
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。
*
* <p>S02 之后认证过滤器除验签外还要求:{@code type=access}、由 access 密钥签发、
* 且对应一个真实存在的活动登录会话与可用用户。既有测试大量直接调用
* {@code jwtTokenProvider.generateAccessToken(userId, phone, claims)},
* 在新规则下会全部 401;本类让它们只需替换辅助方法体。</p>
*
* <h2>为什么要按数据库登记</h2>
* <p>Spring 会按类缓存测试上下文,而本仓库允许用 {@code @TestPropertySource} 覆盖
* {@code spring.datasource.url} 隔离各自的 H2 库(例如 {@code CareerProgressControllerTest}
* 使用 {@code integrationdb_careerprogress})。因此夹具必须记住**每一个**出现过的测试库,
* 并在签发时把会话行写入全部已知库 —— 这样无论当前测试读的是哪个库,都能看到会话。
* 相比「只记第一个」或「记最后一个」,这是唯一与上下文缓存顺序无关的做法。</p>
*
* <p>夹具用固定有效期构造自己的 {@link JwtTokenProvider},<strong>不读取任何注入的 Clock</strong>,
* 因此不受 {@code @MockBean Clock} 影响。</p>
*
* <p><strong>仅存在于 src/test</strong>,不会进入生产制品。</p>
*/
public final class TestAuthTokens {
/** 与生产一致的固定业务时区。 */
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
/**
* 夹具签发时间:固定为足够早的过去时刻。
*
* <p>S02 的解析契约是 {@code iat <= capturedNow < exp},而部分既有测试会把
* {@code Clock} 固定在一个较早的时间(例如消息撤回的 HTTP 集成测试)。
* 若夹具用"真实当前时间"签发,这些测试下就会命中"iat 在未来"而被拒绝。
* 因此夹具统一使用一个远早于所有测试时钟的 iat,配合超长有效期。</p>
*/
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<String, Library> 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<String, Library> 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;
}
}
}

38
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}。
*
* <p>使用 {@code @AutoConfiguration}(而非 {@code spring.factories} 的
* {@code TestExecutionListener}):后者会<strong>替换</strong> Spring 的默认执行监听器,
* 导致依赖注入等基础能力失效。</p>
*
* <p>{@code @WebMvcTest} 切片没有 DataSource 与 JWT 配置,通过
* {@code ObjectProvider} 安全跳过(不用 {@code @ConditionalOnBean}:
* 它在自动配置阶段顺序敏感,可能先于 DataSourceAutoConfiguration 求值)。</p>
*/
@AutoConfiguration
public class TestAuthTokensAutoConfiguration {
@Bean
public TestAuthTokensInstaller testAuthTokensInstaller(
org.springframework.beans.factory.ObjectProvider<DataSource> dataSource,
org.springframework.beans.factory.ObjectProvider<AppJwtProperties> 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 {
}
}

4
mydreamjob-server/src/test/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchControllerTest.java

@ -58,11 +58,11 @@ class ApplicationMatchControllerTest {
} }
private String studentToken() { 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() { 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() { private ApplicationMatchRecord record() {

9
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoreCardControllerTest.java

@ -56,18 +56,15 @@ class CandidateScoreCardControllerTest {
} }
private String studentToken() { private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT");
Map.of("roles", "STUDENT"));
} }
private String companyToken() { private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY");
Map.of("roles", "COMPANY"));
} }
private String adminToken() { private String adminToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN");
Map.of("roles", "ADMIN"));
} }
private CandidateScoreCardResponse buildScoreCard(String status, int sampleCount) { private CandidateScoreCardResponse buildScoreCard(String status, int sampleCount) {

6
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateScoringControllerTest.java

@ -53,15 +53,15 @@ class CandidateScoringControllerTest {
} }
private String studentToken() { 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() { 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() { 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() { private CandidateScoringOverviewResponse buildOverview() {

2
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/dashboard/CompanyScoringDashboardControllerTest.java

@ -58,7 +58,7 @@ class CompanyScoringDashboardControllerTest {
} }
private String token(long uid, String role) { 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 { @Test void overview_company_should200_andMasked() throws Exception {

2
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/governance/CandidateScoringGovernanceControllerTest.java

@ -50,7 +50,7 @@ class CandidateScoringGovernanceControllerTest {
} }
private String token(long uid, String role) { 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) { private AppealResponse buildAppeal(Long id, String status) {

3
mydreamjob-server/src/test/java/com/mydreamjob/careerprogress/CareerProgressControllerTest.java

@ -182,8 +182,7 @@ class CareerProgressControllerTest {
} }
private String token(Long userId) { private String token(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
String.valueOf(userId), Map.of("roles", "STUDENT"));
} }
// ── 权限矩阵 ────────────────────────────────────────────── // ── 权限矩阵 ──────────────────────────────────────────────

4
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanyControllerMvcTest.java

@ -1,5 +1,6 @@
package com.mydreamjob.company; package com.mydreamjob.company;
import com.mydreamjob.auth.AuthSessionService;
import com.mydreamjob.auth.JwtTokenProvider; import com.mydreamjob.auth.JwtTokenProvider;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -15,7 +16,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@WebMvcTest(CompanyController.class) @WebMvcTest(CompanyController.class)
@AutoConfigureMockMvc(addFilters = false) @AutoConfigureMockMvc(addFilters = false)
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class}) @MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class,
AuthSessionService.class, java.time.Clock.class})
class CompanyControllerMvcTest { class CompanyControllerMvcTest {
@Autowired @Autowired

6
mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingControllerTest.java

@ -180,8 +180,7 @@ class CompetitionRankingControllerTest {
} }
private String token(Long userId) { private String token(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
String.valueOf(userId), Map.of("roles", "STUDENT"));
} }
// ── 权限矩阵 ────────────────────────────────────────────── // ── 权限矩阵 ──────────────────────────────────────────────
@ -321,7 +320,6 @@ class CompetitionRankingControllerTest {
} }
private String tokenWithRole(Long userId, User.Role role) { private String tokenWithRole(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
String.valueOf(userId), Map.of("roles", role.name()));
} }
} }

3
mydreamjob-server/src/test/java/com/mydreamjob/dataexport/PersonalDataExportControllerTest.java

@ -94,8 +94,7 @@ class PersonalDataExportControllerTest {
} }
private String token(long userId, User.Role role) { private String token(long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken( return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", role.name()));
} }
private PersonalDataExportTask pendingTask(Long userId) { private PersonalDataExportTask pendingTask(Long userId) {

10
mydreamjob-server/src/test/java/com/mydreamjob/deletion/AccountDeletionControllerTest.java

@ -82,8 +82,7 @@ class AccountDeletionControllerTest {
} }
private String token(long userId) { private String token(long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken( return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT"));
} }
@Test @Test
@ -243,10 +242,11 @@ class AccountDeletionControllerTest {
assertEquals(DeletionStatus.COMPLETED, assertEquals(DeletionStatus.COMPLETED,
deletionRepository.findByUserId(uid).orElseThrow().getStatus()); 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))) mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid)))
.andExpect(status().isOk()) .andExpect(status().isUnauthorized());
.andExpect(jsonPath("$.data.status").value("COMPLETED"));
} }
@Test @Test

3
mydreamjob-server/src/test/java/com/mydreamjob/deletion/DeletionGuardInterceptorTest.java

@ -76,8 +76,7 @@ class DeletionGuardInterceptorTest {
} }
private String token(long uid) { private String token(long uid) {
return "Bearer " + jwtTokenProvider.generateAccessToken( return com.mydreamjob.auth.support.TestAuthTokens.bearer(uid, "STUDENT");
uid, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT"));
} }
private void setStatus(long uid, DeletionStatus status, LocalDateTime effectiveAt) { private void setStatus(long uid, DeletionStatus status, LocalDateTime effectiveAt) {

2
mydreamjob-server/src/test/java/com/mydreamjob/feedback/CandidateFeedbackControllerTest.java

@ -43,7 +43,7 @@ class CandidateFeedbackControllerTest {
} }
private String token() { 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 { @Test void createFeedback_shouldReturn200() throws Exception {

2
mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/GlobalExceptionHandlerInterviewScoreTest.java

@ -51,7 +51,7 @@ class GlobalExceptionHandlerInterviewScoreTest {
ValueOperations<String, Object> ops = mock(ValueOperations.class); ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops); when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false); 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() { private String body() {

3
mydreamjob-server/src/test/java/com/mydreamjob/interviewscore/InterviewScoreControllerTest.java

@ -125,8 +125,7 @@ class InterviewScoreControllerTest {
} }
private String token(Long userId, User.Role role) { private String token(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
String.valueOf(userId), Map.of("roles", role.name()));
} }
private String bodyJson(Integer t, Integer c, Integer ps, Integer rf, Integer pf) { private String bodyJson(Integer t, Integer c, Integer ps, Integer rf, Integer pf) {

2
mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallControllerTest.java

@ -45,7 +45,7 @@ class MessageRecallControllerTest {
} }
private String token(Long userId) { 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 @Test

3
mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessageRecallHttpIntegrationTest.java

@ -118,8 +118,7 @@ class MessageRecallHttpIntegrationTest {
} }
private String token(Long userId, User.Role role) { private String token(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
java.util.Map.of("roles", role.name()));
} }
@Test @Test

2
mydreamjob-server/src/test/java/com/mydreamjob/messaging/MessagingControllerTest.java

@ -47,7 +47,7 @@ class MessagingControllerTest {
} }
private String token() { 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 { @Test void listConversations_shouldReturn200() throws Exception {

2
mydreamjob-server/src/test/java/com/mydreamjob/notification/NotificationControllerTest.java

@ -42,7 +42,7 @@ class NotificationControllerTest {
} }
private String token() { 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 { @Test void list_shouldReturn200() throws Exception {

4
mydreamjob-server/src/test/java/com/mydreamjob/offer/OfferControllerTest.java

@ -61,11 +61,11 @@ class OfferControllerTest {
} }
private String companyToken() { 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() { 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<String, Object> offerView() { private Map<String, Object> offerView() {

3
mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java

@ -75,8 +75,7 @@ class PrivacySettingsControllerTest {
} }
private String studentToken(long userId) { private String studentToken(long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken( return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT"));
} }
// ── GET:STUDENT 成功 + 无记录默认值 ────────────────────── // ── GET:STUDENT 成功 + 无记录默认值 ──────────────────────

3
mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java

@ -82,8 +82,7 @@ class ProfileControllerTest {
} }
private String bearerToken(long userId, String phone) { private String bearerToken(long userId, String phone) {
return "Bearer " + jwtTokenProvider.generateAccessToken( return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
userId, phone, Map.of("roles", "STUDENT"));
} }
private String studentToken(long userId) { private String studentToken(long userId) {

2
mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionControllerTest.java

@ -58,7 +58,7 @@ class ResumeVersionControllerTest {
} }
private String studentToken() { 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<String, Object> versionView() { private Map<String, Object> versionView() {

2
mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java

@ -71,7 +71,7 @@ class ResumeVersionPermissionControllerTest {
} }
private String token(Long userId) { 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 ──────────────────────────────────── // ── 另一学生访问 → 403 ────────────────────────────────────

4
mydreamjob-server/src/test/java/com/mydreamjob/review/AdminReviewControllerTest.java

@ -59,11 +59,11 @@ class AdminReviewControllerTest {
} }
private String adminToken() { 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() { 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 @Test

3
mydreamjob-server/src/test/java/com/mydreamjob/review/CompanyReviewManagementControllerTest.java

@ -33,8 +33,7 @@ class CompanyReviewManagementControllerTest {
@MockBean private RedisTemplate<String, Object> redisTemplate; @MockBean private RedisTemplate<String, Object> redisTemplate;
private String token(long id, String role) { private String token(long id, String role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(id, role.equals("COMPANY") ? "1" : "2", return com.mydreamjob.auth.support.TestAuthTokens.bearer(id, role);
Map.of("roles", role));
} }
@Test @Test

3
mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewControllerTest.java

@ -45,8 +45,7 @@ class JobReviewControllerTest {
} }
private String token() { private String token() {
return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "1", return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT");
java.util.Map.of("roles", "STUDENT"));
} }
@Test void createReview_shouldReturn200() throws Exception { @Test void createReview_shouldReturn200() throws Exception {

4
mydreamjob-server/src/test/java/com/mydreamjob/review/JobReviewDimensionControllerTest.java

@ -59,11 +59,11 @@ class JobReviewDimensionControllerTest {
} }
private String studentToken() { 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() { 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) { private String reviewBody(boolean withDims) {

9
mydreamjob-server/src/test/java/com/mydreamjob/review/StudentReviewWeightControllerTest.java

@ -51,18 +51,15 @@ class StudentReviewWeightControllerTest {
} }
private String studentToken() { private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT");
Map.of("roles", "STUDENT"));
} }
private String companyToken() { private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY");
Map.of("roles", "COMPANY"));
} }
private String adminToken() { private String adminToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN");
Map.of("roles", "ADMIN"));
} }
// ── 模板列表 ────────────────────────────────────────────── // ── 模板列表 ──────────────────────────────────────────────

3
mydreamjob-server/src/test/java/com/mydreamjob/risk/AdminRiskControllerTest.java

@ -103,8 +103,7 @@ class AdminRiskControllerTest {
} }
private String token(Long userId, User.Role role) { private String token(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
String.valueOf(userId), Map.of("roles", role.name()));
} }
private ScoringRiskEvent newEvent(String status, long version) { private ScoringRiskEvent newEvent(String status, long version) {

6
mydreamjob-server/src/test/java/com/mydreamjob/safety/AdminReportControllerTest.java

@ -101,13 +101,11 @@ class AdminReportControllerTest {
} }
private String token(Long userId) { private String token(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
String.valueOf(userId), Map.of("roles", "STUDENT"));
} }
private String tokenWithRole(Long userId, User.Role role) { private String tokenWithRole(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
String.valueOf(userId), Map.of("roles", role.name()));
} }
@Test @Test

3
mydreamjob-server/src/test/java/com/mydreamjob/safety/BlockControllerTest.java

@ -88,8 +88,7 @@ class BlockControllerTest {
} }
private String token(Long userId) { private String token(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
String.valueOf(userId), Map.of("roles", "STUDENT"));
} }
@Test @Test

6
mydreamjob-server/src/test/java/com/mydreamjob/safety/ReportControllerTest.java

@ -123,13 +123,11 @@ class ReportControllerTest {
} }
private String token(Long userId) { private String token(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
String.valueOf(userId), Map.of("roles", "STUDENT"));
} }
private String tokenWithRole(Long userId, User.Role role) { private String tokenWithRole(Long userId, User.Role role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
String.valueOf(userId), Map.of("roles", role.name()));
} }
// ── 用户举报 ──────────────────────────────────────────────── // ── 用户举报 ────────────────────────────────────────────────

3
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringControllerTest.java

@ -211,7 +211,6 @@ class FairnessMonitoringControllerTest {
} }
private String token(long userId, String role) { private String token(long userId, String role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role);
Map.of("roles", role));
} }
} }

3
mydreamjob-server/src/test/java/com/mydreamjob/scoringmonitoring/FairnessMonitoringHttpIntegrationTest.java

@ -301,7 +301,6 @@ class FairnessMonitoringHttpIntegrationTest {
} }
private String token(long userId, String role) { private String token(long userId, String role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role);
Map.of("roles", role));
} }
} }

3
mydreamjob-server/src/test/java/com/mydreamjob/talentdiscovery/TalentDiscoveryControllerTest.java

@ -57,8 +57,7 @@ class TalentDiscoveryControllerTest {
} }
private String token(long userId, String role) { private String token(long userId, String role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1380013" + userId, return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role);
java.util.Map.of("roles", role));
} }
@Test @Test

2
mydreamjob-server/src/test/java/com/mydreamjob/talentinvitation/TalentInvitationControllerTest.java

@ -56,6 +56,6 @@ class TalentInvitationControllerTest {
} }
private String token(Long userId, String role) { 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);
} }
} }

1
mydreamjob-server/src/test/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

@ -0,0 +1 @@
com.mydreamjob.auth.support.TestAuthTokensAutoConfiguration

3
mydreamjob-server/src/test/resources/application-integration-test.yml

@ -55,6 +55,9 @@ app:
file-ttl-hours: 24 file-ttl-hours: 24
jwt: jwt:
secret: test-jwt-secret-key-for-integration-tests-only-not-for-production 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 expiration-ms: 3600000
refresh-expiration-ms: 86400000 refresh-expiration-ms: 86400000
sms: sms:

3
mydreamjob-server/src/test/resources/application-test.yml

@ -22,6 +22,9 @@ spring:
app: app:
jwt: jwt:
secret: test-jwt-secret-key-for-unit-tests-only-not-for-production 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 expiration-ms: 3600000
refresh-expiration-ms: 604800000 refresh-expiration-ms: 604800000

Loading…
Cancel
Save