Files
lixi 273064c10d docs: 第三波交付收口——认证契约与两端实现报告入档
- 新增 API 契约:docs/api/openapi.yaml(T6a 正式化)与契约说明页(契约先行原则)
- 入档报告 16(后端 JWT 会话,37→73 测试)与 17(Flutter 登录纵切,7→30 测试)
- 进展看板更新至第三波完成,第四波为联调 E2E → CI → 编排 → 埋点
- 门禁:mkdocs build --strict 通过
2026-09-04 12:18:29 +08:00

12 KiB
Raw Permalink Blame History

16 后端认证报告:JWT + refresh 会话 + /internal 鉴权 + OpenAPI(第一迭代·第三波)

  • 执行人:Senior Developer
  • 日期:2026-09-04
  • 仓库:patbond-apidev 分支,已提交);patbond-doc 仅新增 docs/api/openapi.yaml 与本报告(均不提交,由主会话收口)
  • 范围:T4JWT RS256 access token、refresh 会话轮换与 family 撤销、/api/v1 前缀迁移、/internal 服务间鉴权、登录失败限制、expiresAt 时区修复)+ T6aOpenAPI 3 正式契约)
  • 门禁:JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean testBUILD SUCCESS73 测试 0 失败(上一波 37 → 73),Testcontainers postgres:18,无遗留容器/进程

1. 采纳的架构(对齐 02 技术评估 §3 任务 4)

会话逻辑全部下沉 patbond-useridentity schema 唯一所有者),patbond-auth 为薄入口:校验参数、编排内部调用、签发 RS256 JWT。

  • identity.auth_sessions 的读写只发生在 patbond-usersession/SessionRepositorySessionService/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(上一波已有能力,字段名无冲突,前端可忽略);错误码新增 42300HTTP 423,登录锁定)——工单第 6 项要求把锁定行为写进契约,冻结稿错误码表没有为它留码,属必要新增,见 §5。

3. Token 与会话实现(ADR-003,全部可配置)

Access tokenpatbond-auth security/JwtSigner

  • RS256jjwt 0.12.6),claimssub=userId、jti=auth_sessions.access_token_jti)、sid=sessionId、iss/iat/exp;有效期 patbond.jwt.access-ttl 默认 15m
  • 私钥经 PATBOND_JWT_PRIVATE_KEY 注入(PEM 文件路径或内联 PEM 皆可),未配置启动即失败;公钥同理注入 userPATBOND_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_detectedWARN 日志只记 family/user id,不记 token)→ 40102。过期、未知 token 同样 40102。
  • 退出:按(verified userId + refresh 摘要)撤销单个会话(reason=logout),幂等;userId 取自验签后的 access token,他人 refresh token 撤销不掉(有专门测试)。多设备并行不互踢(有专门测试)。

登录失败限制(patbond-userDB 落地)

  • 简化为按用户名计数(而非工单示例的"用户名+IP"):计数器在 identity.user_credentialsfailed_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-userInternalAuthFilterOncePerRequestFilter,注册于 /internal/*):校验 X-Internal-Tokenpatbond.internal-tokenPATBOND_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. ★ 新增错误码 42300HTTP 423:登录锁定。工单第 6 项要求锁定行为进契约,冻结稿错误码表无对应码;40100 会误导客户端提示"密码错误"。前端需增加一个分支(可先按通用错误提示处理)。
  2. ★ register 的可选 nickname 字段保留并写入 OpenAPI:上一波已实现的能力,删除反而破坏既有内部契约;对只发送冻结稿三字段的客户端完全透明。

另:锁定策略按用户名而非"用户名+IP"(工单示例措辞为"如",视为允许的简化,理由见 §3)。

6. 本波挖出并修复的两个存量缺陷(E2E 的直接产出)

跨服务 E2EAuthE2eIntegrationTest:同 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-hc5Apache 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 test73 测试,0 失败 0 错误BUILD SUCCESScommon 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 authPEM 路径或内联)
PATBOND_JWT_PUBLIC_KEY user(PEM 路径或内联;未配置时 /api/v1/** 返回 500 并记 ERROR
PATBOND_ACCESS_TTL / PATBOND_REFRESH_TTL 15m / 30d auth / userADR-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} 目前无调用方(上一波遗留),保留未动。