diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 526e5aa..67c0ab0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -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, ) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt index 8997a26..1ce2238 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneApp.kt @@ -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() } }, ) } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/auth/AuthManager.kt b/app/src/main/kotlin/top/yeij/cyrene/core/auth/AuthManager.kt index de7253e..9a2d2e2 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/auth/AuthManager.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/auth/AuthManager.kt @@ -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.Unknown) val state: StateFlow = _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 + } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/auth/SessionStore.kt b/app/src/main/kotlin/top/yeij/cyrene/core/auth/SessionStore.kt index c8b56c4..25ce2ea 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/core/auth/SessionStore.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/core/auth/SessionStore.kt @@ -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" } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/auth/TokenRefresher.kt b/app/src/main/kotlin/top/yeij/cyrene/core/auth/TokenRefresher.kt new file mode 100644 index 0000000..33a1bd4 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/auth/TokenRefresher.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/auth/UnauthorizedPolicy.kt b/app/src/main/kotlin/top/yeij/cyrene/core/auth/UnauthorizedPolicy.kt new file mode 100644 index 0000000..db702de --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/auth/UnauthorizedPolicy.kt @@ -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 +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt index 3ade1d8..3852f41 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt @@ -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 create( baseUrl: String, service: Class, 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 } diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/CyreneApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/CyreneApi.kt index 31923f0..1b373e0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/CyreneApi.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/CyreneApi.kt @@ -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> + + @POST("api/v1/auth/refresh") + suspend fun refresh(@Body body: RefreshRequest): Response> + + @POST("api/v1/auth/logout") + suspend fun logout(@Body body: LogoutRequest): Response> } diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/LoginDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/LoginDto.kt index de40326..c1f9594 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/LoginDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/LoginDto.kt @@ -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, ) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt index 6e24ef2..e86c1e2 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ChatSocket.kt @@ -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 diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/auth/AuthManagerBootstrapTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/auth/AuthManagerBootstrapTest.kt new file mode 100644 index 0000000..e12b120 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/auth/AuthManagerBootstrapTest.kt @@ -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> = { + Response.success( + ApiEnvelope( + data = LoginResponse("jwt-default", System.currentTimeMillis() / 1000 + 86_400, "owner", null), + ), + ) + }, + private val refreshResult: (RefreshRequest) -> Response> = { + Response.success( + ApiEnvelope( + data = RefreshResponse("jwt-refreshed", System.currentTimeMillis() / 1000 + 86_400, "owner", null), + ), + ) + }, + private val logoutResult: (LogoutRequest) -> Response> = { + Response.success(ApiEnvelope(data = LogoutResponse(revoked = true))) + }, + ) : CyreneApi { + + val loginRequests = CopyOnWriteArrayList() + val refreshRequests = CopyOnWriteArrayList() + val logoutRequests = CopyOnWriteArrayList() + + override suspend fun login(body: LoginRequest): Response> { + loginRequests += body + return loginResult(body) + } + + override suspend fun refresh(body: RefreshRequest): Response> { + refreshRequests += body + return refreshResult(body) + } + + override suspend fun logout(body: LogoutRequest): Response> { + logoutRequests += body + return logoutResult(body) + } + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/auth/TokenRefresherTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/auth/TokenRefresherTest.kt new file mode 100644 index 0000000..47bd9e4 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/auth/TokenRefresherTest.kt @@ -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(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() + + 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) + } + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/auth/UnauthorizedRetryEndToEndTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/auth/UnauthorizedRetryEndToEndTest.kt new file mode 100644 index 0000000..2bfb199 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/auth/UnauthorizedRetryEndToEndTest.kt @@ -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 = 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() + val refreshBodies = CopyOnWriteArrayList() + + /** 打开后,未授权的请求会先等到「两个都到齐」再回 401,用来钉住真实的并发重叠。 */ + @Volatile + var gateConcurrentRequests: Boolean = false + + private val arrived = CountDownLatch(2) + + /** 必须是原子的:两个请求会被 gate 扣住并发地写它。 */ + val unauthorizedResponses = AtomicInteger() + + /** 送到 `/auth/refresh` 的 refreshToken 值(去引号后的原样)。 */ + val refreshedWith: List + 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 + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AuthDtoRealPayloadTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AuthDtoRealPayloadTest.kt new file mode 100644 index 0000000..0cfd107 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AuthDtoRealPayloadTest.kt @@ -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) + } + } +}