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
```
## 已知限制 (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 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用”
2. 无企业注册/认证流程
3. 无岗位、投递、聊天、评分等业务功能
4. 无 Token 刷新接口(Week 2+)
5. 无密码修改/重置功能
6. RabbitMQ 未实现消息监听器(仅队列定义)
7. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN
3. 无密码修改/重置功能
4. 未提供查看全部登录设备、远程踢出单个设备、Token introspection 等接口
5. RabbitMQ 未实现消息监听器(仅队列定义)
6. Actuator 健康检查部分依赖(Redis/RabbitMQ)在未启动时返回 DOWN

2
docs/backend-development-roadmap.md

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

34
docs/frontend-integration.md

@ -59,9 +59,9 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
4. 点击「确认导入」,Apifox 自动生成全部接口、参数、响应模型
5. 在 Apifox 中可直接调试:登录接口获取 token 后,设置「环境变量 `token`」,在接口「Authorization」填 `Bearer {{token}}`
## 5. Token 使用方式
## 5. Token 使用方式(S02)
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`。
登录成功返回 `accessToken`(有效期 24h)与 `refreshToken`(有效期 7 天)。
```json
{
@ -81,6 +81,36 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
Authorization: Bearer <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. 请求头
| 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": {
@ -11881,19 +11935,30 @@
},
"limitations": {
"type": "array",
"items": { "type": "string" }
"items": {
"type": "string"
}
},
"evidenceLevel": {
"type": "string",
"enum": ["DIRECT_RULE", "SELF_REPORTED", "BEHAVIOR_PROXY", "UNVALIDATED_PROXY"]
"enum": [
"DIRECT_RULE",
"SELF_REPORTED",
"BEHAVIOR_PROXY",
"UNVALIDATED_PROXY"
]
},
"ruleContributions": {
"type": "array",
"items": { "$ref": "#/components/schemas/CandidateScoringRuleContribution" }
"items": {
"$ref": "#/components/schemas/CandidateScoringRuleContribution"
}
},
"improvementActions": {
"type": "array",
"items": { "type": "string" }
"items": {
"type": "string"
}
},
"explanationVersion": {
"type": "string"
@ -15301,41 +15366,91 @@
"CandidateScoringCoverage": {
"type": "object",
"properties": {
"computableDimensions": { "type": "integer", "format": "int32" },
"totalDimensions": { "type": "integer", "format": "int32" },
"includedDimensions": { "type": "array", "items": { "type": "string" } },
"computableDimensions": {
"type": "integer",
"format": "int32"
},
"totalDimensions": {
"type": "integer",
"format": "int32"
},
"includedDimensions": {
"type": "array",
"items": {
"type": "string"
}
},
"excludedDimensions": {
"type": "array",
"items": { "$ref": "#/components/schemas/CandidateScoringExcludedDimension" }
"items": {
"$ref": "#/components/schemas/CandidateScoringExcludedDimension"
}
}
}
},
"CandidateScoringExcludedDimension": {
"type": "object",
"properties": {
"dimension": { "type": "string" },
"status": { "type": "string" },
"reason": { "type": "string" }
"dimension": {
"type": "string"
},
"status": {
"type": "string"
},
"reason": {
"type": "string"
}
}
},
"CandidateScoringRuleContribution": {
"type": "object",
"properties": {
"ruleCode": { "type": "string" },
"outcome": { "type": "string", "enum": ["APPLIED", "NOT_APPLIED", "MISSING", "EXPIRED", "UNAVAILABLE"] },
"contribution": { "type": "number", "nullable": true },
"maxContribution": { "type": "number", "nullable": true },
"label": { "type": "string" },
"observation": { "type": "string" },
"explanation": { "type": "string" }
"ruleCode": {
"type": "string"
},
"outcome": {
"type": "string",
"enum": [
"APPLIED",
"NOT_APPLIED",
"MISSING",
"EXPIRED",
"UNAVAILABLE"
]
},
"contribution": {
"type": "number",
"nullable": true
},
"maxContribution": {
"type": "number",
"nullable": true
},
"label": {
"type": "string"
},
"observation": {
"type": "string"
},
"explanation": {
"type": "string"
}
}
},
"CandidateScoringValidity": {
"type": "object",
"properties": {
"completedAt": { "type": "string", "format": "date-time" },
"validUntil": { "type": "string", "format": "date-time" },
"expired": { "type": "boolean" }
"completedAt": {
"type": "string",
"format": "date-time"
},
"validUntil": {
"type": "string",
"format": "date-time"
},
"expired": {
"type": "boolean"
}
}
},
"StudentProfileResponse": {
@ -15632,6 +15747,48 @@
"format": "date-time"
}
}
},
"RefreshTokenRequest": {
"type": "object",
"required": [
"refreshToken"
],
"properties": {
"refreshToken": {
"type": "string",
"minLength": 1,
"maxLength": 4096
}
}
},
"TokenPairResponse": {
"type": "object",
"properties": {
"accessToken": {
"type": "string"
},
"refreshToken": {
"type": "string"
},
"expiresIn": {
"type": "integer",
"format": "int64"
}
}
},
"ApiResponseTokenPairResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/TokenPairResponse"
}
}
}
},
"securitySchemes": {

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

@ -29,6 +29,8 @@
| C06 评价与企业回复 | 评价、预审、管理员决策、企业官方回复 | review/、feedback/ | 公开载荷无学生身份;仅通过回复公开;幂等及审核并发;企业归属 | 小刘 / 张顺 |
| C07 测评与评分治理 | RIASEC、四维能力、评分卡、排名、申诉回滚、防刷 | assessment/、ability/、candidatescore/、competition/、risk/ | 抽题/计时/有效期/规则开关;申诉回滚;评分读取与写入边界;结果解释 | 张顺 / 小刘 |
| C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 |
| S01 认证安全整改 | 验证码不落日志、受控取码、`/jobs` 认证边界 | auth/、docs/smoke-test.sh、job/JobController.java | 双开关受控取码;mock=false 完整回滚;`/jobs` 权限矩阵(独立代码复验与真实 MySQL/Docker 黑盒已通过) | 张顺 / 独立复验 |
| S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 |
## 3. 一期必须补齐的八个功能组

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'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 173, f"Expected 173 paths, got {len(d['paths'])}"
assert len(d['paths']) == 175, f"Expected 175 paths, got {len(d['paths'])}"
paths = d['paths']
@ -591,10 +591,51 @@ assert '/conversations' in paths and '/conversations/messages' in paths
assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths
print(' N07 recall path/schema validated; existing messaging operations preserved')
# 统计 operation 总数并校验 V43 + N07 计数 211
# ── S02:Access/Refresh Token 隔离的两个新增 operation ─────────────
# 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除;
# 新 schema 不得暴露数据库字段或凭据。
assert '/auth/refresh' in paths, 'Missing /auth/refresh'
assert '/auth/logout' in paths, 'Missing /auth/logout'
assert 'post' in paths['/auth/refresh'], '/auth/refresh must be POST'
assert 'post' in paths['/auth/logout'], '/auth/logout must be POST'
_refresh_body = paths['/auth/refresh']['post']['requestBody']['content']['application/json']['schema']
_refresh_req = resolve_schema(_refresh_body)
assert sorted(_refresh_req['properties']) == ['refreshToken'], \
f"Refresh request body must only contain refreshToken, got {sorted(_refresh_req['properties'])}"
assert 'requestBody' not in paths['/auth/logout']['post'], '/auth/logout must not declare requestBody'
# 登录接口的静态契约不得被 S02 改动(请求/响应引用保持原样)
_login_post = paths['/auth/login']['post']
assert _login_post['requestBody']['content']['application/json']['schema']['$ref'] == \
'#/components/schemas/LoginRequest', 'login request schema changed'
assert _login_post['responses']['200']['content']['*/*']['schema']['$ref'] == \
'#/components/schemas/ApiResponseMapStringObject', 'login response schema changed'
assert _login_post.get('operationId') == 'mockLogin', 'login operationId changed'
_s02_text = json.dumps({
'refresh': paths['/auth/refresh'],
'logout': paths['/auth/logout'],
'schemas': {k: v for k, v in d['components']['schemas'].items()
if k in ('RefreshTokenRequest', 'TokenPairResponse', 'ApiResponseTokenPairResponse')},
}, ensure_ascii=False)
for forbidden in ('tokenHash', 'refreshTokenHash', 'sessionId', 'revocationReason',
'phone', 'secret', 'password', 'jwt'):
assert forbidden not in _s02_text, f'S02 contract exposes forbidden field {forbidden}'
# 未新增其他认证或管理接口(认证相关路径只能是既有集合 + 这两个)
_auth_paths = {p for p in paths if p.startswith('/auth')}
assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout operations and isolation schemas validated')
# 统计 operation 总数并校验 V43 + N07 + S02 计数 213
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 211, f"Expected 211 operations, got {operations}"
assert operations == 213, f"Expected 213 operations, got {operations}"
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

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

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

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,
String details, String ipAddress, String userAgent, String requestId) {
logWithContext(userId, action, entityType, entityId, details,
AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build());
AuditContext.builder().requestId(requestId).ipAddress(ipAddress).userAgent(userAgent).build(),
LocalDateTime.now());
}
/**
@ -30,11 +31,22 @@ public class AuditService {
*/
public void log(Long userId, String action, String entityType, Long entityId,
String details, AuditContext context) {
logWithContext(userId, action, entityType, entityId, details, context);
logWithContext(userId, action, entityType, entityId, details, context, LocalDateTime.now());
}
/**
* 记录一个审计事件(显式时间戳版本,S02)。
*
* <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,
String details, AuditContext ctx) {
String details, AuditContext ctx, LocalDateTime createdAt) {
try {
// 脱敏:details 中不得包含 code/password/token/secret 等关键词的值
String safeDetails = sanitize(details);
@ -47,7 +59,7 @@ public class AuditService {
.ipAddress(ctx != null ? ctx.getIpAddress() : null)
.userAgent(ctx != null ? ctx.getUserAgent() : null)
.requestId(ctx != null ? ctx.getRequestId() : null)
.createdAt(LocalDateTime.now())
.createdAt(createdAt)
.build();
auditLogRepository.save(auditLog);
} catch (Exception e) {
@ -73,6 +85,32 @@ public class AuditService {
log(userId, "LOGOUT", "USER", userId, "User logged out", ctx);
}
/** S02:登录会话创建,使用显式 capturedNow。 */
public void logSessionCreated(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) {
log(userId, "AUTH_SESSION_CREATED", "AUTH_SESSION", null,
"{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt);
}
/** S02:Refresh Token 轮换成功,使用显式 capturedNow。 */
public void logTokenRefreshed(Long userId, String sessionRef, AuditContext ctx, LocalDateTime capturedAt) {
log(userId, "AUTH_TOKEN_REFRESHED", "AUTH_SESSION", null,
"{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt);
}
/** S02:会话被撤销(登出、重放处置或全局吊销)。 */
public void logSessionRevoked(Long userId, String sessionRef, String reason,
AuditContext ctx, LocalDateTime capturedAt) {
log(userId, "AUTH_SESSION_REVOKED", "AUTH_SESSION", null,
"{\"sessionRef\":\"" + sessionRef + "\",\"reason\":\"" + reason + "\"}", ctx, capturedAt);
}
/** S02:检测到 Refresh Token 重放。 */
public void logRefreshReuseDetected(Long userId, String sessionRef,
AuditContext ctx, LocalDateTime capturedAt) {
log(userId, "AUTH_REFRESH_REUSE_DETECTED", "AUTH_SESSION", null,
"{\"sessionRef\":\"" + sessionRef + "\"}", ctx, capturedAt);
}
/**
* 脱敏:不记录可能的敏感字段值
*/

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;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
@ -12,25 +13,34 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.*;
/**
* Mock 认证服务 - 仅用于开发/测试环境登录原型
* 认证服务(S02):手机号验证码登录,并编排刷新与登出。
*
* <p>登录成功后创建服务端登录会话;Access/Refresh 由各自的密钥签发,
* 数据库只保存 Refresh Token 的 HMAC 摘要。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
/** 登录与刷新响应中的 expiresIn 秒数,保持既有契约不变(设计 D04)。 */
public static final long EXPIRES_IN_SECONDS = 86400L;
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final SmsVerificationService smsVerificationService;
private final AuditService auditService;
private final AccountDeletionRepository accountDeletionRepository;
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService;
private final Clock clock;
/**
* Mock 登录:验证短信验证码后自动创建/登录用户
* Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。
*/
@Transactional
public Map<String, Object> mockLogin(String phone, String code, String purpose,
@ -64,28 +74,55 @@ public class AuthService {
}
// 已注销(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) {
throw new BusinessException(ErrorCode.ACCOUNT_DELETED);
}
// 生成 token
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getPhone(), Map.of("role", user.getRole().name()));
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId(), user.getPhone());
// 认证入口只取一次时间,后续 JWT iat/exp、数据库时间与审计全部同源。
Instant capturedNow = clock.instant();
AuditContext ctx = AuditContext.builder()
.requestId(requestId)
.ipAddress(ipAddress)
.userAgent(userAgent)
.build();
// 会话记录必须与登录在同一事务内持久化;失败则整个登录失败,不返回 Token。
AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, capturedNow, ctx);
auditService.logLogin(user.getId(), ipAddress, userAgent, requestId);
auditService.log(user.getId(), "LOGIN", "USER", user.getId(), "User logged in", ctx, toLocal(capturedNow));
Map<String, Object> result = new HashMap<>();
result.put("userId", user.getId());
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("accessToken", accessToken);
result.put("refreshToken", refreshToken);
result.put("expiresIn", 86400);
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", EXPIRES_IN_SECONDS);
return result;
}
/**
* 刷新:消费并轮换 Refresh Token。
*
* @throws InvalidTokenException 任何失败(统一对外 401)
*/
public AuthSessionService.SessionTokens refresh(String refreshToken,
String requestId, String ipAddress, String userAgent) {
Instant capturedNow = clock.instant();
return authSessionService.refresh(refreshToken, capturedNow, context(requestId, ipAddress, userAgent));
}
/**
* 登出:撤销当前会话(幂等)。
*/
public void logout(Long userId, String sessionId,
String requestId, String ipAddress, String userAgent) {
Instant capturedNow = clock.instant();
authSessionService.logout(userId, sessionId, capturedNow, context(requestId, ipAddress, userAgent));
}
/**
* 获取当前用户信息
*/
@ -105,4 +142,16 @@ public class AuthService {
result.put("createdAt", user.getCreatedAt());
return result;
}
private AuditContext context(String requestId, String ipAddress, String userAgent) {
return AuditContext.builder()
.requestId(requestId)
.ipAddress(ipAddress)
.userAgent(userAgent)
.build();
}
private static java.time.LocalDateTime toLocal(Instant instant) {
return AuthSessionService.toLocal(instant);
}
}

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.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* JWT Token 实体(用于 token 吊销和刷新)
* 登录会话中的 Refresh Token 记录(S02)。
*
* <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
@Builder
@ -19,9 +28,11 @@ import java.time.LocalDateTime;
@AllArgsConstructor
@Entity
@Table(name = "auth_tokens")
@EntityListeners(AuditingEntityListener.class)
public class AuthToken {
/** Token 类型:S02 起 auth_tokens 只保存 Refresh 记录。 */
public static final String TYPE_REFRESH = "REFRESH";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ -34,7 +45,7 @@ public class AuthToken {
@Column(name = "token_type", nullable = false, length = 20)
@Builder.Default
private String tokenType = "ACCESS";
private String tokenType = TYPE_REFRESH;
@Column(name = "expired_at", nullable = false)
private LocalDateTime expiredAt;
@ -46,7 +57,32 @@ public class AuthToken {
@Column(name = "refresh_token_hash", length = 255)
private String refreshTokenHash;
@CreatedDate
@Column(name = "session_id", length = 36)
private String sessionId;
@Column(name = "token_jti", length = 36)
private String tokenJti;
@Column(name = "parent_token_id")
private Long parentTokenId;
@Column(name = "consumed_at")
private LocalDateTime consumedAt;
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
@Column(name = "revocation_reason", length = 40)
private String revocationReason;
@Version
@Column(name = "version", nullable = false)
@Builder.Default
private Long version = 0L;
// S02:不使用 @CreatedDate —— Spring Data 审计会在持久化时用“当前时间”覆盖显式值,
// 导致 created_at 与认证入口捕获的 capturedNow 不同源。生产写入路径只有
// AuthSessionService(创建会话、轮换后继)两处,均显式设置 createdAt。
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

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;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* auth_tokens 数据访问(S02)。
*
* <p>并发消费与撤销**一律通过带条件的原子 UPDATE** 完成,并严格检查受影响行数;
* 禁止以 {@code findById -> set -> save} 作为并发保证。</p>
*/
@Repository
public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
@ -17,4 +26,94 @@ public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
Optional<AuthToken> findByRefreshTokenHash(String refreshTokenHash);
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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Collections;
/**
* JWT 认证过滤器
* S02 认证过滤器:只接受 Access Token。
*
* <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
@Component
@ -26,31 +38,46 @@ import java.util.Collections;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final AuthSessionService authSessionService;
private final Clock clock;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null && tokenProvider.validateToken(token)) {
if (token != null) {
authenticate(request, token);
}
filterChain.doFilter(request, response);
}
private void authenticate(HttpServletRequest request, String token) {
try {
Long userId = tokenProvider.parseUserId(token);
if (userId == null || userId <= 0) {
// 非法主体一律不建立认证;不回显 subject
log.warn("JWT authentication rejected: invalid subject");
} else {
Instant capturedNow = clock.instant();
AccessTokenClaims claims = tokenProvider.parseAndValidateAccessToken(token, capturedNow);
if (!authSessionService.isSessionActive(claims.userId(), claims.sessionId(), capturedNow)) {
SecurityContextHolder.clearContext();
log.warn("JWT authentication rejected: session not active");
return;
}
// 资源权限仍以后续服务层校验为准,这里只建立“已认证用户”身份。
// principal 保持为 userId(既有约 40 处 Long.valueOf(auth.getName()) 依赖该形态),
// 已校验的 sessionId 通过 details 传递,供 /auth/logout 使用。
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
new UsernamePasswordAuthenticationToken(claims.userId(), null, Collections.emptyList());
authentication.setDetails(new SessionAuthenticationDetails(request, claims.sessionId()));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user {} from JWT", userId);
}
} catch (InvalidTokenException e) {
// 固定安全文本:不区分过期/类型错误/签名错误,也不回显任何 claim 原值。
SecurityContextHolder.clearContext();
} 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
private String extractToken(HttpServletRequest request) {

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

@ -1,84 +1,207 @@
package com.mydreamjob.auth;
import io.jsonwebtoken.*;
import com.mydreamjob.config.AppJwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
/**
* JWT 工具类
* S02 Access/Refresh Token 签发与校验。
*
* <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
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private static final String CLAIM_TYPE = "type";
private static final String CLAIM_SID = "sid";
private static final String CLAIM_ROLE = "role";
private static final String TYPE_ACCESS = "access";
private static final String TYPE_REFRESH = "refresh";
private final SecretKey accessKey;
private final SecretKey refreshKey;
private final long expirationMs;
private final long refreshExpirationMs;
private final String issuer;
private final String accessAudience;
private final String refreshAudience;
public JwtTokenProvider(
@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.expiration-ms:86400000}") long expirationMs,
@Value("${app.jwt.refresh-expiration-ms:604800000}") long refreshExpirationMs) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expirationMs = expirationMs;
this.refreshExpirationMs = refreshExpirationMs;
public JwtTokenProvider(AppJwtProperties properties) {
this.accessKey = Keys.hmacShaKeyFor(properties.getAccessSecret().getBytes(StandardCharsets.UTF_8));
this.refreshKey = Keys.hmacShaKeyFor(properties.getRefreshSecret().getBytes(StandardCharsets.UTF_8));
this.expirationMs = properties.getExpirationMs();
this.refreshExpirationMs = properties.getRefreshExpirationMs();
this.issuer = properties.getIssuer();
this.accessAudience = properties.getAccessAudience();
this.refreshAudience = properties.getRefreshAudience();
}
public String generateAccessToken(Long userId, String phone, Map<String, Object> claims) {
return generateToken(userId, phone, claims, expirationMs);
}
// ── 签发 ────────────────────────────────────────────────────
public String generateRefreshToken(Long userId, String phone) {
return generateToken(userId, phone, Map.of("type", "refresh"), refreshExpirationMs);
public IssuedToken generateAccessToken(Long userId, String role, String sessionId,
String tokenId, Instant capturedNow) {
Instant expiresAt = capturedNow.plusMillis(expirationMs);
String token = Jwts.builder()
.subject(String.valueOf(userId))
.id(tokenId)
.claim(CLAIM_TYPE, TYPE_ACCESS)
.claim(CLAIM_SID, sessionId)
.claim(CLAIM_ROLE, role)
.issuer(issuer)
.audience().add(accessAudience).and()
.issuedAt(Date.from(capturedNow))
.expiration(Date.from(expiresAt))
.signWith(accessKey)
.compact();
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt);
}
private String generateToken(Long userId, String phone, Map<String, Object> claims, long expirationMs) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expirationMs);
return Jwts.builder()
public IssuedToken generateRefreshToken(Long userId, String sessionId,
String tokenId, Instant capturedNow) {
Instant expiresAt = capturedNow.plusMillis(refreshExpirationMs);
String token = Jwts.builder()
.subject(String.valueOf(userId))
.claim("phone", phone)
.claims(claims)
.issuedAt(now)
.expiration(expiry)
.signWith(secretKey)
.id(tokenId)
.claim(CLAIM_TYPE, TYPE_REFRESH)
.claim(CLAIM_SID, sessionId)
.issuer(issuer)
.audience().add(refreshAudience).and()
.issuedAt(Date.from(capturedNow))
.expiration(Date.from(expiresAt))
.signWith(refreshKey)
.compact();
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt);
}
// ── 校验 ────────────────────────────────────────────────────
/**
* 校验并解析 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) {
String subject = Jwts.parser()
.verifyWith(secretKey)
private Claims parse(String rawToken, SecretKey key, String expectedType,
String expectedAudience, Instant capturedNow) {
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()
.parseSignedClaims(token)
.getPayload()
.getSubject();
return Long.parseLong(subject);
.parseSignedClaims(rawToken)
.getPayload();
} catch (JwtException | IllegalArgumentException e) {
// 固定安全文本:不回显 Token、claim 原值或异常消息。
throw new InvalidTokenException();
}
// 时间契约(设计第七节):iat <= capturedNow < exp。
//
// 过期判定:exp 缺失或 exp <= capturedNow 一律拒绝。
Date expiration = claims.getExpiration();
if (expiration == null || !expiration.toInstant().isAfter(capturedNow)) {
throw new InvalidTokenException();
}
// 签发时间判定:iat 缺失或 iat > capturedNow 一律拒绝。
// 缺 iat 的 Token 无法界定有效窗口;iat 在未来说明 Token 被伪造或时钟被绕过。
Date issuedAt = claims.getIssuedAt();
if (issuedAt == null || issuedAt.toInstant().isAfter(capturedNow)) {
throw new InvalidTokenException();
}
return claims;
}
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 {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return true;
} catch (JwtException e) {
log.debug("JWT validation failed: {}", e.getMessage());
return false;
userId = Long.parseLong(subject);
} catch (NumberFormatException e) {
throw new InvalidTokenException();
}
if (userId <= 0) {
throw new InvalidTokenException();
}
return userId;
}
public boolean isTokenExpired(String token) {
private String requireCanonicalUuid(String value) {
if (value == null || value.isBlank()) {
throw new InvalidTokenException();
}
UUID parsed;
try {
return Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(token).getPayload().getExpiration().before(new Date());
} catch (JwtException e) {
return true;
parsed = UUID.fromString(value);
} catch (IllegalArgumentException e) {
throw new InvalidTokenException();
}
// 只接受规范小写连字符形式,避免宽松解析放行异常写法。
if (!parsed.toString().equals(value)) {
throw new InvalidTokenException();
}
return value;
}
private static Instant toInstant(Date date) {
return date == null ? null : date.toInstant();
}
}

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

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

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

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

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

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

@ -1,5 +1,6 @@
package com.mydreamjob.security;
import com.mydreamjob.auth.SessionAuthenticationDetails;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
@ -33,6 +34,29 @@ public class CurrentUserAccessor {
return requireUserId(SecurityContextHolder.getContext().getAuthentication());
}
/**
* 解析当前请求**已验证**的登录会话 ID(S02)。
*
* <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。
*

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

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

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

@ -74,15 +74,27 @@ spring:
fail-on-unknown-properties: false
# JWT
# S02:Access 与 Refresh 使用独立签名密钥,另设独立的 Token 摘要密钥。
# 三项密钥均无默认值,缺失即启动失败(见 AppJwtProperties 校验)。
app:
jwt:
secret: ${JWT_SECRET}
# 旧配置迁移项:S02 签发/校验的 Token **不使用**该密钥,不得回退到它。
# 仍保留是因为 talentdiscovery.CandidateKeyGenerator 用它派生候选人展示键;
# 该组件在密钥为空时自行失败关闭。这里改为**非强制解析**,使未配置 JWT_SECRET
# 的环境也能启动(Token 安全性完全由下面三项独立密钥承担)。
secret: ${JWT_SECRET:}
access-secret: ${JWT_ACCESS_SECRET}
refresh-secret: ${JWT_REFRESH_SECRET}
token-hash-secret: ${JWT_TOKEN_HASH_SECRET}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000}
issuer: mydreamjob
access-audience: mydreamjob-api
refresh-audience: mydreamjob-auth
resume:
storage-type: ${RESUME_STORAGE_TYPE:local}
storage-dir: ${RESUME_STORAGE_DIR:./resumes}
export-dir: ${RESUME_EXPORT_DIR:./resume-exports}
refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000}
async:
resume-export:

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) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId,
String.valueOf(userId), Map.of("roles", role.name()));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
}
@Test

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

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

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

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

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() {
return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT");
}
private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(COMPANY_ID, "3", Map.of("roles", "COMPANY"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_ID, "COMPANY");
}
private ApplicationMatchRecord record() {

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

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

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

@ -53,15 +53,15 @@ class CandidateScoringControllerTest {
}
private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(100L, "13800138000", Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(100L, "STUDENT");
}
private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(200L, "13800138001", Map.of("roles", "COMPANY"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(200L, "COMPANY");
}
private String adminToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(300L, "13800138002", Map.of("roles", "ADMIN"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(300L, "ADMIN");
}
private CandidateScoringOverviewResponse buildOverview() {

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) {
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 {

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) {
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) {

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

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

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

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

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

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

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) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", role.name()));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
}
private PersonalDataExportTask pendingTask(Long userId) {

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

@ -82,8 +82,7 @@ class AccountDeletionControllerTest {
}
private String token(long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
}
@Test
@ -243,10 +242,11 @@ class AccountDeletionControllerTest {
assertEquals(DeletionStatus.COMPLETED,
deletionRepository.findByUserId(uid).orElseThrow().getStatus());
// 第二次 GET 仍应为 COMPLETED,不能出现"第一次 PENDING、第二次 COMPLETED"的不一致
// S02 起:注销完成会脱敏(active=false)并撤销该用户全部会话,
// 因此用同一账号再次请求必须被拒绝为 401,而不再是 COMPLETED 的 200。
// 这不影响本用例的一致性目标 —— 状态一致仍由上方的数据库断言保证。
mockMvc.perform(get("/profile/me/deletion").header("Authorization", token(uid)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("COMPLETED"));
.andExpect(status().isUnauthorized());
}
@Test

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

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

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

@ -43,7 +43,7 @@ class CandidateFeedbackControllerTest {
}
private String token() {
return "Bearer " + jwtTokenProvider.generateAccessToken(10L, "1", Map.of("roles", "COMPANY"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY");
}
@Test void createFeedback_shouldReturn200() throws Exception {

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

@ -51,7 +51,7 @@ class GlobalExceptionHandlerInterviewScoreTest {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(any())).thenReturn(false);
token = "Bearer " + jwtTokenProvider.generateAccessToken(10L, "1", Map.of("roles", "COMPANY"));
token = com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY");
}
private String body() {

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) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId,
String.valueOf(userId), Map.of("roles", role.name()));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
}
private String bodyJson(Integer t, Integer c, Integer ps, Integer rf, Integer pf) {

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

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

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) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1",
java.util.Map.of("roles", role.name()));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
}
@Test

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

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

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

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

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

@ -61,11 +61,11 @@ class OfferControllerTest {
}
private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(COMPANY_USER_ID, "1", Map.of("roles", "COMPANY"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(COMPANY_USER_ID, "COMPANY");
}
private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT");
}
private Map<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) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "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) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, phone, Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
}
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() {
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() {

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

@ -71,7 +71,7 @@ class ResumeVersionPermissionControllerTest {
}
private String token(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, "STUDENT");
}
// ── 另一学生访问 → 403 ────────────────────────────────────

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

@ -59,11 +59,11 @@ class AdminReviewControllerTest {
}
private String adminToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(ADMIN_ID, "1", Map.of("roles", "ADMIN"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(ADMIN_ID, "ADMIN");
}
private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT");
}
@Test

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

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

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

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

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

@ -59,11 +59,11 @@ class JobReviewDimensionControllerTest {
}
private String studentToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(STUDENT_ID, "2", Map.of("roles", "STUDENT"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(STUDENT_ID, "STUDENT");
}
private String companyToken() {
return "Bearer " + jwtTokenProvider.generateAccessToken(10L, "3", Map.of("roles", "COMPANY"));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(10L, "COMPANY");
}
private String reviewBody(boolean withDims) {

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

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

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) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId,
String.valueOf(userId), Map.of("roles", role.name()));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role.name());
}
private ScoringRiskEvent newEvent(String status, long version) {

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -56,6 +56,6 @@ class TalentInvitationControllerTest {
}
private String token(Long userId, String role) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", role));
return com.mydreamjob.auth.support.TestAuthTokens.bearer(userId, role);
}
}

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
jwt:
secret: test-jwt-secret-key-for-integration-tests-only-not-for-production
access-secret: test-access-secret-key-for-integration-tests-only-not-for-production
refresh-secret: test-refresh-secret-key-for-integration-tests-only-not-for-production
token-hash-secret: test-token-hash-secret-key-for-integration-tests-only-not-for-production
expiration-ms: 3600000
refresh-expiration-ms: 86400000
sms:

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

@ -22,6 +22,9 @@ spring:
app:
jwt:
secret: test-jwt-secret-key-for-unit-tests-only-not-for-production
access-secret: test-access-secret-key-for-unit-tests-only-not-for-production
refresh-secret: test-refresh-secret-key-for-unit-tests-only-not-for-production
token-hash-secret: test-token-hash-secret-key-for-unit-tests-only-not-for-production
expiration-ms: 3600000
refresh-expiration-ms: 604800000

Loading…
Cancel
Save