docs: 第三波交付收口——认证契约与两端实现报告入档
- 新增 API 契约:docs/api/openapi.yaml(T6a 正式化)与契约说明页(契约先行原则) - 入档报告 16(后端 JWT 会话,37→73 测试)与 17(Flutter 登录纵切,7→30 测试) - 进展看板更新至第三波完成,第四波为联调 E2E → CI → 编排 → 埋点 - 门禁:mkdocs build --strict 通过
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# 16 后端认证报告:JWT + refresh 会话 + /internal 鉴权 + OpenAPI(第一迭代·第三波)
|
||||
|
||||
- 执行人:Senior Developer
|
||||
- 日期:2026-09-04
|
||||
- 仓库:`patbond-api`(dev 分支,已提交);`patbond-doc` 仅新增 `docs/api/openapi.yaml` 与本报告(均不提交,由主会话收口)
|
||||
- 范围:T4(JWT RS256 access token、refresh 会话轮换与 family 撤销、`/api/v1` 前缀迁移、`/internal` 服务间鉴权、登录失败限制、expiresAt 时区修复)+ T6a(OpenAPI 3 正式契约)
|
||||
- 门禁:`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` → **BUILD SUCCESS,73 测试 0 失败**(上一波 37 → 73),Testcontainers postgres:18,无遗留容器/进程
|
||||
|
||||
---
|
||||
|
||||
## 1. 采纳的架构(对齐 02 技术评估 §3 任务 4)
|
||||
|
||||
**会话逻辑全部下沉 `patbond-user`**(identity schema 唯一所有者),**`patbond-auth` 为薄入口**:校验参数、编排内部调用、签发 RS256 JWT。
|
||||
|
||||
- `identity.auth_sessions` 的读写只发生在 patbond-user(`session/SessionRepository`、`SessionService`、`/internal/sessions` 三个内部端点)。
|
||||
- jti 由 user 在建会话时铸造并落 `access_token_jti`,随响应带回给 auth 嵌入 JWT——一次内部调用完成建会话+对账,无需回写。
|
||||
- **`/api/v1/me` 由 patbond-user 直接验签**(RS256 公钥本地验证,`security/JwtVerifier` + `BearerAuthFilter`),请求不经过 auth。这正是选 RS256 而非 HS256 的理由:M2 起 pet/community 等资源服务同样只拿公钥即可本地验签,共享密钥不扩散。
|
||||
- auth 侧仅 logout 需要验签(取 sub 作为 userId,防跨账号撤销),用私钥推导出的公钥完成,auth 只需配置一个私钥。
|
||||
|
||||
## 2. 公开契约(冻结稿 → 实现,字段零偏差)
|
||||
|
||||
- 5 个端点:`POST /api/v1/auth/{register,login,refresh,logout}` + `GET /api/v1/me`,与冻结稿逐字段一致;`AuthTokenResponse` 恰好 6 个字段 `{userId, tokenType, accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt}`,`/me` 恰好 `{userId, username, phone, createdAt}`。测试显式断言"多余字段不存在"(旧的 username/nickname/expiresAt 已从响应移除)。
|
||||
- **旧路径 `/auth/register`、`/auth/login` 直接删除,不做兼容保留**。理由:尚无任何已发布客户端,Flutter 端正按 `/api/v1` 冻结稿并行开发,保留旧路径只会产生第二套需要测试和废弃的入口。
|
||||
- 遗留修复:`expiresAt`(无时区 `LocalDateTime`)随响应重构消亡,两个时间字段均为 `OffsetDateTime`,序列化为 ISO 8601 带偏移(报告 10 §5.2 关闭)。
|
||||
- 契约之外的说明(已在 openapi.yaml 标注):register 仍接受**可选** `nickname`(上一波已有能力,字段名无冲突,前端可忽略);错误码新增 **42300(HTTP 423,登录锁定)**——工单第 6 项要求把锁定行为写进契约,冻结稿错误码表没有为它留码,属必要新增,见 §5。
|
||||
|
||||
## 3. Token 与会话实现(ADR-003,全部可配置)
|
||||
|
||||
### Access token(patbond-auth `security/JwtSigner`)
|
||||
- RS256(jjwt 0.12.6),claims:`sub`=userId、`jti`(=auth_sessions.access_token_jti)、`sid`=sessionId、`iss`/`iat`/`exp`;有效期 `patbond.jwt.access-ttl` 默认 **15m**。
|
||||
- 私钥经 `PATBOND_JWT_PRIVATE_KEY` 注入(PEM 文件路径或内联 PEM 皆可),未配置**启动即失败**;公钥同理注入 user(`PATBOND_JWT_PUBLIC_KEY`)。sample 与 README 给出 openssl 生成命令;仓库内无任何密钥材料(测试密钥每次运行时生成,经 `@DynamicPropertySource` 注入)。
|
||||
|
||||
### Refresh 会话(patbond-user `session/*`,表结构照 V1 实现)
|
||||
- 256-bit `SecureRandom` → base64url 不透明串;库中只存 **SHA-256 摘要**(满足 `ck_sessions_refresh_hash` 32 字节约束),测试逐字节比对摘要且断言明文不落库。TTL `patbond.session.refresh-ttl` 默认 **30d**。
|
||||
- **刷新即轮换**:同事务内插入新会话行 + 关闭旧行(`revoked_at`/`rotated_at`/`replaced_by_session_id` 链到新行,reason=`rotated`),新行沿用同一 `token_family_id`。关闭旧行的 UPDATE 带 `revoked_at IS NULL` 守卫,并发轮换同一 token 时只有一个成功,失败方按重用处理。
|
||||
- **重用检测**:已轮换/已撤销的 refresh token 再次出现 → 撤销该 family 全部存活会话(reason=`reuse_detected`,WARN 日志只记 family/user id,不记 token)→ 40102。过期、未知 token 同样 40102。
|
||||
- **退出**:按(verified userId + refresh 摘要)撤销单个会话(reason=`logout`),幂等;userId 取自验签后的 access token,他人 refresh token 撤销不掉(有专门测试)。多设备并行不互踢(有专门测试)。
|
||||
|
||||
### 登录失败限制(patbond-user,DB 落地)
|
||||
- 简化为**按用户名**计数(而非工单示例的"用户名+IP"):计数器在 `identity.user_credentials`(`failed_login_count`/`failure_window_started_at`/`locked_until`),单条原子 UPDATE 完成窗口重置/累加/触锁判定,多实例与重启安全——这是 IP 维度所不具备的(IP 需额外存储且 MVP 无反向代理拓扑,`X-Forwarded-For` 不可信)。策略:**15 分钟窗口内失败 5 次 → 锁 15 分钟**(三值均为配置项);锁定期间密码正确也返回 **423/42300**;成功登录重置计数并刷 `users.last_login_at`。行为已写入 openapi.yaml 顶部说明。
|
||||
|
||||
## 4. /internal 服务间鉴权
|
||||
|
||||
- `patbond-user` 侧 `InternalAuthFilter`(OncePerRequestFilter,注册于 `/internal/*`):校验 `X-Internal-Token` 与 `patbond.internal-token`(`PATBOND_INTERNAL_TOKEN` 注入;比较用 `MessageDigest.isEqual` 常数时间);缺失/错误/服务端未配置一律 **401**(信封 code 40101,语义"服务间凭证缺失或无效"——内部接口不在公开错误码表内,复用 401 族最贴切)。未配置时 fail-closed 并记 ERROR。
|
||||
- `patbond-auth` 侧 Feign `RequestInterceptor` 自动附头;本地开发两端默认值一致(`dev-only-internal-token`,sample 注明生产必须注入强随机值)。
|
||||
- `/api/v1/*` 由 `BearerAuthFilter` 保护(40101),`/internal/*` 由 InternalAuthFilter 保护,无 spring-security 依赖。
|
||||
|
||||
## 5. 相对冻结稿的偏差清单
|
||||
|
||||
**字段名/端点/信封:零偏差。** 两项显著标注的增补:
|
||||
|
||||
1. **★ 新增错误码 42300(HTTP 423)**:登录锁定。工单第 6 项要求锁定行为进契约,冻结稿错误码表无对应码;40100 会误导客户端提示"密码错误"。前端需增加一个分支(可先按通用错误提示处理)。
|
||||
2. **★ register 的可选 `nickname` 字段保留并写入 OpenAPI**:上一波已实现的能力,删除反而破坏既有内部契约;对只发送冻结稿三字段的客户端完全透明。
|
||||
|
||||
另:锁定策略按用户名而非"用户名+IP"(工单示例措辞为"如",视为允许的简化,理由见 §3)。
|
||||
|
||||
## 6. 本波挖出并修复的两个存量缺陷(E2E 的直接产出)
|
||||
|
||||
跨服务 E2E(`AuthE2eIntegrationTest`:同 JVM 启动真实 user 服务 + Testcontainers postgres:18,全程真实 HTTP)首次跑通了 auth→user 的真实失败链路,立刻暴露上一波"错误码不折叠"修复(审计 M1)**在真实调用中从未生效**——当时所有 auth 测试都 mock 了 UserClient:
|
||||
|
||||
1. **ErrorDecoder 注册位置错误**:`ApiErrorDecoder` 原以普通 `@Bean` 放在应用上下文,而 Feign 子上下文自带 `@ConditionalOnMissingBean` 的默认 ErrorDecoder(条件只看子上下文),父上下文的 bean 被遮蔽。修复:移入 `FeignInternalConfig` 并经 `@EnableFeignClients(defaultConfiguration=…)` 注册进每个 Feign 子上下文(该类刻意不加 `@Configuration`,注释已说明原因)。
|
||||
2. **JDK HttpURLConnection 读不到 401 错误体**:Feign 默认传输层对流式 POST 收到 401 时 `getErrorStream()` 为 null,错误信封不可读,一律折叠 503/50300。修复:auth 引入 `feign-hc5`(Apache HttpClient 5,版本随 Spring Cloud BOM),OpenFeign 自动启用。
|
||||
3. 顺带:ApiErrorDecoder 解析失败不再静默吞异常,改记一条不含响应体的 WARN(响应体可能回显请求数据,不落日志)。
|
||||
|
||||
修复后 40100/40102/40900/42300 均端到端原样透传(E2E 断言)。
|
||||
|
||||
## 7. 测试与验收执行记录
|
||||
|
||||
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test`:**73 测试,0 失败 0 错误**,BUILD SUCCESS(common 3 / user 40 / auth 30)。新增 36 个,对照工单第 8 项:
|
||||
|
||||
| 验收点 | 覆盖测试 |
|
||||
| --- | --- |
|
||||
| 注册→登录→me→刷新→旧 refresh 重用被拒且 family 撤销→退出后 refresh 失效 | `AuthE2eIntegrationTest.fullAuthVerticalFlow`(真实 HTTP 全链路)+ `SessionLifecycleIntegrationTest` 7 例(含轮换链 DB 断言、摘要比对、family 撤销后存活会话数=0) |
|
||||
| access 过期/伪造 → 40101 | E2E `expiredAndForgedAccessTokensAnswer40101` + `MeEndpointTest` 5 例(缺失/过期/伪造/垃圾 token)+ `JwtSignerTest` 5 例(过期/异钥/篡改/fail-fast) |
|
||||
| /internal 无密钥 → 401 | E2E `internalEndpointsRejectCallsWithoutTheServiceCredential` + `InternalAuthFilterTest` 3 例(缺失/错误/sessions 端点) |
|
||||
| 登录失败限制生效 | E2E `repeatedLoginFailuresLockTheAccount` + `LoginLockoutIntegrationTest` 3 例(锁定、成功重置窗口、锁过期恢复) |
|
||||
| 多设备并行/退出仅当前会话 | E2E `logoutOnOneDeviceKeepsOtherDevicesLoggedIn` + `SessionLifecycleIntegrationTest`(含"他人 userId 撤销不掉"用例) |
|
||||
| 冻结契约形状(字段恰好、ISO 8601 带偏移、JWT 格式) | `AuthControllerTest` 16 例(含 40102/42300 透传、logout 三种失败)+ E2E 时间断言 |
|
||||
|
||||
既有 37 个测试全部保留并通过(UserControllerTest 仅补服务凭证头)。日志红线复核:全部新增日志语句不含密码、token(含摘要)与手机号全文。`docker ps` 无遗留容器,无遗留后台进程。
|
||||
|
||||
## 8. 配置项汇总(新增)
|
||||
|
||||
| 环境变量 | 默认 | 服务 |
|
||||
| --- | --- | --- |
|
||||
| `PATBOND_INTERNAL_TOKEN` | dev-only-internal-token | 两端(生产必须注入强随机值) |
|
||||
| `PATBOND_JWT_PRIVATE_KEY` | 无(必填,fail-fast) | auth(PEM 路径或内联) |
|
||||
| `PATBOND_JWT_PUBLIC_KEY` | 无 | user(PEM 路径或内联;未配置时 /api/v1/** 返回 500 并记 ERROR) |
|
||||
| `PATBOND_ACCESS_TTL` / `PATBOND_REFRESH_TTL` | 15m / 30d | auth / user(ADR-003) |
|
||||
| `PATBOND_LOGIN_LOCK_MAX_FAILURES` / `_WINDOW` / `_DURATION` | 5 / 15m / 15m | user |
|
||||
|
||||
README 已更新(密钥生成步骤、环境变量表、新端点、postgres 16→18 文案对齐 ADR-008)。
|
||||
|
||||
## 9. 遗留问题
|
||||
|
||||
1. **access token 无主动吊销**:退出/family 撤销只影响 refresh,已签发 access 在剩余 ≤15 分钟内仍有效(行业常规,jti/sid 已入库,将来可加黑名单)。已在 openapi.yaml 说明。
|
||||
2. **`/internal` 为静态共享密钥**:02 评估建议的最小方案;换 mTLS 或 token exchange 留待后续 ADR。
|
||||
3. **auth_sessions 无清理任务**:过期/撤销行会累积,需要后续加定期清理(表已有 `ix_auth_sessions_active_expiry` 部分索引支撑)。
|
||||
4. **user 服务公钥未配置时不 fail-fast**(为测试上下文启动便利,/api/v1 请求时 500+ERROR 日志);若希望与 auth 一致改为启动即失败,是一行改动。
|
||||
5. **登录锁定不含 IP 维度**(§3 理由);埋点列 `last_failed_at` 已在写。
|
||||
6. **jjwt 0.12.6 / feign-hc5 版本**:jjwt 不在 Boot BOM 内、两模块各自 pin 同一版本;feign-hc5 随 Spring Cloud BOM。
|
||||
7. `GET /internal/users/by-username/{username}` 目前无调用方(上一波遗留),保留未动。
|
||||
Reference in New Issue
Block a user