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

@@ -37,8 +37,8 @@ class AppContainer(context: Context) {
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = SessionsApi::class.java,
tokenProvider = { sessionStore.load().token },
onUnauthorized = { authManager.onSessionExpired() },
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedBaseUrl = baseUrl
cachedSessionsApi = created
@@ -61,8 +61,8 @@ class AppContainer(context: Context) {
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = AdminApi::class.java,
tokenProvider = { sessionStore.load().token },
onUnauthorized = { authManager.onSessionExpired() },
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedAdminBaseUrl = baseUrl
cachedAdminApi = created
@@ -83,8 +83,8 @@ class AppContainer(context: Context) {
val created = ApiClientFactory.create(
baseUrl = baseUrl,
service = MetaApi::class.java,
tokenProvider = { sessionStore.load().token },
onUnauthorized = { authManager.onSessionExpired() },
tokenProvider = authManager::currentToken,
unauthorizedPolicy = authManager.unauthorizedPolicy,
)
cachedMetaBaseUrl = baseUrl
cachedMetaApi = created
@@ -95,7 +95,8 @@ class AppContainer(context: Context) {
fun createChatSocket(): ChatSocket = ChatSocket(
baseUrlProvider = { authManager.currentBaseUrl },
tokenProvider = { sessionStore.load().token },
onAuthFailed = { authManager.onSessionExpired() },
tokenProvider = authManager::currentToken,
onAuthRecover = authManager::recoverSocketAuth,
onAuthFailed = authManager::onSessionExpired,
)
}
@@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import top.yeij.cyrene.core.auth.SessionState
import top.yeij.cyrene.ui.chat.ChatHomeScreen
import top.yeij.cyrene.ui.common.LoadingScreen
@@ -12,6 +14,7 @@ import top.yeij.cyrene.ui.login.LoginScreen
@Composable
fun CyreneApp(container: AppContainer) {
val state by container.authManager.state.collectAsState()
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
container.authManager.bootstrap()
@@ -22,7 +25,8 @@ fun CyreneApp(container: AppContainer) {
SessionState.LoggedOut -> LoginScreen(container)
is SessionState.LoggedIn -> ChatHomeScreen(
container = container,
onLogout = { container.authManager.logout() },
// 登出要先把服务端的 refresh 授权吊销掉,所以是挂起的;UI 不因为它在主线程等待。
onLogout = { scope.launch { container.authManager.logout() } },
)
}
}
@@ -5,11 +5,16 @@ import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiErrorEnvelope
import top.yeij.cyrene.data.api.CyreneApi
import top.yeij.cyrene.data.api.dto.LoginRequest
import top.yeij.cyrene.data.api.dto.LogoutRequest
import top.yeij.cyrene.data.api.dto.RefreshRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -22,30 +27,88 @@ sealed interface SessionState {
data class LoggedIn(val baseUrl: String, val role: String) : SessionState
}
class AuthManager(private val store: SessionStore) {
/**
* 会话生命周期:登录、静默恢复、续期、登出。
*
* B2 起密码**不再落盘**:长期凭据是 refresh token,访问令牌过期时用它续期,
* 续期由 [TokenRefresher] 单飞协调(并发 401 只会真的刷一次——服务端轮换无宽限期)。
*
* @param apiFactory 认证接口的构造方式。默认走真实 Retrofit;单测注入假实现即可覆盖
* 登录 / 迁移 / 登出路径,既不需要网络也不需要新增依赖。
*/
class AuthManager(
private val store: SessionStorage,
private val apiFactory: (String) -> CyreneApi = { ApiClientFactory.create(baseUrl = it) },
) {
private val _state = MutableStateFlow<SessionState>(SessionState.Unknown)
val state: StateFlow<SessionState> = _state.asStateFlow()
private var api: CyreneApi? = null
@Volatile
var currentBaseUrl: String = ""
private set
/** 启动时:有效 token 直接进入;过期则用已存凭据静默重登(D12);否则回登录页。 */
private val refresher = TokenRefresher(store, ::refreshCall)
/** 不带令牌的认证客户端(登录 / 刷新 / 登出),按 baseUrl 缓存。可被 OkHttp 线程读到。 */
@Volatile
private var authApi: CyreneApi? = null
@Volatile
private var authApiBaseUrl: String? = null
/**
* 401 处置:能续期就用新令牌重试一次,明确被拒才判终态。
* 暂时性失败(网络 / 5xx)**绝不清会话**——见 B2 §6。
*/
val unauthorizedPolicy: UnauthorizedPolicy = object : UnauthorizedPolicy {
override fun renew(usedToken: String): AuthRecovery = refresher.renew(usedToken)
override fun onSessionEnded() = onSessionExpired()
}
/** tokenProvider 与 WS 握手用:临近过期时阻塞地先换令牌。 */
fun currentToken(): String? = refresher.ensureToken()
/**
* WS 握手 401 的一次性恢复:换一次令牌让上层重连。
* 与 401 拦截器共用同一条单飞路径,因此 REST 与 WS 同时失效也只会打一次刷新。
*/
fun recoverSocketAuth(): AuthRecovery = refresher.renew(store.load().token)
/**
* 启动时恢复会话(D12 + B2 迁移)。顺序即优先级:
*
* 1. **迁移**:没有 refresh token 却还留着旧版的明文密码 → 用旧凭据静默登录一次换 refresh token,
* 成功后立刻把它抹掉;失败(例如离线)保留原值、**不登出**,下次启动再试。
* 2. 已经有 refresh token → 任何残留的明文密码立刻抹掉。
* 3. 访问令牌过期 → 用 refresh token 续期。密码没了以后,这是唯一的静默恢复途径;
* 没有了它,隔一天再打开就要重新输密码(比旧版还差)。
* 4. 仍然拿不到有效令牌 → 回登录页(username 仍预填)。
*/
suspend fun bootstrap() = withContext(Dispatchers.IO) {
val stored = store.load()
currentBaseUrl = stored.baseUrl
when {
!stored.token.isNullOrBlank() && stored.expiresAt * 1000 > System.currentTimeMillis() -> {
_state.value = SessionState.LoggedIn(stored.baseUrl, stored.role.orEmpty())
}
var session = store.load()
currentBaseUrl = session.baseUrl
!stored.username.isNullOrBlank() && !stored.password.isNullOrBlank() -> {
val error = doLogin(stored.baseUrl, stored.username, stored.password)
if (error != null) _state.value = SessionState.LoggedOut
}
val legacyUser = session.username?.takeIf(String::isNotBlank)
val legacyPassword = session.password?.takeIf(String::isNotBlank)
if (session.refreshToken.isNullOrBlank() && legacyUser != null && legacyPassword != null) {
doLogin(session.baseUrl, legacyUser, legacyPassword)
session = store.load()
} else if (!session.password.isNullOrBlank()) {
store.purgePassword()
session = store.load()
}
else -> _state.value = SessionState.LoggedOut
if (!session.hasValidToken() && !session.refreshToken.isNullOrBlank()) {
refresher.renew(session.token)
session = store.load()
}
_state.value = if (session.hasValidToken()) {
SessionState.LoggedIn(session.baseUrl, session.role.orEmpty())
} else {
SessionState.LoggedOut
}
}
@@ -54,28 +117,45 @@ class AuthManager(private val store: SessionStore) {
doLogin(ApiClientFactory.normalizeBaseUrl(rawBaseUrl), username, password)
}
fun logout() {
/**
* 主动登出:先尽力吊销服务端的 refresh 授权(否则本地「登出」之后那条授权还能活 30 天),
* 再抹掉本地一切。吊销失败、超时、离线都不阻断本地清理,所以离线登出依然干净。
*/
suspend fun logout() = withContext(Dispatchers.IO) {
val refreshToken = store.load().refreshToken?.takeIf(String::isNotBlank)
if (refreshToken != null) {
// 吊销请求不能拖住登出:给一个短上限,超时就当离线处理。
withTimeoutOrNull(REVOKE_TIMEOUT_MS) {
try {
authApi(sessionBaseUrl()).logout(LogoutRequest(refreshToken))
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// 尽力而为:离线 / 服务端报错也必须把本地清干净。
}
}
}
store.clearAll()
api = null
authApi = null
authApiBaseUrl = null
_state.value = SessionState.LoggedOut
}
/** 会话过期(401):清 token、回登录页;凭据保留以便下次静默重登。 */
/** 会话终结(refresh 不可能或明确被拒):清令牌回登录页;username 保留以便预填。 */
fun onSessionExpired() {
store.clearSession()
api = null
_state.value = SessionState.LoggedOut
}
private suspend fun doLogin(url: String, username: String, password: String): CyreneError? {
val client = ApiClientFactory.create(baseUrl = url)
return try {
val response = client.login(LoginRequest(username, password))
val response = authApi(url).login(LoginRequest(username, password))
val data = response.body()?.data
when {
response.isSuccessful && data != null -> {
store.saveLogin(url, data.token, data.expiresAt, data.role, username, password)
api = client
store.saveLogin(url, data.token, data.expiresAt, data.role, username, data.refreshToken)
// B2 红线:不再写明文密码。拿到长期凭据后,把历史遗留的那一份也一并抹掉。
if (!data.refreshToken.isNullOrBlank()) store.purgePassword()
currentBaseUrl = url
_state.value = SessionState.LoggedIn(url, data.role)
null
@@ -85,7 +165,7 @@ class AuthManager(private val store: SessionStore) {
else -> {
val parsed = parseError(response.errorBody()?.string())
if (response.code() == 401) {
if (response.code() == HTTP_UNAUTHORIZED) {
CyreneError.Unauthorized(parsed?.code.orEmpty(), parsed?.message.orEmpty())
} else {
CyreneError.Server(
@@ -101,6 +181,52 @@ class AuthManager(private val store: SessionStore) {
}
}
/**
* [TokenRefresher] 注入的阻塞刷新调用。
*
* 它跑在 OkHttp 的调用线程上(拦截器 / tokenProvider),所以这里用 `runBlocking` 就地驱动
* Retrofit 的挂起接口——那正是此刻需要的语义:同步等结果。
*
* 客户端**不带令牌**,镜像 [doLogin]:既不会让过期令牌搭车,也不会把刷新的 401 送回
* 本类的过期处理路径(那两个客户端用的是 [NoUnauthorizedPolicy])。
*/
private fun refreshCall(refreshToken: String): RefreshCall {
val baseUrl = sessionBaseUrl()
if (baseUrl.isBlank()) return RefreshCall.Failed
return try {
val response = runBlocking { authApi(baseUrl).refresh(RefreshRequest(refreshToken)) }
val data = response.body()?.data
when {
response.isSuccessful && data != null ->
RefreshCall.Ok(data.token, data.expiresAt, data.refreshToken)
// 200 但解不动:不拿会话去赌,当暂时性失败。
response.isSuccessful -> RefreshCall.Failed
// 该接口唯一的 401 就是 invalid_grant(未知 / 已吊销 / 已过期 / 已被用掉)。
response.code() == HTTP_UNAUTHORIZED -> RefreshCall.Rejected
else -> RefreshCall.Failed
}
} catch (_: Exception) {
RefreshCall.Failed
}
}
private fun authApi(baseUrl: String): CyreneApi {
val cached = authApi
if (cached != null && authApiBaseUrl == baseUrl) return cached
val created = apiFactory(baseUrl)
authApi = created
authApiBaseUrl = baseUrl
return created
}
private fun sessionBaseUrl(): String = currentBaseUrl.ifBlank { store.load().baseUrl }
private fun StoredSession.hasValidToken(): Boolean =
!token.isNullOrBlank() && expiresAt * 1000 > System.currentTimeMillis()
private fun parseError(body: String?) = body
?.takeIf { it.isNotBlank() }
?.let {
@@ -118,4 +244,11 @@ class AuthManager(private val store: SessionStore) {
is IOException -> CyreneError.Local(CyreneError.LocalKind.NoNetwork)
else -> CyreneError.Local(CyreneError.LocalKind.Unknown)
}
private companion object {
const val HTTP_UNAUTHORIZED = 401
/** 吊销授权的最长等待:到点就当离线,本地照样清干净。 */
const val REVOKE_TIMEOUT_MS = 3_000L
}
}
@@ -11,11 +11,55 @@ data class StoredSession(
val expiresAt: Long,
val role: String?,
val username: String?,
/** 长期凭据(B2);B2 之前登录的安装没有这一项,因此可能为 null。 */
val refreshToken: String?,
/**
* **遗留字段**:B2 之前的版本把登录密码落在这里。
* 只读用于一次性迁移,绝不再写入;见 [SessionStorage.purgePassword]。
*/
val password: String?,
)
/** JWT 与登录凭据的加密存储(红线:不落明文、不打日志)。 */
class SessionStore(context: Context) {
/**
* 会话存储契约。[SessionStore] 是唯一的生产实现(EncryptedSharedPreferences);
* 单元测试用内存假实现顶替,避免把 Android 的加密存储拖进 JVM 测试。
*/
interface SessionStorage {
fun load(): StoredSession
/** 登录成功:整份会话落盘。**不写密码**(B2 红线)。 */
fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
)
/**
* 只替换令牌三元组,保留 baseUrl / username / role。
* [refreshToken] 为 null 表示沿用原值——`AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端不下发新的,
* 此时旧凭据仍然有效,绝不能丢掉。
*/
fun updateTokens(token: String, expiresAt: Long, refreshToken: String?)
/**
* 确定性抹掉历史遗留的明文密码。
* 用 `commit()` 而不是 `apply()`:这是红线清除,必须在下一次读取之前就落到磁盘上。
*/
fun purgePassword()
/** 会话失效:清 token 与 refresh token,保留 username 以便登录页预填。 */
fun clearSession()
/** 主动登出:整份清空。 */
fun clearAll()
}
/** JWT 与登录凭据的加密存储(红线:不落明文密码、不打日志)。 */
class SessionStore(context: Context) : SessionStorage {
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
@@ -25,44 +69,58 @@ class SessionStore(context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
fun load(): StoredSession = StoredSession(
override fun load(): StoredSession = StoredSession(
baseUrl = prefs.getString(KEY_BASE_URL, "").orEmpty(),
token = prefs.getString(KEY_TOKEN, null),
expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L),
role = prefs.getString(KEY_ROLE, null),
username = prefs.getString(KEY_USERNAME, null),
refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null),
// 只读:迁移路径靠它判断「这是升级前的安装」,不再写入。
password = prefs.getString(KEY_PASSWORD, null),
)
fun saveLogin(
override fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
password: String,
refreshToken: String?,
) {
prefs.edit()
val editor = prefs.edit()
.putString(KEY_BASE_URL, baseUrl)
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
.putString(KEY_ROLE, role)
.putString(KEY_USERNAME, username)
.putString(KEY_PASSWORD, password)
.apply()
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
editor.apply()
}
/** 会话失效(401/过期):清 token,但保留凭据以便静默重登(D12)。 */
fun clearSession() {
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
val editor = prefs.edit()
.putString(KEY_TOKEN, token)
.putLong(KEY_EXPIRES_AT, expiresAt)
// 轮换关闭时服务端不下发新凭据,旧的那一份继续有效,必须保留。
if (!refreshToken.isNullOrBlank()) editor.putString(KEY_REFRESH_TOKEN, refreshToken)
editor.apply()
}
override fun purgePassword() {
prefs.edit().remove(KEY_PASSWORD).commit()
}
override fun clearSession() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_EXPIRES_AT)
.remove(KEY_ROLE)
.remove(KEY_REFRESH_TOKEN)
.apply()
}
/** 主动登出:凭据一并清掉,避免下次启动被静默登回去。 */
fun clearAll() {
override fun clearAll() {
prefs.edit().clear().apply()
}
@@ -73,6 +131,7 @@ class SessionStore(context: Context) {
const val KEY_EXPIRES_AT = "expires_at"
const val KEY_ROLE = "role"
const val KEY_USERNAME = "username"
const val KEY_REFRESH_TOKEN = "refresh_token"
const val KEY_PASSWORD = "password"
}
}
@@ -0,0 +1,132 @@
package top.yeij.cyrene.core.auth
/** 一次令牌续期的结果。 */
sealed interface AuthRecovery {
/** 已拿到可用的访问令牌:可能是刚签发的,也可能是别的线程刚换好的。 */
data class Renewed(val token: String) : AuthRecovery
/**
* 无法续期:本地没有 refresh token,或被服务端以 `invalid_grant` 拒绝
* (未知 / 已吊销 / 已过期 / 已被用掉,服务端把这四种情况折叠成一个码)。
* 这是终态——必须重新登录。
*/
data object Unrecoverable : AuthRecovery
/** 暂时性失败:网络不通、5xx、响应解不动。会话可能仍然有效,稍后再试。 */
data object Transient : AuthRecovery
}
/**
* [TokenRefresher] 注入的刷新调用结果。
* 把 HTTP 细节挡在协调逻辑之外,单飞与代际守卫因此可以纯 JVM 单测。
*/
sealed interface RefreshCall {
/** 200:新的访问令牌;[refreshToken] 为 null 表示服务端关闭了轮换、旧凭据继续有效。 */
data class Ok(val token: String, val expiresAt: Long, val refreshToken: String?) : RefreshCall
/** `401 invalid_grant`。 */
data object Rejected : RefreshCall
/** 网络 / 5xx / 解析失败。 */
data object Failed : RefreshCall
}
/**
* 单飞(single-flight)刷新协调器。
*
* ## 为什么必须单飞
* 服务端 `RefreshStore.Rotate` 是**原子且无宽限期**的:同一个 refresh token 被第二次使用会立刻
* 拿到 `invalid_grant`(防重放)。而本端并发 401 是常态——管理概览页两个请求并发、聊天页 REST 与
* WS 同时起步。所以同一时刻只允许一个线程真正发刷新请求,其余线程在锁上等待后复用结果。
*
* ## 代际守卫
* 拿到锁之后**重新读一次存储**:如果已存的访问令牌既不是自己手里那份([renew] 的 `staleToken`)、
* 又还没到过期点,说明等在锁上的这段时间里别的线程已经换好了令牌——直接复用,绝不刷第二次。
*
* ## 线程模型
* 全部逻辑跑在**调用方线程**上(OkHttp 的调用线程 / WS 重连协程),用 JVM 监视器锁串行化。
* 因此这里没有、也不能用协程 `Mutex`——调用点根本没有 suspend 上下文。
* 真实的刷新请求由调用方用**不带令牌**的客户端发出(见 `AuthManager.refreshCall`),
* 不会递归回本类。
*
* @param store 会话读写;实现必须让写入立刻对下一次 [SessionStorage.load] 可见。
* @param call 真正发刷新请求的阻塞调用。
* @param now 当前毫秒时间戳,可注入以便单测。
*/
class TokenRefresher(
private val store: SessionStorage,
private val call: (refreshToken: String) -> RefreshCall,
private val now: () -> Long = System::currentTimeMillis,
) {
private val lock = Any()
/**
* 已经确定无效的那个 refresh token。`invalid_grant` 对该值而言是**永久**结论,
* 记下来可以让同一次失效里的其它并发 401 直接得到终态,不必各打一次注定失败的请求。
* 值本身随机且每次轮换都变,因此不需要额外失效逻辑。
*/
private var rejectedRefreshToken: String? = null
/** 距过期不足 [SKEW_MS] 就算「该提前换令牌了」。 */
fun isNearExpiry(session: StoredSession): Boolean =
session.expiresAt * 1000 - now() <= SKEW_MS
/**
* 阻塞地给出一个当下可用的访问令牌;临近过期时先走一次单飞刷新。
* 供 OkHttp 的 tokenProvider 与 WS 握手使用(每次重连都会重新读)。
*
* 刷新失败时**仍然返回手里那份令牌**——它可能还没真的过期,401 交给兜底路径处理;
* 在这里返回 null 会让整条请求链断掉,比让服务端来裁决更糟。
*/
fun ensureToken(): String? {
val session = store.load()
val token = session.token?.takeIf(String::isNotBlank) ?: return null
if (!isNearExpiry(session)) return token
return when (val recovery = renew(token)) {
is AuthRecovery.Renewed -> recovery.token
AuthRecovery.Unrecoverable, AuthRecovery.Transient -> token
}
}
/**
* 单飞刷新:同一时刻只有一个线程真正发请求,其余线程在锁上等待并复用结果。
*
* @param staleToken 触发续期的那次请求实际携带的令牌;null 表示只知道「手上没有可用的令牌」。
*/
fun renew(staleToken: String?): AuthRecovery = synchronized(lock) {
val session = store.load()
// 代际守卫:等在锁上的这段时间里,别的线程已经换好了令牌 → 复用,绝不用同一个 refresh token 打第二次。
val current = session.token
if (!current.isNullOrBlank() && current != staleToken && !isNearExpiry(session)) {
return@synchronized AuthRecovery.Renewed(current)
}
val refreshToken = session.refreshToken?.takeIf(String::isNotBlank)
?: return@synchronized AuthRecovery.Unrecoverable
if (refreshToken == rejectedRefreshToken) return@synchronized AuthRecovery.Unrecoverable
when (val result = call(refreshToken)) {
is RefreshCall.Ok -> {
rejectedRefreshToken = null
store.updateTokens(result.token, result.expiresAt, result.refreshToken)
AuthRecovery.Renewed(result.token)
}
RefreshCall.Rejected -> {
rejectedRefreshToken = refreshToken
AuthRecovery.Unrecoverable
}
RefreshCall.Failed -> AuthRecovery.Transient
}
}
private companion object {
/** 提前量:24h 的访问令牌留 60s 余量,避免踩着过期边界发请求。 */
const val SKEW_MS = 60_000L
}
}
@@ -0,0 +1,29 @@
package top.yeij.cyrene.core.auth
/**
* 「带了令牌仍然 401」的处置策略。
*
* 由 `ApiClientFactory` 的拦截器在 **OkHttp 的调用线程上同步调用**,所以实现必须是
* 阻塞且线程安全的——那里没有 suspend 上下文,协程 `Mutex` 帮不上忙。
*/
interface UnauthorizedPolicy {
/**
* 尝试把被服务端拒绝的令牌换成可用的新令牌。
*
* @param usedToken 触发这次 401 的请求实际携带的访问令牌。
* @return [AuthRecovery.Renewed] 时调用方会用新令牌重试一次;另外两种都不重试。
*/
fun renew(usedToken: String): AuthRecovery
/** 终态:续期不可能(没有 refresh token)或明确被拒(`invalid_grant`)——必须重新登录。 */
fun onSessionEnded()
}
/** 不带令牌的客户端(登录 / 刷新 / 登出)用:永不续期,也永不判终态。 */
object NoUnauthorizedPolicy : UnauthorizedPolicy {
override fun renew(usedToken: String): AuthRecovery = AuthRecovery.Unrecoverable
override fun onSessionEnded() = Unit
}
@@ -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
@@ -0,0 +1,406 @@
package top.yeij.cyrene.core.auth
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiEnvelope
import top.yeij.cyrene.data.api.CyreneApi
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 java.io.IOException
import java.util.concurrent.CopyOnWriteArrayList
/**
* B2 迁移与登出的行为钉死。
*
* 迁移的目标(brief §9):**升级对用户不可见**——不强制重登、不丢会话,
* 但首次成功认证之后任何安装都不再留着明文密码。
*
* 全部走内存假存储 + 假 [CyreneApi],所以既不用网络,也不用新增依赖。
* 夹具里的用户名/密码一律是中性值,真实凭据不进仓库。
*/
class AuthManagerBootstrapTest {
private val baseUrl = "http://127.0.0.1:10942/"
private val username = "testuser"
private val legacyPassword = "legacy-secret"
private val nowSeconds = System.currentTimeMillis() / 1000
private val newToken = "jwt-new"
private val newRefreshToken = "refresh-new"
// —— 迁移 ——
@Test
fun `legacy password is exchanged for a refresh token and then wiped`() = runBlocking {
val store = FakeSessionStorage(
session(token = null, expiresAt = 0, refreshToken = null, password = legacyPassword),
)
val api = FakeCyreneApi(
loginResult = {
Response.success(
ApiEnvelope(
data = LoginResponse(
token = newToken,
expiresAt = nowSeconds + 86_400,
role = "owner",
refreshToken = newRefreshToken,
),
),
)
},
)
val manager = AuthManager(store) { api }
manager.bootstrap()
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
assertEquals(1, api.loginRequests.size)
assertEquals(username, api.loginRequests.single().username)
assertEquals(legacyPassword, api.loginRequests.single().password)
assertEquals(newToken, store.load().token)
assertEquals(newRefreshToken, store.load().refreshToken)
assertNull("迁移成功后必须立刻抹掉明文密码", store.load().password)
assertEquals("username 必须留着(登录页与缓存指纹都靠它)", username, store.load().username)
}
@Test
fun `legacy login failure keeps the legacy values for the next launch`() = runBlocking {
val store = FakeSessionStorage(
session(token = null, expiresAt = 0, refreshToken = null, password = legacyPassword),
)
val api = FakeCyreneApi(loginResult = { throw IOException("offline") })
val manager = AuthManager(store) { api }
manager.bootstrap()
assertEquals(SessionState.LoggedOut, manager.state.value)
// 没网也不能吞掉凭据:下次启动还要再试一次。
assertEquals(legacyPassword, store.load().password)
assertEquals(username, store.load().username)
assertNull(store.load().refreshToken)
}
@Test
fun `a still-valid access token survives a failed legacy migration without logging out`() = runBlocking {
val store = FakeSessionStorage(
session(
token = "jwt-still-valid",
expiresAt = nowSeconds + 3_600,
refreshToken = null,
password = legacyPassword,
),
)
val api = FakeCyreneApi(loginResult = { throw IOException("offline") })
val manager = AuthManager(store) { api }
manager.bootstrap()
// 关键:手里还有有效令牌时,迁移失败**绝不能让用户掉到登录页**。
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
assertEquals("jwt-still-valid", store.load().token)
assertEquals(legacyPassword, store.load().password)
}
@Test
fun `a leftover password is wiped when a refresh token is already present`() = runBlocking {
val store = FakeSessionStorage(
session(
token = "jwt-valid",
expiresAt = nowSeconds + 3_600,
refreshToken = "refresh-existing",
password = legacyPassword,
),
)
val api = FakeCyreneApi()
val manager = AuthManager(store) { api }
manager.bootstrap()
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
assertEquals(0, api.loginRequests.size)
assertNull(store.load().password)
assertEquals("refresh-existing", store.load().refreshToken)
}
@Test
fun `nothing stored at all lands on the login screen`() = runBlocking {
val store = FakeSessionStorage(StoredSession(baseUrl, null, 0, null, null, null, null))
val manager = AuthManager(store) { FakeCyreneApi() }
manager.bootstrap()
assertEquals(SessionState.LoggedOut, manager.state.value)
}
// —— 令牌过期后的静默恢复 ——
@Test
fun `an expired access token is renewed with the refresh token at bootstrap`() = runBlocking {
val store = FakeSessionStorage(
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-existing"),
)
val api = FakeCyreneApi(
refreshResult = {
Response.success(
ApiEnvelope(
data = RefreshResponse(
token = newToken,
expiresAt = nowSeconds + 86_400,
role = "owner",
refreshToken = newRefreshToken,
),
),
)
},
)
val manager = AuthManager(store) { api }
manager.bootstrap()
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
assertEquals(1, api.refreshRequests.size)
assertEquals("refresh-existing", api.refreshRequests.single().refreshToken)
assertEquals(newToken, store.load().token)
assertEquals(newRefreshToken, store.load().refreshToken)
}
@Test
fun `an expired token with a rejected refresh token forces a re-login`() = runBlocking {
val store = FakeSessionStorage(
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-dead"),
)
val api = FakeCyreneApi(
refreshResult = {
Response.error(401, """{"error":{"code":"invalid_grant"}}""".asErrorBody())
},
)
val manager = AuthManager(store) { api }
manager.bootstrap()
assertEquals(SessionState.LoggedOut, manager.state.value)
assertEquals(1, api.refreshRequests.size)
}
// —— 登出 ——
@Test
fun `logout revokes the server-side grant before clearing locally`() = runBlocking {
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"))
val api = FakeCyreneApi()
val manager = AuthManager(store) { api }
manager.logout()
assertEquals(1, api.logoutRequests.size)
assertEquals("refresh-1", api.logoutRequests.single().refreshToken)
assertEquals(SessionState.LoggedOut, manager.state.value)
assertNull(store.load().token)
assertNull(store.load().refreshToken)
}
@Test
fun `an offline logout still clears everything locally`() = runBlocking {
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"))
val api = FakeCyreneApi(logoutResult = { throw IOException("offline") })
val manager = AuthManager(store) { api }
manager.logout()
assertEquals(SessionState.LoggedOut, manager.state.value)
assertNull(store.load().refreshToken)
assertNull(store.load().token)
}
@Test
fun `logout without a refresh token does not even call the server`() = runBlocking {
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = null))
val api = FakeCyreneApi()
val manager = AuthManager(store) { api }
manager.logout()
assertEquals(0, api.logoutRequests.size)
assertEquals(SessionState.LoggedOut, manager.state.value)
}
// —— 终态 ——
@Test
fun `terminal session loss clears the tokens but keeps the username`() {
val store = FakeSessionStorage(
session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"),
)
val manager = AuthManager(store) { FakeCyreneApi() }
manager.onSessionExpired()
assertEquals(SessionState.LoggedOut, manager.state.value)
assertNull(store.load().token)
assertNull(store.load().refreshToken)
assertEquals(username, store.load().username)
}
@Test
fun `a transient refresh failure never ends the session`() = runBlocking {
val store = FakeSessionStorage(
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
)
val api = FakeCyreneApi(refreshResult = { throw IOException("offline") })
val manager = AuthManager(store) { api }
// 拦截器只在终态才回调 onSessionEnded,所以这里只验证策略本身把两种失败分开了。
assertEquals(AuthRecovery.Transient, manager.unauthorizedPolicy.renew("jwt-expired"))
assertEquals("refresh-1", store.load().refreshToken)
}
@Test
fun `the unauthorized policy reports terminal loss only for a rejected credential`() {
val terminalStore = FakeSessionStorage(
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
)
val terminalApi = FakeCyreneApi(
refreshResult = { Response.error(401, """{"error":{"code":"invalid_grant"}}""".asErrorBody()) },
)
val terminalManager = AuthManager(terminalStore) { terminalApi }
assertEquals(AuthRecovery.Unrecoverable, terminalManager.unauthorizedPolicy.renew("jwt-expired"))
// 没有 refresh token 可用 → 同样是终态。
val noCredentialStore = FakeSessionStorage(
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = null),
)
val noCredentialManager = AuthManager(noCredentialStore) { FakeCyreneApi() }
assertEquals(AuthRecovery.Unrecoverable, noCredentialManager.unauthorizedPolicy.renew("jwt-expired"))
// 续期成功 → 重试用的新令牌就是刷新回来的那一个。
val okStore = FakeSessionStorage(
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
)
val okApi = FakeCyreneApi(
refreshResult = {
Response.success(ApiEnvelope(data = RefreshResponse(newToken, nowSeconds + 86_400, "owner", null)))
},
)
val okManager = AuthManager(okStore) { okApi }
val recovered = okManager.unauthorizedPolicy.renew("jwt-expired")
assertTrue(recovered is AuthRecovery.Renewed)
assertEquals(newToken, (recovered as AuthRecovery.Renewed).token)
}
// —— 夹具 ——
private fun session(
token: String?,
expiresAt: Long,
refreshToken: String?,
password: String? = null,
) = StoredSession(
baseUrl = baseUrl,
token = token,
expiresAt = expiresAt,
role = "owner",
username = username,
refreshToken = refreshToken,
password = password,
)
private fun String.asErrorBody(): ResponseBody = toResponseBody("application/json".toMediaType())
private class FakeSessionStorage(initial: StoredSession) : SessionStorage {
@Volatile
private var session: StoredSession = initial
override fun load(): StoredSession = session
override fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
) {
session = session.copy(
baseUrl = baseUrl,
token = token,
expiresAt = expiresAt,
role = role,
username = username,
refreshToken = refreshToken,
)
}
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
session = session.copy(
token = token,
expiresAt = expiresAt,
refreshToken = refreshToken ?: session.refreshToken,
)
}
override fun purgePassword() {
session = session.copy(password = null)
}
override fun clearSession() {
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
}
override fun clearAll() {
session = StoredSession("", null, 0, null, null, null, null)
}
}
/** 假的认证接口:三个端点各自可替换返回,并记录收到的请求体。 */
private class FakeCyreneApi(
private val loginResult: (LoginRequest) -> Response<ApiEnvelope<LoginResponse>> = {
Response.success(
ApiEnvelope(
data = LoginResponse("jwt-default", System.currentTimeMillis() / 1000 + 86_400, "owner", null),
),
)
},
private val refreshResult: (RefreshRequest) -> Response<ApiEnvelope<RefreshResponse>> = {
Response.success(
ApiEnvelope(
data = RefreshResponse("jwt-refreshed", System.currentTimeMillis() / 1000 + 86_400, "owner", null),
),
)
},
private val logoutResult: (LogoutRequest) -> Response<ApiEnvelope<LogoutResponse>> = {
Response.success(ApiEnvelope(data = LogoutResponse(revoked = true)))
},
) : CyreneApi {
val loginRequests = CopyOnWriteArrayList<LoginRequest>()
val refreshRequests = CopyOnWriteArrayList<RefreshRequest>()
val logoutRequests = CopyOnWriteArrayList<LogoutRequest>()
override suspend fun login(body: LoginRequest): Response<ApiEnvelope<LoginResponse>> {
loginRequests += body
return loginResult(body)
}
override suspend fun refresh(body: RefreshRequest): Response<ApiEnvelope<RefreshResponse>> {
refreshRequests += body
return refreshResult(body)
}
override suspend fun logout(body: LogoutRequest): Response<ApiEnvelope<LogoutResponse>> {
logoutRequests += body
return logoutResult(body)
}
}
}
@@ -0,0 +1,290 @@
package top.yeij.cyrene.core.auth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
/**
* 单飞刷新与代际守卫的行为钉死(B2 §4/§5)。
*
* 服务端的轮换是**原子且无宽限期**的:同一个 refresh token 被第二次使用立刻 `invalid_grant`,
* 所以「并发 401 只刷一次」不是优化而是正确性要求——这些用例就是它的证据。
*/
class TokenRefresherTest {
private val nowMs = 1_791_000_000_000L
private val nowSeconds = nowMs / 1000
private val staleToken = "jwt-stale"
private val freshToken = "jwt-fresh"
private val staleRefreshToken = "refresh-stale"
private val rotatedRefreshToken = "refresh-rotated"
// —— 用例 ——
@Test
fun `concurrent callers sharing one stale token perform exactly one refresh`() = runBlocking {
val calls = AtomicInteger()
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
val refresher = TokenRefresher(
store = store,
call = { _ ->
calls.incrementAndGet()
// 制造重叠窗口:其余调用方要么正在锁上等,要么已经排在门口。
Thread.sleep(50)
RefreshCall.Ok(freshToken, nowSeconds + 86_400, rotatedRefreshToken)
},
now = { nowMs },
)
val results = (1..8)
.map { async(Dispatchers.IO) { refresher.renew(staleToken) } }
.awaitAll()
assertEquals("8 个并发续期只能真的打一次刷新", 1, calls.get())
results.forEach { assertEquals(AuthRecovery.Renewed(freshToken), it) }
assertEquals(freshToken, store.load().token)
assertEquals(rotatedRefreshToken, store.load().refreshToken)
}
@Test
fun `generation guard reuses a token another thread already refreshed`() {
val calls = AtomicInteger()
// 别人已经换好了:手里的令牌与 staleToken 不同、且没过期。
val store = FakeSessionStorage(session(token = freshToken, expiresAt = nowSeconds + 86_400))
val refresher = TokenRefresher(
store = store,
call = { calls.incrementAndGet(); RefreshCall.Ok("unused", nowSeconds, null) },
now = { nowMs },
)
val recovery = refresher.renew(staleToken)
assertEquals(AuthRecovery.Renewed(freshToken), recovery)
assertEquals("代际守卫生效时不该发刷新请求", 0, calls.get())
}
@Test
fun `an expired token equal to the stale one is still refreshed`() {
val calls = AtomicInteger()
// 值相同但已经过期 → 守卫不能把它当成「别人刷好的令牌」放行。
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 60))
val refresher = TokenRefresher(
store = store,
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
now = { nowMs },
)
assertEquals(AuthRecovery.Renewed(freshToken), refresher.renew(staleToken))
assertEquals(1, calls.get())
}
@Test
fun `invalid_grant is terminal and is not retried for the same credential`() {
val calls = AtomicInteger()
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
val refresher = TokenRefresher(
store = store,
call = { calls.incrementAndGet(); RefreshCall.Rejected },
now = { nowMs },
)
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
// 同一个凭据已经确定无效:并发的第二个 401 不该再打一次必然失败的请求。
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
assertEquals(1, calls.get())
assertEquals("终态不能改动已存令牌", staleToken, store.load().token)
}
@Test
fun `transient failures stay distinguishable from terminal ones and remain retryable`() {
val calls = AtomicInteger()
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
val refresher = TokenRefresher(
store = store,
call = { calls.incrementAndGet(); RefreshCall.Failed },
now = { nowMs },
)
assertEquals(AuthRecovery.Transient, refresher.renew(staleToken))
assertEquals(AuthRecovery.Transient, refresher.renew(staleToken))
// 网络 / 5xx 不是终态:必须留着下次再试,所以这里**不**记忆失败。
assertEquals(2, calls.get())
}
@Test
fun `a missing refresh token is terminal without any network call`() {
val calls = AtomicInteger()
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1, refresh = null))
val refresher = TokenRefresher(
store = store,
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds, null) },
now = { nowMs },
)
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
assertEquals(0, calls.get())
}
@Test
fun `a rotation-disabled response passes a null refresh token straight through`() {
val calls = AtomicInteger()
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
val refresher = TokenRefresher(
store = store,
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
now = { nowMs },
)
assertEquals(AuthRecovery.Renewed(freshToken), refresher.renew(staleToken))
// 服务端不下发新凭据 → 传 null 给存储层,由它保留旧值(AUTH_REFRESH_ROTATE_DISABLED)。
assertEquals(listOf<String?>(null), store.updateRefreshTokenArguments)
assertEquals(staleRefreshToken, store.load().refreshToken)
}
@Test
fun `proactive path refreshes only when the token is inside the skew`() {
val calls = AtomicInteger()
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds + 300))
val refresher = TokenRefresher(
store = store,
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
now = { nowMs },
)
// 还有 5 分钟:余量(60s)之外,直接交出去,不打网络。
assertEquals(staleToken, refresher.ensureToken())
assertEquals(0, calls.get())
// 只剩 30 秒:进到余量里,先换令牌。
store.setToken(staleToken, nowSeconds + 30)
assertEquals(freshToken, refresher.ensureToken())
assertEquals(1, calls.get())
}
@Test
fun `proactive path falls back to the stored token when refreshing fails`() {
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds + 10))
val refresher = TokenRefresher(
store = store,
call = { RefreshCall.Failed },
now = { nowMs },
)
// 返回 null 会让整条请求链断掉;那一刻令牌可能还没真过期,交给服务端裁决更好。
assertEquals(staleToken, refresher.ensureToken())
}
@Test
fun `no stored token means nothing to hand out`() {
val refresher = TokenRefresher(
store = FakeSessionStorage(session(token = null, expiresAt = 0)),
call = { RefreshCall.Ok(freshToken, nowSeconds, null) },
now = { nowMs },
)
assertNull(refresher.ensureToken())
}
@Test
fun `a fresh rotation also clears the terminal memo`() {
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
val calls = AtomicInteger()
val refresher = TokenRefresher(
store = store,
call = { calls.incrementAndGet(); RefreshCall.Rejected },
now = { nowMs },
)
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
assertEquals(1, calls.get())
// 换一份凭据(重新登录)之后,同一条路径必须重新可以尝试。
store.setRefreshToken("refresh-after-login")
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
assertEquals(2, calls.get())
}
// —— 夹具 ——
private fun session(
token: String?,
expiresAt: Long,
refresh: String? = staleRefreshToken,
) = StoredSession(
baseUrl = "http://127.0.0.1:10942",
token = token,
expiresAt = expiresAt,
role = "owner",
username = "testuser",
refreshToken = refresh,
password = null,
)
/** 内存假实现:写入必须立刻对 [load] 可见,否则代际守卫测不出来。 */
private class FakeSessionStorage(initial: StoredSession) : SessionStorage {
@Volatile
private var session: StoredSession = initial
/** `updateTokens` 收到的 refreshToken 实参,按调用顺序记录(含 null)。 */
val updateRefreshTokenArguments = CopyOnWriteArrayList<String?>()
override fun load(): StoredSession = session
override fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
) {
session = session.copy(
baseUrl = baseUrl,
token = token,
expiresAt = expiresAt,
role = role,
username = username,
refreshToken = refreshToken,
password = null,
)
}
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
updateRefreshTokenArguments += refreshToken
session = session.copy(
token = token,
expiresAt = expiresAt,
// 与生产实现同义:null 表示沿用原值。
refreshToken = refreshToken ?: session.refreshToken,
)
}
override fun purgePassword() {
session = session.copy(password = null)
}
override fun clearSession() {
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
}
override fun clearAll() {
session = StoredSession("", null, 0, null, null, null, null)
}
fun setToken(token: String, expiresAt: Long) {
session = session.copy(token = token, expiresAt = expiresAt)
}
fun setRefreshToken(refreshToken: String?) {
session = session.copy(refreshToken = refreshToken)
}
}
}
@@ -0,0 +1,286 @@
package top.yeij.cyrene.core.auth
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import org.junit.After
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.SessionsApi
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
/**
* 端到端(真 OkHttp + 真 Retrofit + 真 HTTP 服务端)验证 B2 §4/§5/§6。
*
* 服务端用 JDK 自带的 `com.sun.net.httpserver`,**不引入任何新依赖**
* (没有 MockWebServer)。这里是唯一能证明「拦截器真的会重试一次、且只重试一次」
* 的地方——单测 [TokenRefresher] 只覆盖协调逻辑,覆盖不到它在 OkHttp 里的接线。
*
* 关键事实(服务端 `RefreshStore.Rotate` 原子且无宽限期):并发 401 里只要有第二个线程
* 拿旧 refresh token 去刷,就会立刻 `invalid_grant`。所以「刷了几次」就是正确性判据。
*/
class UnauthorizedRetryEndToEndTest {
private val staleToken = "jwt-stale"
private val freshToken = "jwt-fresh"
private val refreshToken = "refresh-1"
private val backend = FakeBackend()
@After
fun tearDown() {
backend.close()
}
@Test
fun `two concurrent 401s cause exactly one refresh and both requests succeed`() = runBlocking {
val store = storeWith(staleToken, expiresInSeconds = 3_600)
val api = authedApi(store)
backend.accepts = setOf(freshToken)
// 扣住前两个请求不放,直到两个都已到达:这样「两个调用方都读到了旧令牌」是确定的,
// 而不是靠调度碰运气。
backend.gateConcurrentRequests = true
val responses = listOf(
async(Dispatchers.IO) { api.listSessions() },
async(Dispatchers.IO) { api.listSessions() },
).awaitAll()
assertTrue("两个请求都应该靠续期后的令牌成功:$responses", responses.all { it.isSuccessful })
assertEquals("两个并发调用方都必须先撞上 401", 2, backend.unauthorizedResponses.get())
assertEquals("并发 401 只能真的打一次刷新", 1, backend.refreshBodies.size)
assertEquals(refreshToken, backend.refreshedWith.single())
assertEquals(freshToken, store.load().token)
assertEquals("刷新后应换到新的 refresh token", "refresh-2", store.load().refreshToken)
}
@Test
fun `a near-expiry token is refreshed proactively so the first attempt already carries the new one`() =
runBlocking {
// 只剩 10 秒:进到 60s 余量里,tokenProvider 必须在发请求之前先换令牌。
val store = storeWith(staleToken, expiresInSeconds = 10)
val api = authedApi(store)
backend.accepts = setOf(freshToken)
val response = api.listSessions()
assertTrue(response.isSuccessful)
assertEquals(1, backend.refreshBodies.size)
assertEquals("主动续期之后第一次请求就该带新令牌", 1, backend.sessionsRequests.size)
assertEquals(0, backend.unauthorizedResponses.get())
}
@Test
fun `an invalid_grant on refresh is terminal and ends the session`() = runBlocking {
val store = storeWith(staleToken, expiresInSeconds = 3_600)
val api = authedApi(store)
backend.accepts = emptySet()
backend.refreshStatus = 401
backend.refreshBody = """{"error":{"code":"invalid_grant","message":"invalid refresh token"}}"""
val response = api.listSessions()
assertEquals(401, response.code())
assertEquals(1, backend.refreshBodies.size)
assertEquals(SessionState.LoggedOut, manager.state.value)
assertNull("终态必须清掉本地令牌", store.load().token)
assertNull(store.load().refreshToken)
}
@Test
fun `a transient refresh failure keeps the session alive`() = runBlocking {
val store = storeWith(staleToken, expiresInSeconds = 3_600)
val api = authedApi(store)
backend.accepts = emptySet()
backend.refreshStatus = 500
backend.refreshBody = """{"error":{"code":"refresh_failed","message":"could not refresh token"}}"""
val response = api.listSessions()
assertEquals(401, response.code())
// 网络 / 5xx 不是终态:会话必须留着,下次请求还能再试。
assertEquals(SessionState.Unknown, manager.state.value)
assertEquals(staleToken, store.load().token)
assertEquals(refreshToken, store.load().refreshToken)
}
@Test
fun `a token that keeps being rejected is retried at most once and never storms`() = runBlocking {
val store = storeWith(staleToken, expiresInSeconds = 3_600)
val api = authedApi(store)
// 服务端谁都不要:刷新成功,但新令牌照样 401。
backend.accepts = emptySet()
val response = api.listSessions()
assertEquals(401, response.code())
// 只刷一次;被拒的请求只重试一次。没有刷新风暴,也没有无限重试。
assertEquals(1, backend.refreshBodies.size)
assertEquals(2, backend.sessionsRequests.size)
assertEquals(SessionState.LoggedOut, manager.state.value)
}
// —— 夹具 ——
private lateinit var manager: AuthManager
/** 真实接线:拦截器用的 tokenProvider / policy 都来自真正的 [AuthManager]。 */
private fun authedApi(store: SessionStorage): SessionsApi {
manager = AuthManager(store) { ApiClientFactory.create(baseUrl = it) }
return ApiClientFactory.create(
baseUrl = backend.baseUrl,
service = SessionsApi::class.java,
tokenProvider = manager::currentToken,
unauthorizedPolicy = manager.unauthorizedPolicy,
)
}
private fun storeWith(token: String, expiresInSeconds: Long) = InMemorySessionStore(
StoredSession(
baseUrl = backend.baseUrl,
token = token,
expiresAt = System.currentTimeMillis() / 1000 + expiresInSeconds,
role = "owner",
username = "testuser",
refreshToken = refreshToken,
password = null,
),
)
private class InMemorySessionStore(initial: StoredSession) : SessionStorage {
@Volatile
private var session: StoredSession = initial
override fun load(): StoredSession = session
override fun saveLogin(
baseUrl: String,
token: String,
expiresAt: Long,
role: String,
username: String,
refreshToken: String?,
) {
session = session.copy(
baseUrl = baseUrl,
token = token,
expiresAt = expiresAt,
role = role,
username = username,
refreshToken = refreshToken,
)
}
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
session = session.copy(
token = token,
expiresAt = expiresAt,
refreshToken = refreshToken ?: session.refreshToken,
)
}
override fun purgePassword() {
session = session.copy(password = null)
}
override fun clearSession() {
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
}
override fun clearAll() {
session = StoredSession("", null, 0, null, null, null, null)
}
}
/**
* 极简假服务端:`/api/v1/sessions` 只认 [accepts] 里的访问令牌,
* `/api/v1/auth/refresh` 按 [refreshStatus] / [refreshBody] 回。
*/
private inner class FakeBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(4)
createContext("/api/v1/sessions", ::handleSessions)
createContext("/api/v1/auth/refresh", ::handleRefresh)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
@Volatile
var accepts: Set<String> = emptySet()
@Volatile
var refreshStatus: Int = 200
@Volatile
var refreshBody: String =
"""{"data":{"token":"$freshToken","expiresAt":${System.currentTimeMillis() / 1000 + 86_400},""" +
""""role":"owner","refreshToken":"refresh-2"}}"""
val sessionsRequests = CopyOnWriteArrayList<String>()
val refreshBodies = CopyOnWriteArrayList<String>()
/** 打开后,未授权的请求会先等到「两个都到齐」再回 401,用来钉住真实的并发重叠。 */
@Volatile
var gateConcurrentRequests: Boolean = false
private val arrived = CountDownLatch(2)
/** 必须是原子的:两个请求会被 gate 扣住并发地写它。 */
val unauthorizedResponses = AtomicInteger()
/** 送到 `/auth/refresh` 的 refreshToken 值(去引号后的原样)。 */
val refreshedWith: List<String>
get() = refreshBodies.map { it.substringAfter("\"refreshToken\":\"").substringBefore('"') }
private fun handleSessions(exchange: HttpExchange) {
val authorization = exchange.requestHeaders.getFirst("Authorization").orEmpty()
val token = authorization.removePrefix("Bearer ").takeIf { authorization.startsWith("Bearer ") }
sessionsRequests += authorization
if (token != null && token in accepts) {
respond(exchange, 200, """{"data":[]}""")
} else {
if (gateConcurrentRequests) {
arrived.countDown()
arrived.await(GATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
}
unauthorizedResponses.incrementAndGet()
respond(exchange, 401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
}
}
private fun handleRefresh(exchange: HttpExchange) {
refreshBodies += exchange.requestBody.readBytes().decodeToString()
respond(exchange, refreshStatus, refreshBody)
}
private fun respond(exchange: HttpExchange, status: Int, body: String) {
val bytes = body.toByteArray()
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
override fun close() {
server.stop(0)
}
}
private companion object {
const val GATE_TIMEOUT_SECONDS = 5L
}
}
@@ -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)
}
}
}