feat(auth): 接入 /auth/refresh,不再把登录口令落盘

客户端此前把明文口令存进 EncryptedSharedPreferences,用于过期后的静默重登
(当时后端没有 refresh 端点)。后端现在有 /auth/refresh + /auth/logout 了,
本轮改为存 refresh token,口令在首次成功认证后即被抹掉。

关键约束:服务端的 refresh 是**单次使用 + 原子旋转 + 无宽限期**——同一个 token
第二次使用立刻 invalid_grant;而本端并发 401 是常态(管理概览页两个请求并发、
聊天页 REST 与 WS 同时起步)。因此:

- core/auth/TokenRefresher.kt:单飞刷新 + 代际守卫(等锁期间别人已换好令牌就直接
  复用,绝不用同一个 refresh token 打第二次)+ 终态记忆(被 invalid_grant 拒过的
  token 记下来,一次失效里只打一次注定失败的请求;Transient 不记忆以保留重试)
- 用应用层 Interceptor 而非 OkHttp Authenticator:后者对**任何** 401 都会被调用
  (含不带令牌的登录请求),且由 RetryAndFollowUp 内部循环驱动(上限 20 次跟进),
  持续 401 的令牌会打出刷新风暴。应用层拦截器只在「带了令牌仍 401」时介入,且严格
  重试一次
- 可恢复的 401 不再登出:Transient(网络/5xx)原样交回 401 并保留会话;只有
  Unrecoverable,或「换上刚签发的令牌仍 401」,才判终态
- WS 握手 401:先尝试一次续期再重连,终态才登出
- 登出补调 POST /auth/logout(此前从不调用,服务端 grant 会白活 30 天)
- 主动 skew(60s)刷新 + 兜底重试;刷新请求走不带令牌的客户端,不会递归回本路径

迁移对用户无感、绝不强制重登:启动时若「无 refresh token 但有旧口令」,用旧路径
静默登录一次换到 refresh token,随即 commit() 抹掉口令;离线失败就下次启动再试,
且不清会话。另外鉴权过期时也走一次续期——否则去掉口令会把会话砍到 24h,比改动前
更差。

HttpLoggingInterceptor 保持 BASIC 不动:/auth/refresh 与 /auth/logout 把 refresh
token 放在**请求体**里,再高一级就会把凭据打进 logcat。

测试:153 全绿(115 → +38)。含真实抓取响应体的 DTO 契约测试、TokenRefresher 的
并发/代际/终态单测、bootstrap 迁移单测,以及走真实 OkHttp + Retrofit + 回环
HttpServer 的端到端测试(证明只重试一次)。未新增任何依赖。

未验证项:主动/被动续期路径本身没能真机触发(唯一输入是加密存储里的 expires_at,
shell 写不进去;而 tokenTTL 是 24h 常量),仅由回环端到端测试覆盖。
This commit is contained in:
AskaEth committed 2026-10-04 13:34:53 +08:00
1 parent 09a3222b7b
commit f5576540b4
14 files changed
+1730 -65

No files matched your search

@@ -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)
}
}
}