96 changed files with 6165 additions and 263 deletions
@ -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 验收通过的替代条件。 |
||||
@ -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。 |
||||
@ -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黑盒全部通过,可以进入精确暂存检查点。 |
||||
@ -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) { |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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) { |
||||
|
} |
||||
@ -1,84 +1,207 @@ |
|||||
package com.mydreamjob.auth; |
package com.mydreamjob.auth; |
||||
|
|
||||
import io.jsonwebtoken.*; |
import com.mydreamjob.config.AppJwtProperties; |
||||
|
import io.jsonwebtoken.Claims; |
||||
|
import io.jsonwebtoken.JwtException; |
||||
|
import io.jsonwebtoken.Jwts; |
||||
import io.jsonwebtoken.security.Keys; |
import io.jsonwebtoken.security.Keys; |
||||
import lombok.extern.slf4j.Slf4j; |
import lombok.extern.slf4j.Slf4j; |
||||
import org.springframework.beans.factory.annotation.Value; |
|
||||
import org.springframework.stereotype.Component; |
import org.springframework.stereotype.Component; |
||||
|
|
||||
import javax.crypto.SecretKey; |
import javax.crypto.SecretKey; |
||||
import java.nio.charset.StandardCharsets; |
import java.nio.charset.StandardCharsets; |
||||
|
import java.time.Instant; |
||||
import java.util.Date; |
import java.util.Date; |
||||
import java.util.Map; |
import java.util.UUID; |
||||
|
|
||||
/** |
/** |
||||
* JWT 工具类 |
* S02 Access/Refresh Token 签发与校验。 |
||||
|
* |
||||
|
* <p>核心变更:</p> |
||||
|
* <ul> |
||||
|
* <li>Access 与 Refresh 使用**不同签名密钥**,交叉验签必然失败;</li> |
||||
|
* <li>强制校验 {@code type}、{@code iss}、{@code aud},不再用“验签通过即有效”判断用途;</li> |
||||
|
* <li>新 Token 不再包含手机号;</li> |
||||
|
* <li>{@code iat}/{@code exp} 一律来自调用方传入的 {@code capturedNow},本类不自行取时;</li> |
||||
|
* <li>过期边界固定为 {@code exp <= capturedNow} 视为过期;</li> |
||||
|
* <li>对外只返回强类型 record,不暴露 JJWT {@code Claims};</li> |
||||
|
* <li>异常消息固定为安全文本,不含 Token 或 claim 原值。</li> |
||||
|
* </ul> |
||||
|
* |
||||
|
* <p>本类**不访问数据库**。</p> |
||||
*/ |
*/ |
||||
@Slf4j |
@Slf4j |
||||
@Component |
@Component |
||||
public class JwtTokenProvider { |
public class JwtTokenProvider { |
||||
|
|
||||
private final SecretKey secretKey; |
private static final String CLAIM_TYPE = "type"; |
||||
|
private static final String CLAIM_SID = "sid"; |
||||
|
private static final String CLAIM_ROLE = "role"; |
||||
|
private static final String TYPE_ACCESS = "access"; |
||||
|
private static final String TYPE_REFRESH = "refresh"; |
||||
|
|
||||
|
private final SecretKey accessKey; |
||||
|
private final SecretKey refreshKey; |
||||
private final long expirationMs; |
private final long expirationMs; |
||||
private final long refreshExpirationMs; |
private final long refreshExpirationMs; |
||||
|
private final String issuer; |
||||
|
private final String accessAudience; |
||||
|
private final String refreshAudience; |
||||
|
|
||||
public JwtTokenProvider( |
public JwtTokenProvider(AppJwtProperties properties) { |
||||
@Value("${app.jwt.secret}") String secret, |
this.accessKey = Keys.hmacShaKeyFor(properties.getAccessSecret().getBytes(StandardCharsets.UTF_8)); |
||||
@Value("${app.jwt.expiration-ms:86400000}") long expirationMs, |
this.refreshKey = Keys.hmacShaKeyFor(properties.getRefreshSecret().getBytes(StandardCharsets.UTF_8)); |
||||
@Value("${app.jwt.refresh-expiration-ms:604800000}") long refreshExpirationMs) { |
this.expirationMs = properties.getExpirationMs(); |
||||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); |
this.refreshExpirationMs = properties.getRefreshExpirationMs(); |
||||
this.expirationMs = expirationMs; |
this.issuer = properties.getIssuer(); |
||||
this.refreshExpirationMs = refreshExpirationMs; |
this.accessAudience = properties.getAccessAudience(); |
||||
|
this.refreshAudience = properties.getRefreshAudience(); |
||||
} |
} |
||||
|
|
||||
public String generateAccessToken(Long userId, String phone, Map<String, Object> claims) { |
// ── 签发 ────────────────────────────────────────────────────
|
||||
return generateToken(userId, phone, claims, expirationMs); |
|
||||
} |
|
||||
|
|
||||
public String generateRefreshToken(Long userId, String phone) { |
public IssuedToken generateAccessToken(Long userId, String role, String sessionId, |
||||
return generateToken(userId, phone, Map.of("type", "refresh"), refreshExpirationMs); |
String tokenId, Instant capturedNow) { |
||||
|
Instant expiresAt = capturedNow.plusMillis(expirationMs); |
||||
|
String token = Jwts.builder() |
||||
|
.subject(String.valueOf(userId)) |
||||
|
.id(tokenId) |
||||
|
.claim(CLAIM_TYPE, TYPE_ACCESS) |
||||
|
.claim(CLAIM_SID, sessionId) |
||||
|
.claim(CLAIM_ROLE, role) |
||||
|
.issuer(issuer) |
||||
|
.audience().add(accessAudience).and() |
||||
|
.issuedAt(Date.from(capturedNow)) |
||||
|
.expiration(Date.from(expiresAt)) |
||||
|
.signWith(accessKey) |
||||
|
.compact(); |
||||
|
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt); |
||||
} |
} |
||||
|
|
||||
private String generateToken(Long userId, String phone, Map<String, Object> claims, long expirationMs) { |
public IssuedToken generateRefreshToken(Long userId, String sessionId, |
||||
Date now = new Date(); |
String tokenId, Instant capturedNow) { |
||||
Date expiry = new Date(now.getTime() + expirationMs); |
Instant expiresAt = capturedNow.plusMillis(refreshExpirationMs); |
||||
|
String token = Jwts.builder() |
||||
return Jwts.builder() |
|
||||
.subject(String.valueOf(userId)) |
.subject(String.valueOf(userId)) |
||||
.claim("phone", phone) |
.id(tokenId) |
||||
.claims(claims) |
.claim(CLAIM_TYPE, TYPE_REFRESH) |
||||
.issuedAt(now) |
.claim(CLAIM_SID, sessionId) |
||||
.expiration(expiry) |
.issuer(issuer) |
||||
.signWith(secretKey) |
.audience().add(refreshAudience).and() |
||||
|
.issuedAt(Date.from(capturedNow)) |
||||
|
.expiration(Date.from(expiresAt)) |
||||
|
.signWith(refreshKey) |
||||
.compact(); |
.compact(); |
||||
|
return new IssuedToken(token, sessionId, tokenId, capturedNow, expiresAt); |
||||
|
} |
||||
|
|
||||
|
// ── 校验 ────────────────────────────────────────────────────
|
||||
|
|
||||
|
/** |
||||
|
* 校验并解析 Access Token。任何失败(签名、类型、issuer、audience、subject、 |
||||
|
* sid、jti、role、过期)统一抛出 {@link InvalidTokenException}。 |
||||
|
*/ |
||||
|
public AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow) { |
||||
|
Claims claims = parse(rawToken, accessKey, TYPE_ACCESS, accessAudience, capturedNow); |
||||
|
Long userId = requirePositiveSubject(claims); |
||||
|
String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class)); |
||||
|
String jti = requireCanonicalUuid(claims.getId()); |
||||
|
String role = claims.get(CLAIM_ROLE, String.class); |
||||
|
if (role == null || role.isBlank()) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
return new AccessTokenClaims(userId, sid, jti, role, |
||||
|
toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration())); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 校验并解析 Refresh Token。Refresh **不含** role,也不接受 role 参与判断。 |
||||
|
*/ |
||||
|
public RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow) { |
||||
|
Claims claims = parse(rawToken, refreshKey, TYPE_REFRESH, refreshAudience, capturedNow); |
||||
|
Long userId = requirePositiveSubject(claims); |
||||
|
String sid = requireCanonicalUuid(claims.get(CLAIM_SID, String.class)); |
||||
|
String jti = requireCanonicalUuid(claims.getId()); |
||||
|
return new RefreshTokenClaims(userId, sid, jti, |
||||
|
toInstant(claims.getIssuedAt()), toInstant(claims.getExpiration())); |
||||
} |
} |
||||
|
|
||||
public Long parseUserId(String token) { |
private Claims parse(String rawToken, SecretKey key, String expectedType, |
||||
String subject = Jwts.parser() |
String expectedAudience, Instant capturedNow) { |
||||
.verifyWith(secretKey) |
if (rawToken == null || rawToken.isBlank()) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
Claims claims; |
||||
|
try { |
||||
|
claims = Jwts.parser() |
||||
|
.verifyWith(key) |
||||
|
// 过期判定由下面显式完成:这里把解析时钟固定在很早的时间,
|
||||
|
// 避免依赖 jjwt 内部边界语义,从而严格执行本文档的契约。
|
||||
|
.clock(() -> Date.from(Instant.EPOCH)) |
||||
|
.require(CLAIM_TYPE, expectedType) |
||||
|
.requireIssuer(issuer) |
||||
|
.requireAudience(expectedAudience) |
||||
.build() |
.build() |
||||
.parseSignedClaims(token) |
.parseSignedClaims(rawToken) |
||||
.getPayload() |
.getPayload(); |
||||
.getSubject(); |
} catch (JwtException | IllegalArgumentException e) { |
||||
return Long.parseLong(subject); |
// 固定安全文本:不回显 Token、claim 原值或异常消息。
|
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
|
||||
|
// 时间契约(设计第七节):iat <= capturedNow < exp。
|
||||
|
//
|
||||
|
// 过期判定:exp 缺失或 exp <= capturedNow 一律拒绝。
|
||||
|
Date expiration = claims.getExpiration(); |
||||
|
if (expiration == null || !expiration.toInstant().isAfter(capturedNow)) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
|
||||
|
// 签发时间判定:iat 缺失或 iat > capturedNow 一律拒绝。
|
||||
|
// 缺 iat 的 Token 无法界定有效窗口;iat 在未来说明 Token 被伪造或时钟被绕过。
|
||||
|
Date issuedAt = claims.getIssuedAt(); |
||||
|
if (issuedAt == null || issuedAt.toInstant().isAfter(capturedNow)) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
|
||||
|
return claims; |
||||
} |
} |
||||
|
|
||||
public boolean validateToken(String token) { |
private Long requirePositiveSubject(Claims claims) { |
||||
|
String subject = claims.getSubject(); |
||||
|
if (subject == null || subject.isBlank()) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
long userId; |
||||
try { |
try { |
||||
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); |
userId = Long.parseLong(subject); |
||||
return true; |
} catch (NumberFormatException e) { |
||||
} catch (JwtException e) { |
throw new InvalidTokenException(); |
||||
log.debug("JWT validation failed: {}", e.getMessage()); |
|
||||
return false; |
|
||||
} |
} |
||||
|
if (userId <= 0) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
return userId; |
||||
} |
} |
||||
|
|
||||
public boolean isTokenExpired(String token) { |
private String requireCanonicalUuid(String value) { |
||||
|
if (value == null || value.isBlank()) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
UUID parsed; |
||||
try { |
try { |
||||
return Jwts.parser().verifyWith(secretKey).build() |
parsed = UUID.fromString(value); |
||||
.parseSignedClaims(token).getPayload().getExpiration().before(new Date()); |
} catch (IllegalArgumentException e) { |
||||
} catch (JwtException e) { |
throw new InvalidTokenException(); |
||||
return true; |
|
||||
} |
} |
||||
|
// 只接受规范小写连字符形式,避免宽松解析放行异常写法。
|
||||
|
if (!parsed.toString().equals(value)) { |
||||
|
throw new InvalidTokenException(); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private static Instant toInstant(Date date) { |
||||
|
return date == null ? null : date.toInstant(); |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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) { |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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) { |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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}"); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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())); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -1,54 +1,85 @@ |
|||||
package com.mydreamjob.auth; |
package com.mydreamjob.auth; |
||||
|
|
||||
import io.jsonwebtoken.*; |
import com.mydreamjob.config.AppJwtProperties; |
||||
import io.jsonwebtoken.security.Keys; |
|
||||
import org.junit.jupiter.api.BeforeEach; |
import org.junit.jupiter.api.BeforeEach; |
||||
import org.junit.jupiter.api.Test; |
import org.junit.jupiter.api.Test; |
||||
|
|
||||
import java.nio.charset.StandardCharsets; |
import java.time.Instant; |
||||
import java.util.Map; |
import java.util.UUID; |
||||
|
|
||||
import static org.junit.jupiter.api.Assertions.*; |
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
/** |
||||
|
* S02 {@link JwtTokenProvider} 基础行为。 |
||||
|
* |
||||
|
* <p>原用例(生成、校验、过期、非法 Token)在 S02 新 API 下重写, |
||||
|
* 断言语义保持不变:合法 Token 可解析出用户、过期与非法 Token 一律拒绝。</p> |
||||
|
* |
||||
|
* <p>Access/Refresh 的类型与密钥隔离矩阵见 {@code JwtTokenTypeIsolationTest}。</p> |
||||
|
*/ |
||||
class JwtTokenProviderTest { |
class JwtTokenProviderTest { |
||||
|
|
||||
private JwtTokenProvider jwtTokenProvider; |
private static final Instant NOW = Instant.parse("2026-09-14T02:00:00Z"); |
||||
|
|
||||
|
private JwtTokenProvider provider; |
||||
|
|
||||
@BeforeEach |
@BeforeEach |
||||
void setUp() { |
void setUp() { |
||||
String secret = "test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security"; |
provider = new JwtTokenProvider(properties(3_600_000L, 86_400_000L)); |
||||
jwtTokenProvider = new JwtTokenProvider(secret, 3600000L, 86400000L); |
} |
||||
|
|
||||
|
static AppJwtProperties properties(long accessMs, long refreshMs) { |
||||
|
AppJwtProperties p = new AppJwtProperties(); |
||||
|
p.setAccessSecret("test-access-secret-key-for-unit-tests-only-not-for-production"); |
||||
|
p.setRefreshSecret("test-refresh-secret-key-for-unit-tests-only-not-for-production"); |
||||
|
p.setTokenHashSecret("test-token-hash-secret-key-for-unit-tests-only-not-for-production"); |
||||
|
p.setExpirationMs(accessMs); |
||||
|
p.setRefreshExpirationMs(refreshMs); |
||||
|
p.setIssuer("mydreamjob"); |
||||
|
p.setAccessAudience("mydreamjob-api"); |
||||
|
p.setRefreshAudience("mydreamjob-auth"); |
||||
|
return p; |
||||
|
} |
||||
|
|
||||
|
static String uuid() { |
||||
|
return UUID.randomUUID().toString(); |
||||
} |
} |
||||
|
|
||||
@Test |
@Test |
||||
void testTokenGenerationAndValidation() { |
void testTokenGenerationAndValidation() { |
||||
String token = jwtTokenProvider.generateAccessToken(1L, "13800000001", Map.of("role", "STUDENT")); |
IssuedToken issued = provider.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); |
||||
assertNotNull(token); |
|
||||
assertTrue(jwtTokenProvider.validateToken(token)); |
assertNotNull(issued.token()); |
||||
assertEquals(1L, jwtTokenProvider.parseUserId(token)); |
AccessTokenClaims claims = provider.parseAndValidateAccessToken(issued.token(), NOW); |
||||
|
assertEquals(1L, claims.userId()); |
||||
|
assertEquals("STUDENT", claims.role()); |
||||
|
assertEquals(issued.sessionId(), claims.sessionId()); |
||||
|
assertEquals(issued.tokenId(), claims.tokenId()); |
||||
} |
} |
||||
|
|
||||
@Test |
@Test |
||||
void testRefreshToken() { |
void testRefreshToken() { |
||||
String token = jwtTokenProvider.generateRefreshToken(2L, "13800000002"); |
IssuedToken issued = provider.generateRefreshToken(2L, uuid(), uuid(), NOW); |
||||
assertNotNull(token); |
|
||||
assertTrue(jwtTokenProvider.validateToken(token)); |
assertNotNull(issued.token()); |
||||
assertEquals(2L, jwtTokenProvider.parseUserId(token)); |
RefreshTokenClaims claims = provider.parseAndValidateRefreshToken(issued.token(), NOW); |
||||
|
assertEquals(2L, claims.userId()); |
||||
|
assertEquals(issued.tokenId(), claims.tokenId()); |
||||
} |
} |
||||
|
|
||||
@Test |
@Test |
||||
void testExpiredToken() { |
void testExpiredToken() { |
||||
JwtTokenProvider shortLived = new JwtTokenProvider( |
// exp = NOW + 1ms;在 NOW + 2ms 校验即过期(边界为 exp <= capturedNow)。
|
||||
"test-secret-key-for-jwt-testing-at-least-256-bits-required-for-security", 1L, 86400000L); |
JwtTokenProvider shortLived = new JwtTokenProvider(properties(1L, 86_400_000L)); |
||||
try { |
IssuedToken issued = shortLived.generateAccessToken(1L, "STUDENT", uuid(), uuid(), NOW); |
||||
Thread.sleep(10); |
|
||||
} catch (InterruptedException ignored) {} |
assertThrows(InvalidTokenException.class, () -> |
||||
String token = shortLived.generateAccessToken(1L, "13800000001", null); |
shortLived.parseAndValidateAccessToken(issued.token(), NOW.plusMillis(2))); |
||||
assertTrue(shortLived.isTokenExpired(token)); |
|
||||
} |
} |
||||
|
|
||||
@Test |
@Test |
||||
void testInvalidToken() { |
void testInvalidToken() { |
||||
assertFalse(jwtTokenProvider.validateToken("invalid-token")); |
assertThrows(InvalidTokenException.class, () -> |
||||
|
provider.parseAndValidateAccessToken("invalid-token", NOW)); |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
com.mydreamjob.auth.support.TestAuthTokensAutoConfiguration |
||||
Loading…
Reference in new issue