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

@@ -1,10 +1,14 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.BuildConfig
import top.yeij.cyrene.core.auth.AuthRecovery
import top.yeij.cyrene.core.auth.NoUnauthorizedPolicy
import top.yeij.cyrene.core.auth.UnauthorizedPolicy
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
@@ -19,29 +23,31 @@ object ApiClientFactory {
coerceInputValues = true
}
/** 登录接口(不注入 token)。 */
/** 登录 / 刷新 / 登出接口(不注入 token、不参与续期)。 */
fun create(
baseUrl: String,
tokenProvider: () -> String? = { null },
onUnauthorized: () -> Unit = {},
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, onUnauthorized)
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy)
/** 通用入口:按需构建任意 Retrofit service。 */
fun <T : Any> create(
baseUrl: String,
service: Class<T>,
tokenProvider: () -> String? = { null },
onUnauthorized: () -> Unit = {},
unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy,
): T {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.addInterceptor(HeaderInterceptor(tokenProvider, onUnauthorized))
.addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy))
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(
HttpLoggingInterceptor().apply {
// BASIC 是上限:`/auth/refresh` 与 `/auth/logout` 把 refresh token 放在**请求体**里,
// 再高一级就会把凭据打进 logcat。
level = HttpLoggingInterceptor.Level.BASIC
redactHeader("Authorization")
},
@@ -69,22 +75,48 @@ object ApiClientFactory {
return url
}
private class HeaderInterceptor(
/**
* 注入 `X-API-Version` 与 `Authorization`,并兜住「**带了令牌仍然** 401」:
* 先向策略要一次新令牌,拿到就用它**重试一次**;明确被拒才判终态。
*
* 为什么是应用层拦截器而不是 OkHttp `Authenticator`:
* `Authenticator` 对**任何** 401 都会被调用(包括不带令牌的登录请求),而且它由
* RetryAndFollowUp 在内部循环里驱动,重试次数(上限 20 次)不受本类控制——一个持续 401 的
* 令牌会打出一串刷新风暴。应用层拦截器只在带令牌时介入,且重试严格一次
* (OkHttp 允许应用层拦截器多次 `proceed()`,`calls != 1` 的检查只对网络拦截器生效)。
*/
private class AuthInterceptor(
private val tokenProvider: () -> String?,
private val onUnauthorized: () -> Unit,
private val unauthorizedPolicy: UnauthorizedPolicy,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider()
val request = chain.request().newBuilder()
.header("X-API-Version", "1")
.apply { if (token != null) header("Authorization", "Bearer $token") }
.build()
val response = chain.proceed(request)
// 只有「带了 token 仍 401」才算会话过期;登录请求不带 token,不受影响。
if (response.code == 401 && token != null) {
onUnauthorized()
var response = chain.proceed(chain.request().withAuth(token))
// 只有「带了 token 仍 401」才算会话被拒;登录/刷新本身不带 token,不受影响。
if (response.code != HTTP_UNAUTHORIZED || token == null) return response
when (val recovery = unauthorizedPolicy.renew(token)) {
is AuthRecovery.Renewed -> {
response.close()
response = chain.proceed(chain.request().withAuth(recovery.token))
// 换上刚签发的令牌仍然 401:服务端根本不接受这个账号,再刷也没有意义,判终态。
if (response.code == HTTP_UNAUTHORIZED) unauthorizedPolicy.onSessionEnded()
}
AuthRecovery.Unrecoverable -> unauthorizedPolicy.onSessionEnded()
// 网络 / 5xx:会话可能还好着,原样把 401 交回去,绝不清会话。
AuthRecovery.Transient -> Unit
}
return response
}
private fun Request.withAuth(token: String?): Request = newBuilder()
.header("X-API-Version", "1")
.apply { if (token != null) header("Authorization", "Bearer $token") }
.build()
}
private const val HTTP_UNAUTHORIZED = 401
}
@@ -2,6 +2,10 @@ package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.LoginRequest
import top.yeij.cyrene.data.api.dto.LoginResponse
import top.yeij.cyrene.data.api.dto.LogoutRequest
import top.yeij.cyrene.data.api.dto.LogoutResponse
import top.yeij.cyrene.data.api.dto.RefreshRequest
import top.yeij.cyrene.data.api.dto.RefreshResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
@@ -10,4 +14,10 @@ interface CyreneApi {
@POST("api/v1/auth/login")
suspend fun login(@Body body: LoginRequest): Response<ApiEnvelope<LoginResponse>>
@POST("api/v1/auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): Response<ApiEnvelope<RefreshResponse>>
@POST("api/v1/auth/logout")
suspend fun logout(@Body body: LogoutRequest): Response<ApiEnvelope<LogoutResponse>>
}
@@ -11,6 +11,49 @@ data class LoginRequest(
@Serializable
data class LoginResponse(
val token: String,
/** **Unix 秒**(不是毫秒):`time.Now().Add(24h).Unix()`。 */
val expiresAt: Long,
val role: String,
/** B2 的长期凭据;服务端没接线 refresh 存储时整个键缺失。 */
val refreshToken: String? = null,
)
/** `POST /api/v1/auth/refresh` 的请求体(公开接口,凭据就在请求体里)。 */
@Serializable
data class RefreshRequest(
val refreshToken: String,
)
/**
* `POST /api/v1/auth/refresh` 的成功载荷。
*
* - [refreshToken] 在默认(一次性轮换)模式下是**新的**凭据,旧的同一笔交易里作废;
* `AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发,此时旧凭据继续有效——所以这里必须可空,
* 调用方也要在缺失时保留原值。
* - [role] 官方文档漏写,但实现一定会返回;给默认值以免文档形状的响应解不动。
*/
@Serializable
data class RefreshResponse(
val token: String,
/** **Unix 秒**。 */
val expiresAt: Long,
val role: String = "",
val refreshToken: String? = null,
)
/** `POST /api/v1/auth/logout` 的请求体。 */
@Serializable
data class LogoutRequest(
val refreshToken: String,
)
/**
* `/auth/logout` 的响应体。文档与实现互相矛盾(`{"ok":true,"revokedCount":n}` / `{"revokedCount":n}`
* 对上真实信封里的 `{"revoked":bool}`),而本端**只看 HTTP 2xx**——所以每个字段都给默认值,
* 三种形状都能宽容解出,解不动也不影响登出。
*/
@Serializable
data class LogoutResponse(
val revoked: Boolean = false,
val revokedCount: Int = 0,
)
@@ -1,5 +1,6 @@
package top.yeij.cyrene.data.ws
import top.yeij.cyrene.core.auth.AuthRecovery
import top.yeij.cyrene.data.api.ApiClientFactory
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -49,19 +50,22 @@ import kotlin.random.Random
* - OkHttp `pingInterval` 30s(服务端 ping 周期 54s / pong 等待 60s,客户端 30s 足够)。
* - 服务端**没有 resume / 补发**:重连成功后由上层用新连接的 `history` 帧 + REST 补差去重。
* - 退避 1s/2s/4s/8s/16s/30s(上限 30s,含抖动)。
* - 握手返回 **HTTP 401** → [WsState.Offline],触发 [onAuthFailed],**不再重连**(需重新登录)。
* - 握手返回 **HTTP 401** → 先向 [onAuthRecover] 要一次续期(B2):换到新令牌就**立刻重连一次**;
* 明确被拒或重连后仍 401 → [WsState.Offline] 并触发 [onAuthFailed];暂时性失败走普通退避。
*
* ## 坏帧
* 每帧解析都走 [WsEvent.parse],非 JSON / 未知 `type` / 结构不匹配一律丢弃,不会中断连接。
*
* @param baseUrlProvider 服务器根地址(如 `http://host:8080`),每次重连时重新读取。
* @param tokenProvider 当前 JWT,每次重连时重新读取(支持静默重登后复用同一实例)。
* @param onAuthFailed 握手 401 回调;在协程内调用,注意切主线程。
* @param tokenProvider 当前 JWT,每次重连时重新读取(续期后无需重建实例)。
* @param onAuthRecover 握手 401 时的一次性兜底,见 [AuthRecovery]。
* @param onAuthFailed 终态回调(令牌被拒且无法续期);在协程内调用,注意切主线程。
* @param client 可注入自定义 OkHttpClient(会 `newBuilder` 派生,保留其拦截器);缺省新建。
*/
class ChatSocket(
private val baseUrlProvider: () -> String?,
private val tokenProvider: () -> String?,
private val onAuthRecover: () -> AuthRecovery = { AuthRecovery.Unrecoverable },
private val onAuthFailed: () -> Unit = {},
client: OkHttpClient? = null,
) {
@@ -104,6 +108,10 @@ class ChatSocket(
@Volatile
private var sessionJob: Job? = null
/** 本次 [connect] 是否已经用续期后的令牌重连过一次——避免握手 401 打成死循环。 */
@Volatile
private var authRetried: Boolean = false
private var scope: CoroutineScope? = null
/** 开始连接(幂等:已在连接 / 重连中时忽略)。 */
@@ -111,6 +119,7 @@ class ChatSocket(
if (wantConnection) return
wantConnection = true
attempt = 0
authRetried = false
sessionJob = ensureScope().launch { runConnectionLoop() }
}
@@ -153,9 +162,7 @@ class ChatSocket(
}
SessionEnd.AuthFailed -> {
wantConnection = false
_state.value = WsState.Offline
onAuthFailed()
if (!recoverAuth()) break
}
SessionEnd.Closed -> {
@@ -171,6 +178,42 @@ class ChatSocket(
}
}
/**
* 握手被拒(HTTP 401)后的恢复(B2):
* - [AuthRecovery.Renewed]:已换到新令牌,**立刻重连一次**(`tokenProvider` 每次重连都会重新读);
* - [AuthRecovery.Unrecoverable]:令牌被服务端拒绝且无法续期 → 终态,触发 [onAuthFailed];
* - [AuthRecovery.Transient]:暂时续不上(网络 / 5xx),会话保留,走普通退避重连。
*
* @return 连接循环是否继续。
*/
private suspend fun recoverAuth(): Boolean {
if (authRetried) return endWithAuthFailure()
return when (onAuthRecover()) {
is AuthRecovery.Renewed -> {
authRetried = true
attempt = 0
_state.value = WsState.Reconnecting
true
}
AuthRecovery.Transient -> {
_state.value = WsState.Reconnecting
delay(backoffDelayMs(attempt))
attempt += 1
true
}
AuthRecovery.Unrecoverable -> endWithAuthFailure()
}
}
private fun endWithAuthFailure(): Boolean {
wantConnection = false
_state.value = WsState.Offline
onAuthFailed()
return false
}
/** 建立一次连接并挂起,直到它关闭 / 失败 / 被取消。 */
private suspend fun runSession(): SessionEnd {
val url = buildUrl() ?: return SessionEnd.ConfigMissing