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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user