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:
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
|
||||
|
||||
Reference in new issue
Block a user