# 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}` 目前无调用方(上一波遗留),保留未动。