273064c10d
- 新增 API 契约:docs/api/openapi.yaml(T6a 正式化)与契约说明页(契约先行原则) - 入档报告 16(后端 JWT 会话,37→73 测试)与 17(Flutter 登录纵切,7→30 测试) - 进展看板更新至第三波完成,第四波为联调 E2E → CI → 编排 → 埋点 - 门禁:mkdocs build --strict 通过
12 KiB
12 KiB
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_hash32 字节约束),测试逐字节比对摘要且断言明文不落库。TTLpatbond.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侧 FeignRequestInterceptor自动附头;本地开发两端默认值一致(dev-only-internal-token,sample 注明生产必须注入强随机值)。/api/v1/*由BearerAuthFilter保护(40101),/internal/*由 InternalAuthFilter 保护,无 spring-security 依赖。
5. 相对冻结稿的偏差清单
字段名/端点/信封:零偏差。 两项显著标注的增补:
- ★ 新增错误码 42300(HTTP 423):登录锁定。工单第 6 项要求锁定行为进契约,冻结稿错误码表无对应码;40100 会误导客户端提示"密码错误"。前端需增加一个分支(可先按通用错误提示处理)。
- ★ register 的可选
nickname字段保留并写入 OpenAPI:上一波已实现的能力,删除反而破坏既有内部契约;对只发送冻结稿三字段的客户端完全透明。
另:锁定策略按用户名而非"用户名+IP"(工单示例措辞为"如",视为允许的简化,理由见 §3)。
6. 本波挖出并修复的两个存量缺陷(E2E 的直接产出)
跨服务 E2E(AuthE2eIntegrationTest:同 JVM 启动真实 user 服务 + Testcontainers postgres:18,全程真实 HTTP)首次跑通了 auth→user 的真实失败链路,立刻暴露上一波"错误码不折叠"修复(审计 M1)在真实调用中从未生效——当时所有 auth 测试都 mock 了 UserClient:
- ErrorDecoder 注册位置错误:
ApiErrorDecoder原以普通@Bean放在应用上下文,而 Feign 子上下文自带@ConditionalOnMissingBean的默认 ErrorDecoder(条件只看子上下文),父上下文的 bean 被遮蔽。修复:移入FeignInternalConfig并经@EnableFeignClients(defaultConfiguration=…)注册进每个 Feign 子上下文(该类刻意不加@Configuration,注释已说明原因)。 - JDK HttpURLConnection 读不到 401 错误体:Feign 默认传输层对流式 POST 收到 401 时
getErrorStream()为 null,错误信封不可读,一律折叠 503/50300。修复:auth 引入feign-hc5(Apache HttpClient 5,版本随 Spring Cloud BOM),OpenFeign 自动启用。 - 顺带: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. 遗留问题
- access token 无主动吊销:退出/family 撤销只影响 refresh,已签发 access 在剩余 ≤15 分钟内仍有效(行业常规,jti/sid 已入库,将来可加黑名单)。已在 openapi.yaml 说明。
/internal为静态共享密钥:02 评估建议的最小方案;换 mTLS 或 token exchange 留待后续 ADR。- auth_sessions 无清理任务:过期/撤销行会累积,需要后续加定期清理(表已有
ix_auth_sessions_active_expiry部分索引支撑)。 - user 服务公钥未配置时不 fail-fast(为测试上下文启动便利,/api/v1 请求时 500+ERROR 日志);若希望与 auth 一致改为启动即失败,是一行改动。
- 登录锁定不含 IP 维度(§3 理由);埋点列
last_failed_at已在写。 - jjwt 0.12.6 / feign-hc5 版本:jjwt 不在 Boot BOM 内、两模块各自 pin 同一版本;feign-hc5 随 Spring Cloud BOM。
GET /internal/users/by-username/{username}目前无调用方(上一波遗留),保留未动。