feat(auth): 接入 /auth/refresh,不再把登录口令落盘

客户端此前把明文口令存进 EncryptedSharedPreferences,用于过期后的静默重登
(当时后端没有 refresh 端点)。后端现在有 /auth/refresh + /auth/logout 了,
本轮改为存 refresh token,口令在首次成功认证后即被抹掉。

关键约束:服务端的 refresh 是**单次使用 + 原子旋转 + 无宽限期**——同一个 token
第二次使用立刻 invalid_grant;而本端并发 401 是常态(管理概览页两个请求并发、
聊天页 REST 与 WS 同时起步)。因此:

- core/auth/TokenRefresher.kt:单飞刷新 + 代际守卫(等锁期间别人已换好令牌就直接
  复用,绝不用同一个 refresh token 打第二次)+ 终态记忆(被 invalid_grant 拒过的
  token 记下来,一次失效里只打一次注定失败的请求;Transient 不记忆以保留重试)
- 用应用层 Interceptor 而非 OkHttp Authenticator:后者对**任何** 401 都会被调用
  (含不带令牌的登录请求),且由 RetryAndFollowUp 内部循环驱动(上限 20 次跟进),
  持续 401 的令牌会打出刷新风暴。应用层拦截器只在「带了令牌仍 401」时介入,且严格
  重试一次
- 可恢复的 401 不再登出:Transient(网络/5xx)原样交回 401 并保留会话;只有
  Unrecoverable,或「换上刚签发的令牌仍 401」,才判终态
- WS 握手 401:先尝试一次续期再重连,终态才登出
- 登出补调 POST /auth/logout(此前从不调用,服务端 grant 会白活 30 天)
- 主动 skew(60s)刷新 + 兜底重试;刷新请求走不带令牌的客户端,不会递归回本路径

迁移对用户无感、绝不强制重登:启动时若「无 refresh token 但有旧口令」,用旧路径
静默登录一次换到 refresh token,随即 commit() 抹掉口令;离线失败就下次启动再试,
且不清会话。另外鉴权过期时也走一次续期——否则去掉口令会把会话砍到 24h,比改动前
更差。

HttpLoggingInterceptor 保持 BASIC 不动:/auth/refresh 与 /auth/logout 把 refresh
token 放在**请求体**里,再高一级就会把凭据打进 logcat。

测试:153 全绿(115 → +38)。含真实抓取响应体的 DTO 契约测试、TokenRefresher 的
并发/代际/终态单测、bootstrap 迁移单测,以及走真实 OkHttp + Retrofit + 回环
HttpServer 的端到端测试(证明只重试一次)。未新增任何依赖。

未验证项:主动/被动续期路径本身没能真机触发(唯一输入是加密存储里的 expires_at,
shell 写不进去;而 tokenTTL 是 24h 常量),仅由回环端到端测试覆盖。
This commit is contained in:
AskaEth committed 2026-10-04 13:34:53 +08:00
1 parent 09a3222b7b
commit f5576540b4
14 files changed
+1730 -65

No files matched your search

@@ -0,0 +1,197 @@
package top.yeij.cyrene.data.api.dto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
import top.yeij.cyrene.data.api.ApiErrorEnvelope
/**
* 认证契约钉死测试(B2),用的是**线上后端实际抓下来的响应体**
* (`http://127.0.0.1:10942`,2026-10-04),只把凭据换成了合成值,其余逐字节照抄。
*
* 解码器用 [ApiClientFactory.json]——与生产同一份 `Json` 配置
* (`ignoreUnknownKeys` / `explicitNulls=false` / `coerceInputValues`),
* 所以这里过不了的形状,线上也一定过不了。
*
* 覆盖真实出现、而手写 JSON 容易漏掉的形状:`expiresAt` 是**秒**不是毫秒、
* `/auth/refresh` 会多回一个文档没写的 `role`、轮换模式下多回一个 `refreshToken`、
* `/auth/logout` 把 `{"revoked":bool}` 包在信封里(文档写的是另外两种形状)。
*/
class AuthDtoRealPayloadTest {
private val json = ApiClientFactory.json
// 合成凭据:形状与线上一致(JWT 三段点分;refresh token 是 32 字节 base64url、无填充 = 43 字符),
// 但值全是编的,真实凭据绝不进仓库。
private val jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
".eyJzdWIiOiJ0ZXN0dXNlciIsInJvbGUiOiJvd25lciIsImV4cCI6MTc5MTE3NzU4NX0" +
".c2lnbmF0dXJlLXN5bnRoZXRpYw"
private val rotatedJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
".eyJzdWIiOiJ0ZXN0dXNlciIsInJvbGUiOiJvd25lciIsImV4cCI6MTc5MTE3NzU4N30" +
".c2lnbmF0dXJlLXN5bnRoZXRpYw"
private val opaque = "kJ7bQ2mZ9vRt4wXp1sNc6yHd3gLf8aE0uTi5oPq2rVs"
private val rotatedOpaque = "mR8cT1nW4yK7dZ0jL3sB6vG9aQ2eH5uXp4rD7fN1oM"
/** 登录请求发出时的本机 Unix 秒;`expiresAt` 与它的差就是服务端声明的有效期。 */
private val loginCapturedAtSeconds = 1_791_091_185L
// —— 以下 6 段是真实响应体(凭据已替换为合成值) ——
private val realLogin = """
{"data":{"token":"$jwt","expiresAt":1791177585,"role":"owner","refreshToken":"$opaque"}}
""".trimIndent()
private val realRefreshRotated = """
{"data":{"token":"$rotatedJwt","expiresAt":1791177587,"role":"owner","refreshToken":"$rotatedOpaque"}}
""".trimIndent()
/**
* `AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端**不下发**新凭据。线上环境关不掉这个开关,
* 所以这一段是按 `refreshResponse` 的 `omitempty` 与文档形状构造的(`role` 也缺一次,
* 因为官方文档恰好漏写了它)。
*/
private val refreshWithoutRotation = """
{"data":{"token":"$rotatedJwt","expiresAt":1791177587,"role":"owner"}}
""".trimIndent()
/** 文档形状:只有 token / expiresAt,没有 role、没有 refreshToken。 */
private val refreshDocumentedShape = """
{"data":{"token":"$rotatedJwt","expiresAt":1791177587}}
""".trimIndent()
private val realRefreshInvalidGrant = """
{"error":{"code":"invalid_grant","message":"invalid refresh token"}}
""".trimIndent()
private val realProtectedUnauthorized = """
{"error":{"code":"unauthorized","message":"auth: unauthorized"}}
""".trimIndent()
private val realLogoutRevoked = """{"data":{"revoked":true}}"""
private val realLogoutNotRevoked = """{"data":{"revoked":false}}"""
@Test
fun `real login payload carries a refresh token and expiresAt in seconds`() {
val data = json
.decodeFromString(ApiEnvelope.serializer(LoginResponse.serializer()), realLogin)
.data!!
assertEquals(jwt, data.token)
assertEquals("owner", data.role)
// expiresAt 是 **Unix 秒**:1_791_177_585 正好是抓包时刻 + 86400(24h)。
assertEquals(1_791_177_585L, data.expiresAt)
assertEquals(86_400L, data.expiresAt - loginCapturedAtSeconds)
// 毫秒会是 ~1.79e12;这里必须是秒,否则客户端算出来的有效期会差 1000 倍。
assertTrue("expiresAt 必须是秒", data.expiresAt < 10_000_000_000L)
// B2 新增:长期凭据确实随登录一起下发。
assertEquals(opaque, data.refreshToken)
assertEquals("线上 refresh token 是 32 字节 base64url、无填充", 43, opaque.length)
}
@Test
fun `login payload without a refresh token still decodes`() {
// 服务端没接线 refresh 存储时整个键缺失(`omitempty`)——B2 之前的契约。
val legacy = """{"data":{"token":"$jwt","expiresAt":1791177585,"role":"owner"}}"""
val data = json
.decodeFromString(ApiEnvelope.serializer(LoginResponse.serializer()), legacy)
.data!!
assertEquals(jwt, data.token)
assertNull(data.refreshToken)
}
@Test
fun `real refresh payload rotates the token and also returns the documented-missing role`() {
val data = json
.decodeFromString(ApiEnvelope.serializer(RefreshResponse.serializer()), realRefreshRotated)
.data!!
assertEquals(rotatedJwt, data.token)
assertNotEquals(jwt, data.token)
assertEquals(1_791_177_587L, data.expiresAt)
assertTrue("expiresAt 必须是秒", data.expiresAt < 10_000_000_000L)
// 文档漏写了 role,实现一定会给——客户端不能因为文档就没这个字段。
assertEquals("owner", data.role)
// 轮换模式:新凭据与旧的不同,旧的在同一次交易里作废。
assertEquals(rotatedOpaque, data.refreshToken)
assertNotEquals(opaque, data.refreshToken)
}
@Test
fun `refresh payload without a rotated token keeps the previous credential`() {
val data = json
.decodeFromString(ApiEnvelope.serializer(RefreshResponse.serializer()), refreshWithoutRotation)
.data!!
assertEquals(rotatedJwt, data.token)
assertEquals("owner", data.role)
// 关掉轮换时服务端不下发新凭据:客户端必须能解成 null 并保留手里那份。
assertNull(data.refreshToken)
}
@Test
fun `documented refresh shape without role decodes to an empty role`() {
val data = json
.decodeFromString(ApiEnvelope.serializer(RefreshResponse.serializer()), refreshDocumentedShape)
.data!!
assertEquals(rotatedJwt, data.token)
assertEquals("", data.role)
assertNull(data.refreshToken)
}
@Test
fun `real refresh rejection is 401 invalid_grant`() {
val error = json
.decodeFromString(ApiErrorEnvelope.serializer(), realRefreshInvalidGrant)
.error!!
// 未知 / 已吊销 / 已过期 / **已经被用掉** 四种情况被服务端折叠成同一个码,
// 因此这个码是终态信号:只能重新登录。
assertEquals("invalid_grant", error.code)
}
@Test
fun `real protected endpoint rejection uses a different code than the refresh one`() {
val error = json
.decodeFromString(ApiErrorEnvelope.serializer(), realProtectedUnauthorized)
.error!!
// 受保护端点上的 401 是 `unauthorized`(访问令牌坏了)——和 `invalid_grant` 必须区分开:
// 前者可以续期,后者只能重登。
assertEquals("unauthorized", error.code)
assertNotEquals("invalid_grant", error.code)
}
@Test
fun `real logout bodies decode in the envelope shape that the docs do not describe`() {
val revoked = json
.decodeFromString(ApiEnvelope.serializer(LogoutResponse.serializer()), realLogoutRevoked)
.data!!
assertTrue(revoked.revoked)
val notRevoked = json
.decodeFromString(ApiEnvelope.serializer(LogoutResponse.serializer()), realLogoutNotRevoked)
.data!!
// 幂等:未知 / 已吊销的凭据仍然 200,只是 revoked=false。
assertTrue(!notRevoked.revoked)
}
@Test
fun `logout bodies in the two documented shapes decode without throwing`() {
// 文档互相矛盾(api-reference 说 `{"ok":true,"revokedCount":n}`,api-client 说 `{"revokedCount":n}`),
// 真实实现又是第三种。本端只看 2xx,所以三种形状都必须能宽容解出、不炸。
for (body in listOf("""{"ok":true,"revokedCount":3}""", """{"revokedCount":3}""", """{}""")) {
val envelope = json.decodeFromString(ApiEnvelope.serializer(LogoutResponse.serializer()), body)
assertNull("文档形状没有 data 信封:$body", envelope.data)
}
}
}