feat(auth): 接入 /auth/refresh,不再把登录口令落盘
客户端此前把明文口令存进 EncryptedSharedPreferences,用于过期后的静默重登 (当时后端没有 refresh 端点)。后端现在有 /auth/refresh + /auth/logout 了, 本轮改为存 refresh token,口令在首次成功认证后即被抹掉。 关键约束:服务端的 refresh 是**单次使用 + 原子旋转 + 无宽限期**——同一个 token 第二次使用立刻 invalid_grant;而本端并发 401 是常态(管理概览页两个请求并发、 聊天页 REST 与 WS 同时起步)。因此: - core/auth/TokenRefresher.kt:单飞刷新 + 代际守卫(等锁期间别人已换好令牌就直接 复用,绝不用同一个 refresh token 打第二次)+ 终态记忆(被 invalid_grant 拒过的 token 记下来,一次失效里只打一次注定失败的请求;Transient 不记忆以保留重试) - 用应用层 Interceptor 而非 OkHttp Authenticator:后者对**任何** 401 都会被调用 (含不带令牌的登录请求),且由 RetryAndFollowUp 内部循环驱动(上限 20 次跟进), 持续 401 的令牌会打出刷新风暴。应用层拦截器只在「带了令牌仍 401」时介入,且严格 重试一次 - 可恢复的 401 不再登出:Transient(网络/5xx)原样交回 401 并保留会话;只有 Unrecoverable,或「换上刚签发的令牌仍 401」,才判终态 - WS 握手 401:先尝试一次续期再重连,终态才登出 - 登出补调 POST /auth/logout(此前从不调用,服务端 grant 会白活 30 天) - 主动 skew(60s)刷新 + 兜底重试;刷新请求走不带令牌的客户端,不会递归回本路径 迁移对用户无感、绝不强制重登:启动时若「无 refresh token 但有旧口令」,用旧路径 静默登录一次换到 refresh token,随即 commit() 抹掉口令;离线失败就下次启动再试, 且不清会话。另外鉴权过期时也走一次续期——否则去掉口令会把会话砍到 24h,比改动前 更差。 HttpLoggingInterceptor 保持 BASIC 不动:/auth/refresh 与 /auth/logout 把 refresh token 放在**请求体**里,再高一级就会把凭据打进 logcat。 测试:153 全绿(115 → +38)。含真实抓取响应体的 DTO 契约测试、TokenRefresher 的 并发/代际/终态单测、bootstrap 迁移单测,以及走真实 OkHttp + Retrofit + 回环 HttpServer 的端到端测试(证明只重试一次)。未新增任何依赖。 未验证项:主动/被动续期路径本身没能真机触发(唯一输入是加密存储里的 expires_at, shell 写不进去;而 tokenTTL 是 24h 常量),仅由回环端到端测试覆盖。
This commit is contained in:
1 parent
09a3222b7b
commit
f5576540b4
14 files changed
+1730
-65
No files matched your search
@@ -0,0 +1,406 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
import top.yeij.cyrene.data.api.CyreneApi
|
||||
import top.yeij.cyrene.data.api.dto.LoginRequest
|
||||
import top.yeij.cyrene.data.api.dto.LoginResponse
|
||||
import top.yeij.cyrene.data.api.dto.LogoutRequest
|
||||
import top.yeij.cyrene.data.api.dto.LogoutResponse
|
||||
import top.yeij.cyrene.data.api.dto.RefreshRequest
|
||||
import top.yeij.cyrene.data.api.dto.RefreshResponse
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* B2 迁移与登出的行为钉死。
|
||||
*
|
||||
* 迁移的目标(brief §9):**升级对用户不可见**——不强制重登、不丢会话,
|
||||
* 但首次成功认证之后任何安装都不再留着明文密码。
|
||||
*
|
||||
* 全部走内存假存储 + 假 [CyreneApi],所以既不用网络,也不用新增依赖。
|
||||
* 夹具里的用户名/密码一律是中性值,真实凭据不进仓库。
|
||||
*/
|
||||
class AuthManagerBootstrapTest {
|
||||
|
||||
private val baseUrl = "http://127.0.0.1:10942/"
|
||||
private val username = "testuser"
|
||||
private val legacyPassword = "legacy-secret"
|
||||
private val nowSeconds = System.currentTimeMillis() / 1000
|
||||
|
||||
private val newToken = "jwt-new"
|
||||
private val newRefreshToken = "refresh-new"
|
||||
|
||||
// —— 迁移 ——
|
||||
|
||||
@Test
|
||||
fun `legacy password is exchanged for a refresh token and then wiped`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = null, expiresAt = 0, refreshToken = null, password = legacyPassword),
|
||||
)
|
||||
val api = FakeCyreneApi(
|
||||
loginResult = {
|
||||
Response.success(
|
||||
ApiEnvelope(
|
||||
data = LoginResponse(
|
||||
token = newToken,
|
||||
expiresAt = nowSeconds + 86_400,
|
||||
role = "owner",
|
||||
refreshToken = newRefreshToken,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
|
||||
assertEquals(1, api.loginRequests.size)
|
||||
assertEquals(username, api.loginRequests.single().username)
|
||||
assertEquals(legacyPassword, api.loginRequests.single().password)
|
||||
assertEquals(newToken, store.load().token)
|
||||
assertEquals(newRefreshToken, store.load().refreshToken)
|
||||
assertNull("迁移成功后必须立刻抹掉明文密码", store.load().password)
|
||||
assertEquals("username 必须留着(登录页与缓存指纹都靠它)", username, store.load().username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy login failure keeps the legacy values for the next launch`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = null, expiresAt = 0, refreshToken = null, password = legacyPassword),
|
||||
)
|
||||
val api = FakeCyreneApi(loginResult = { throw IOException("offline") })
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
// 没网也不能吞掉凭据:下次启动还要再试一次。
|
||||
assertEquals(legacyPassword, store.load().password)
|
||||
assertEquals(username, store.load().username)
|
||||
assertNull(store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a still-valid access token survives a failed legacy migration without logging out`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(
|
||||
token = "jwt-still-valid",
|
||||
expiresAt = nowSeconds + 3_600,
|
||||
refreshToken = null,
|
||||
password = legacyPassword,
|
||||
),
|
||||
)
|
||||
val api = FakeCyreneApi(loginResult = { throw IOException("offline") })
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
// 关键:手里还有有效令牌时,迁移失败**绝不能让用户掉到登录页**。
|
||||
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
|
||||
assertEquals("jwt-still-valid", store.load().token)
|
||||
assertEquals(legacyPassword, store.load().password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leftover password is wiped when a refresh token is already present`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(
|
||||
token = "jwt-valid",
|
||||
expiresAt = nowSeconds + 3_600,
|
||||
refreshToken = "refresh-existing",
|
||||
password = legacyPassword,
|
||||
),
|
||||
)
|
||||
val api = FakeCyreneApi()
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
|
||||
assertEquals(0, api.loginRequests.size)
|
||||
assertNull(store.load().password)
|
||||
assertEquals("refresh-existing", store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing stored at all lands on the login screen`() = runBlocking {
|
||||
val store = FakeSessionStorage(StoredSession(baseUrl, null, 0, null, null, null, null))
|
||||
val manager = AuthManager(store) { FakeCyreneApi() }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
}
|
||||
|
||||
// —— 令牌过期后的静默恢复 ——
|
||||
|
||||
@Test
|
||||
fun `an expired access token is renewed with the refresh token at bootstrap`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-existing"),
|
||||
)
|
||||
val api = FakeCyreneApi(
|
||||
refreshResult = {
|
||||
Response.success(
|
||||
ApiEnvelope(
|
||||
data = RefreshResponse(
|
||||
token = newToken,
|
||||
expiresAt = nowSeconds + 86_400,
|
||||
role = "owner",
|
||||
refreshToken = newRefreshToken,
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedIn(baseUrl, "owner"), manager.state.value)
|
||||
assertEquals(1, api.refreshRequests.size)
|
||||
assertEquals("refresh-existing", api.refreshRequests.single().refreshToken)
|
||||
assertEquals(newToken, store.load().token)
|
||||
assertEquals(newRefreshToken, store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired token with a rejected refresh token forces a re-login`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-dead"),
|
||||
)
|
||||
val api = FakeCyreneApi(
|
||||
refreshResult = {
|
||||
Response.error(401, """{"error":{"code":"invalid_grant"}}""".asErrorBody())
|
||||
},
|
||||
)
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.bootstrap()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertEquals(1, api.refreshRequests.size)
|
||||
}
|
||||
|
||||
// —— 登出 ——
|
||||
|
||||
@Test
|
||||
fun `logout revokes the server-side grant before clearing locally`() = runBlocking {
|
||||
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"))
|
||||
val api = FakeCyreneApi()
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.logout()
|
||||
|
||||
assertEquals(1, api.logoutRequests.size)
|
||||
assertEquals("refresh-1", api.logoutRequests.single().refreshToken)
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertNull(store.load().token)
|
||||
assertNull(store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offline logout still clears everything locally`() = runBlocking {
|
||||
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"))
|
||||
val api = FakeCyreneApi(logoutResult = { throw IOException("offline") })
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.logout()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertNull(store.load().refreshToken)
|
||||
assertNull(store.load().token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logout without a refresh token does not even call the server`() = runBlocking {
|
||||
val store = FakeSessionStorage(session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = null))
|
||||
val api = FakeCyreneApi()
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
manager.logout()
|
||||
|
||||
assertEquals(0, api.logoutRequests.size)
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
}
|
||||
|
||||
// —— 终态 ——
|
||||
|
||||
@Test
|
||||
fun `terminal session loss clears the tokens but keeps the username`() {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = "jwt", expiresAt = nowSeconds + 60, refreshToken = "refresh-1"),
|
||||
)
|
||||
val manager = AuthManager(store) { FakeCyreneApi() }
|
||||
|
||||
manager.onSessionExpired()
|
||||
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertNull(store.load().token)
|
||||
assertNull(store.load().refreshToken)
|
||||
assertEquals(username, store.load().username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient refresh failure never ends the session`() = runBlocking {
|
||||
val store = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
|
||||
)
|
||||
val api = FakeCyreneApi(refreshResult = { throw IOException("offline") })
|
||||
val manager = AuthManager(store) { api }
|
||||
|
||||
// 拦截器只在终态才回调 onSessionEnded,所以这里只验证策略本身把两种失败分开了。
|
||||
assertEquals(AuthRecovery.Transient, manager.unauthorizedPolicy.renew("jwt-expired"))
|
||||
assertEquals("refresh-1", store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the unauthorized policy reports terminal loss only for a rejected credential`() {
|
||||
val terminalStore = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
|
||||
)
|
||||
val terminalApi = FakeCyreneApi(
|
||||
refreshResult = { Response.error(401, """{"error":{"code":"invalid_grant"}}""".asErrorBody()) },
|
||||
)
|
||||
val terminalManager = AuthManager(terminalStore) { terminalApi }
|
||||
assertEquals(AuthRecovery.Unrecoverable, terminalManager.unauthorizedPolicy.renew("jwt-expired"))
|
||||
|
||||
// 没有 refresh token 可用 → 同样是终态。
|
||||
val noCredentialStore = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = null),
|
||||
)
|
||||
val noCredentialManager = AuthManager(noCredentialStore) { FakeCyreneApi() }
|
||||
assertEquals(AuthRecovery.Unrecoverable, noCredentialManager.unauthorizedPolicy.renew("jwt-expired"))
|
||||
|
||||
// 续期成功 → 重试用的新令牌就是刷新回来的那一个。
|
||||
val okStore = FakeSessionStorage(
|
||||
session(token = "jwt-expired", expiresAt = nowSeconds - 60, refreshToken = "refresh-1"),
|
||||
)
|
||||
val okApi = FakeCyreneApi(
|
||||
refreshResult = {
|
||||
Response.success(ApiEnvelope(data = RefreshResponse(newToken, nowSeconds + 86_400, "owner", null)))
|
||||
},
|
||||
)
|
||||
val okManager = AuthManager(okStore) { okApi }
|
||||
val recovered = okManager.unauthorizedPolicy.renew("jwt-expired")
|
||||
assertTrue(recovered is AuthRecovery.Renewed)
|
||||
assertEquals(newToken, (recovered as AuthRecovery.Renewed).token)
|
||||
}
|
||||
|
||||
// —— 夹具 ——
|
||||
|
||||
private fun session(
|
||||
token: String?,
|
||||
expiresAt: Long,
|
||||
refreshToken: String?,
|
||||
password: String? = null,
|
||||
) = StoredSession(
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = "owner",
|
||||
username = username,
|
||||
refreshToken = refreshToken,
|
||||
password = password,
|
||||
)
|
||||
|
||||
private fun String.asErrorBody(): ResponseBody = toResponseBody("application/json".toMediaType())
|
||||
|
||||
private class FakeSessionStorage(initial: StoredSession) : SessionStorage {
|
||||
|
||||
@Volatile
|
||||
private var session: StoredSession = initial
|
||||
|
||||
override fun load(): StoredSession = session
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
session = session.copy(
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = role,
|
||||
username = username,
|
||||
refreshToken = refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
session = session.copy(
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
refreshToken = refreshToken ?: session.refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
session = session.copy(password = null)
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
session = StoredSession("", null, 0, null, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** 假的认证接口:三个端点各自可替换返回,并记录收到的请求体。 */
|
||||
private class FakeCyreneApi(
|
||||
private val loginResult: (LoginRequest) -> Response<ApiEnvelope<LoginResponse>> = {
|
||||
Response.success(
|
||||
ApiEnvelope(
|
||||
data = LoginResponse("jwt-default", System.currentTimeMillis() / 1000 + 86_400, "owner", null),
|
||||
),
|
||||
)
|
||||
},
|
||||
private val refreshResult: (RefreshRequest) -> Response<ApiEnvelope<RefreshResponse>> = {
|
||||
Response.success(
|
||||
ApiEnvelope(
|
||||
data = RefreshResponse("jwt-refreshed", System.currentTimeMillis() / 1000 + 86_400, "owner", null),
|
||||
),
|
||||
)
|
||||
},
|
||||
private val logoutResult: (LogoutRequest) -> Response<ApiEnvelope<LogoutResponse>> = {
|
||||
Response.success(ApiEnvelope(data = LogoutResponse(revoked = true)))
|
||||
},
|
||||
) : CyreneApi {
|
||||
|
||||
val loginRequests = CopyOnWriteArrayList<LoginRequest>()
|
||||
val refreshRequests = CopyOnWriteArrayList<RefreshRequest>()
|
||||
val logoutRequests = CopyOnWriteArrayList<LogoutRequest>()
|
||||
|
||||
override suspend fun login(body: LoginRequest): Response<ApiEnvelope<LoginResponse>> {
|
||||
loginRequests += body
|
||||
return loginResult(body)
|
||||
}
|
||||
|
||||
override suspend fun refresh(body: RefreshRequest): Response<ApiEnvelope<RefreshResponse>> {
|
||||
refreshRequests += body
|
||||
return refreshResult(body)
|
||||
}
|
||||
|
||||
override suspend fun logout(body: LogoutRequest): Response<ApiEnvelope<LogoutResponse>> {
|
||||
logoutRequests += body
|
||||
return logoutResult(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* 单飞刷新与代际守卫的行为钉死(B2 §4/§5)。
|
||||
*
|
||||
* 服务端的轮换是**原子且无宽限期**的:同一个 refresh token 被第二次使用立刻 `invalid_grant`,
|
||||
* 所以「并发 401 只刷一次」不是优化而是正确性要求——这些用例就是它的证据。
|
||||
*/
|
||||
class TokenRefresherTest {
|
||||
|
||||
private val nowMs = 1_791_000_000_000L
|
||||
private val nowSeconds = nowMs / 1000
|
||||
|
||||
private val staleToken = "jwt-stale"
|
||||
private val freshToken = "jwt-fresh"
|
||||
private val staleRefreshToken = "refresh-stale"
|
||||
private val rotatedRefreshToken = "refresh-rotated"
|
||||
|
||||
// —— 用例 ——
|
||||
|
||||
@Test
|
||||
fun `concurrent callers sharing one stale token perform exactly one refresh`() = runBlocking {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { _ ->
|
||||
calls.incrementAndGet()
|
||||
// 制造重叠窗口:其余调用方要么正在锁上等,要么已经排在门口。
|
||||
Thread.sleep(50)
|
||||
RefreshCall.Ok(freshToken, nowSeconds + 86_400, rotatedRefreshToken)
|
||||
},
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
val results = (1..8)
|
||||
.map { async(Dispatchers.IO) { refresher.renew(staleToken) } }
|
||||
.awaitAll()
|
||||
|
||||
assertEquals("8 个并发续期只能真的打一次刷新", 1, calls.get())
|
||||
results.forEach { assertEquals(AuthRecovery.Renewed(freshToken), it) }
|
||||
assertEquals(freshToken, store.load().token)
|
||||
assertEquals(rotatedRefreshToken, store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generation guard reuses a token another thread already refreshed`() {
|
||||
val calls = AtomicInteger()
|
||||
// 别人已经换好了:手里的令牌与 staleToken 不同、且没过期。
|
||||
val store = FakeSessionStorage(session(token = freshToken, expiresAt = nowSeconds + 86_400))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok("unused", nowSeconds, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
val recovery = refresher.renew(staleToken)
|
||||
|
||||
assertEquals(AuthRecovery.Renewed(freshToken), recovery)
|
||||
assertEquals("代际守卫生效时不该发刷新请求", 0, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired token equal to the stale one is still refreshed`() {
|
||||
val calls = AtomicInteger()
|
||||
// 值相同但已经过期 → 守卫不能把它当成「别人刷好的令牌」放行。
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 60))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Renewed(freshToken), refresher.renew(staleToken))
|
||||
assertEquals(1, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid_grant is terminal and is not retried for the same credential`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Rejected },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
// 同一个凭据已经确定无效:并发的第二个 401 不该再打一次必然失败的请求。
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
assertEquals(1, calls.get())
|
||||
assertEquals("终态不能改动已存令牌", staleToken, store.load().token)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transient failures stay distinguishable from terminal ones and remain retryable`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Failed },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Transient, refresher.renew(staleToken))
|
||||
assertEquals(AuthRecovery.Transient, refresher.renew(staleToken))
|
||||
// 网络 / 5xx 不是终态:必须留着下次再试,所以这里**不**记忆失败。
|
||||
assertEquals(2, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing refresh token is terminal without any network call`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1, refresh = null))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
assertEquals(0, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rotation-disabled response passes a null refresh token straight through`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Renewed(freshToken), refresher.renew(staleToken))
|
||||
// 服务端不下发新凭据 → 传 null 给存储层,由它保留旧值(AUTH_REFRESH_ROTATE_DISABLED)。
|
||||
assertEquals(listOf<String?>(null), store.updateRefreshTokenArguments)
|
||||
assertEquals(staleRefreshToken, store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `proactive path refreshes only when the token is inside the skew`() {
|
||||
val calls = AtomicInteger()
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds + 300))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Ok(freshToken, nowSeconds + 86_400, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
// 还有 5 分钟:余量(60s)之外,直接交出去,不打网络。
|
||||
assertEquals(staleToken, refresher.ensureToken())
|
||||
assertEquals(0, calls.get())
|
||||
|
||||
// 只剩 30 秒:进到余量里,先换令牌。
|
||||
store.setToken(staleToken, nowSeconds + 30)
|
||||
assertEquals(freshToken, refresher.ensureToken())
|
||||
assertEquals(1, calls.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `proactive path falls back to the stored token when refreshing fails`() {
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds + 10))
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { RefreshCall.Failed },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
// 返回 null 会让整条请求链断掉;那一刻令牌可能还没真过期,交给服务端裁决更好。
|
||||
assertEquals(staleToken, refresher.ensureToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no stored token means nothing to hand out`() {
|
||||
val refresher = TokenRefresher(
|
||||
store = FakeSessionStorage(session(token = null, expiresAt = 0)),
|
||||
call = { RefreshCall.Ok(freshToken, nowSeconds, null) },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertNull(refresher.ensureToken())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fresh rotation also clears the terminal memo`() {
|
||||
val store = FakeSessionStorage(session(token = staleToken, expiresAt = nowSeconds - 1))
|
||||
val calls = AtomicInteger()
|
||||
val refresher = TokenRefresher(
|
||||
store = store,
|
||||
call = { calls.incrementAndGet(); RefreshCall.Rejected },
|
||||
now = { nowMs },
|
||||
)
|
||||
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
assertEquals(1, calls.get())
|
||||
|
||||
// 换一份凭据(重新登录)之后,同一条路径必须重新可以尝试。
|
||||
store.setRefreshToken("refresh-after-login")
|
||||
assertEquals(AuthRecovery.Unrecoverable, refresher.renew(staleToken))
|
||||
assertEquals(2, calls.get())
|
||||
}
|
||||
|
||||
// —— 夹具 ——
|
||||
|
||||
private fun session(
|
||||
token: String?,
|
||||
expiresAt: Long,
|
||||
refresh: String? = staleRefreshToken,
|
||||
) = StoredSession(
|
||||
baseUrl = "http://127.0.0.1:10942",
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = "owner",
|
||||
username = "testuser",
|
||||
refreshToken = refresh,
|
||||
password = null,
|
||||
)
|
||||
|
||||
/** 内存假实现:写入必须立刻对 [load] 可见,否则代际守卫测不出来。 */
|
||||
private class FakeSessionStorage(initial: StoredSession) : SessionStorage {
|
||||
|
||||
@Volatile
|
||||
private var session: StoredSession = initial
|
||||
|
||||
/** `updateTokens` 收到的 refreshToken 实参,按调用顺序记录(含 null)。 */
|
||||
val updateRefreshTokenArguments = CopyOnWriteArrayList<String?>()
|
||||
|
||||
override fun load(): StoredSession = session
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
session = session.copy(
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = role,
|
||||
username = username,
|
||||
refreshToken = refreshToken,
|
||||
password = null,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
updateRefreshTokenArguments += refreshToken
|
||||
session = session.copy(
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
// 与生产实现同义:null 表示沿用原值。
|
||||
refreshToken = refreshToken ?: session.refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
session = session.copy(password = null)
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
session = StoredSession("", null, 0, null, null, null, null)
|
||||
}
|
||||
|
||||
fun setToken(token: String, expiresAt: Long) {
|
||||
session = session.copy(token = token, expiresAt = expiresAt)
|
||||
}
|
||||
|
||||
fun setRefreshToken(refreshToken: String?) {
|
||||
session = session.copy(refreshToken = refreshToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package top.yeij.cyrene.core.auth
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* 端到端(真 OkHttp + 真 Retrofit + 真 HTTP 服务端)验证 B2 §4/§5/§6。
|
||||
*
|
||||
* 服务端用 JDK 自带的 `com.sun.net.httpserver`,**不引入任何新依赖**
|
||||
* (没有 MockWebServer)。这里是唯一能证明「拦截器真的会重试一次、且只重试一次」
|
||||
* 的地方——单测 [TokenRefresher] 只覆盖协调逻辑,覆盖不到它在 OkHttp 里的接线。
|
||||
*
|
||||
* 关键事实(服务端 `RefreshStore.Rotate` 原子且无宽限期):并发 401 里只要有第二个线程
|
||||
* 拿旧 refresh token 去刷,就会立刻 `invalid_grant`。所以「刷了几次」就是正确性判据。
|
||||
*/
|
||||
class UnauthorizedRetryEndToEndTest {
|
||||
|
||||
private val staleToken = "jwt-stale"
|
||||
private val freshToken = "jwt-fresh"
|
||||
private val refreshToken = "refresh-1"
|
||||
|
||||
private val backend = FakeBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two concurrent 401s cause exactly one refresh and both requests succeed`() = runBlocking {
|
||||
val store = storeWith(staleToken, expiresInSeconds = 3_600)
|
||||
val api = authedApi(store)
|
||||
backend.accepts = setOf(freshToken)
|
||||
// 扣住前两个请求不放,直到两个都已到达:这样「两个调用方都读到了旧令牌」是确定的,
|
||||
// 而不是靠调度碰运气。
|
||||
backend.gateConcurrentRequests = true
|
||||
|
||||
val responses = listOf(
|
||||
async(Dispatchers.IO) { api.listSessions() },
|
||||
async(Dispatchers.IO) { api.listSessions() },
|
||||
).awaitAll()
|
||||
|
||||
assertTrue("两个请求都应该靠续期后的令牌成功:$responses", responses.all { it.isSuccessful })
|
||||
assertEquals("两个并发调用方都必须先撞上 401", 2, backend.unauthorizedResponses.get())
|
||||
assertEquals("并发 401 只能真的打一次刷新", 1, backend.refreshBodies.size)
|
||||
assertEquals(refreshToken, backend.refreshedWith.single())
|
||||
assertEquals(freshToken, store.load().token)
|
||||
assertEquals("刷新后应换到新的 refresh token", "refresh-2", store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a near-expiry token is refreshed proactively so the first attempt already carries the new one`() =
|
||||
runBlocking {
|
||||
// 只剩 10 秒:进到 60s 余量里,tokenProvider 必须在发请求之前先换令牌。
|
||||
val store = storeWith(staleToken, expiresInSeconds = 10)
|
||||
val api = authedApi(store)
|
||||
backend.accepts = setOf(freshToken)
|
||||
|
||||
val response = api.listSessions()
|
||||
|
||||
assertTrue(response.isSuccessful)
|
||||
assertEquals(1, backend.refreshBodies.size)
|
||||
assertEquals("主动续期之后第一次请求就该带新令牌", 1, backend.sessionsRequests.size)
|
||||
assertEquals(0, backend.unauthorizedResponses.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invalid_grant on refresh is terminal and ends the session`() = runBlocking {
|
||||
val store = storeWith(staleToken, expiresInSeconds = 3_600)
|
||||
val api = authedApi(store)
|
||||
backend.accepts = emptySet()
|
||||
backend.refreshStatus = 401
|
||||
backend.refreshBody = """{"error":{"code":"invalid_grant","message":"invalid refresh token"}}"""
|
||||
|
||||
val response = api.listSessions()
|
||||
|
||||
assertEquals(401, response.code())
|
||||
assertEquals(1, backend.refreshBodies.size)
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
assertNull("终态必须清掉本地令牌", store.load().token)
|
||||
assertNull(store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transient refresh failure keeps the session alive`() = runBlocking {
|
||||
val store = storeWith(staleToken, expiresInSeconds = 3_600)
|
||||
val api = authedApi(store)
|
||||
backend.accepts = emptySet()
|
||||
backend.refreshStatus = 500
|
||||
backend.refreshBody = """{"error":{"code":"refresh_failed","message":"could not refresh token"}}"""
|
||||
|
||||
val response = api.listSessions()
|
||||
|
||||
assertEquals(401, response.code())
|
||||
// 网络 / 5xx 不是终态:会话必须留着,下次请求还能再试。
|
||||
assertEquals(SessionState.Unknown, manager.state.value)
|
||||
assertEquals(staleToken, store.load().token)
|
||||
assertEquals(refreshToken, store.load().refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a token that keeps being rejected is retried at most once and never storms`() = runBlocking {
|
||||
val store = storeWith(staleToken, expiresInSeconds = 3_600)
|
||||
val api = authedApi(store)
|
||||
// 服务端谁都不要:刷新成功,但新令牌照样 401。
|
||||
backend.accepts = emptySet()
|
||||
|
||||
val response = api.listSessions()
|
||||
|
||||
assertEquals(401, response.code())
|
||||
// 只刷一次;被拒的请求只重试一次。没有刷新风暴,也没有无限重试。
|
||||
assertEquals(1, backend.refreshBodies.size)
|
||||
assertEquals(2, backend.sessionsRequests.size)
|
||||
assertEquals(SessionState.LoggedOut, manager.state.value)
|
||||
}
|
||||
|
||||
// —— 夹具 ——
|
||||
|
||||
private lateinit var manager: AuthManager
|
||||
|
||||
/** 真实接线:拦截器用的 tokenProvider / policy 都来自真正的 [AuthManager]。 */
|
||||
private fun authedApi(store: SessionStorage): SessionsApi {
|
||||
manager = AuthManager(store) { ApiClientFactory.create(baseUrl = it) }
|
||||
return ApiClientFactory.create(
|
||||
baseUrl = backend.baseUrl,
|
||||
service = SessionsApi::class.java,
|
||||
tokenProvider = manager::currentToken,
|
||||
unauthorizedPolicy = manager.unauthorizedPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
private fun storeWith(token: String, expiresInSeconds: Long) = InMemorySessionStore(
|
||||
StoredSession(
|
||||
baseUrl = backend.baseUrl,
|
||||
token = token,
|
||||
expiresAt = System.currentTimeMillis() / 1000 + expiresInSeconds,
|
||||
role = "owner",
|
||||
username = "testuser",
|
||||
refreshToken = refreshToken,
|
||||
password = null,
|
||||
),
|
||||
)
|
||||
|
||||
private class InMemorySessionStore(initial: StoredSession) : SessionStorage {
|
||||
|
||||
@Volatile
|
||||
private var session: StoredSession = initial
|
||||
|
||||
override fun load(): StoredSession = session
|
||||
|
||||
override fun saveLogin(
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
expiresAt: Long,
|
||||
role: String,
|
||||
username: String,
|
||||
refreshToken: String?,
|
||||
) {
|
||||
session = session.copy(
|
||||
baseUrl = baseUrl,
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
role = role,
|
||||
username = username,
|
||||
refreshToken = refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateTokens(token: String, expiresAt: Long, refreshToken: String?) {
|
||||
session = session.copy(
|
||||
token = token,
|
||||
expiresAt = expiresAt,
|
||||
refreshToken = refreshToken ?: session.refreshToken,
|
||||
)
|
||||
}
|
||||
|
||||
override fun purgePassword() {
|
||||
session = session.copy(password = null)
|
||||
}
|
||||
|
||||
override fun clearSession() {
|
||||
session = session.copy(token = null, expiresAt = 0, role = null, refreshToken = null)
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
session = StoredSession("", null, 0, null, null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 极简假服务端:`/api/v1/sessions` 只认 [accepts] 里的访问令牌,
|
||||
* `/api/v1/auth/refresh` 按 [refreshStatus] / [refreshBody] 回。
|
||||
*/
|
||||
private inner class FakeBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(4)
|
||||
createContext("/api/v1/sessions", ::handleSessions)
|
||||
createContext("/api/v1/auth/refresh", ::handleRefresh)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
@Volatile
|
||||
var accepts: Set<String> = emptySet()
|
||||
|
||||
@Volatile
|
||||
var refreshStatus: Int = 200
|
||||
|
||||
@Volatile
|
||||
var refreshBody: String =
|
||||
"""{"data":{"token":"$freshToken","expiresAt":${System.currentTimeMillis() / 1000 + 86_400},""" +
|
||||
""""role":"owner","refreshToken":"refresh-2"}}"""
|
||||
|
||||
val sessionsRequests = CopyOnWriteArrayList<String>()
|
||||
val refreshBodies = CopyOnWriteArrayList<String>()
|
||||
|
||||
/** 打开后,未授权的请求会先等到「两个都到齐」再回 401,用来钉住真实的并发重叠。 */
|
||||
@Volatile
|
||||
var gateConcurrentRequests: Boolean = false
|
||||
|
||||
private val arrived = CountDownLatch(2)
|
||||
|
||||
/** 必须是原子的:两个请求会被 gate 扣住并发地写它。 */
|
||||
val unauthorizedResponses = AtomicInteger()
|
||||
|
||||
/** 送到 `/auth/refresh` 的 refreshToken 值(去引号后的原样)。 */
|
||||
val refreshedWith: List<String>
|
||||
get() = refreshBodies.map { it.substringAfter("\"refreshToken\":\"").substringBefore('"') }
|
||||
|
||||
private fun handleSessions(exchange: HttpExchange) {
|
||||
val authorization = exchange.requestHeaders.getFirst("Authorization").orEmpty()
|
||||
val token = authorization.removePrefix("Bearer ").takeIf { authorization.startsWith("Bearer ") }
|
||||
sessionsRequests += authorization
|
||||
if (token != null && token in accepts) {
|
||||
respond(exchange, 200, """{"data":[]}""")
|
||||
} else {
|
||||
if (gateConcurrentRequests) {
|
||||
arrived.countDown()
|
||||
arrived.await(GATE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
}
|
||||
unauthorizedResponses.incrementAndGet()
|
||||
respond(exchange, 401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRefresh(exchange: HttpExchange) {
|
||||
refreshBodies += exchange.requestBody.readBytes().decodeToString()
|
||||
respond(exchange, refreshStatus, refreshBody)
|
||||
}
|
||||
|
||||
private fun respond(exchange: HttpExchange, status: Int, body: String) {
|
||||
val bytes = body.toByteArray()
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val GATE_TIMEOUT_SECONDS = 5L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
import top.yeij.cyrene.data.api.ApiErrorEnvelope
|
||||
|
||||
/**
|
||||
* 认证契约钉死测试(B2),用的是**线上后端实际抓下来的响应体**
|
||||
* (`http://127.0.0.1:10942`,2026-10-04),只把凭据换成了合成值,其余逐字节照抄。
|
||||
*
|
||||
* 解码器用 [ApiClientFactory.json]——与生产同一份 `Json` 配置
|
||||
* (`ignoreUnknownKeys` / `explicitNulls=false` / `coerceInputValues`),
|
||||
* 所以这里过不了的形状,线上也一定过不了。
|
||||
*
|
||||
* 覆盖真实出现、而手写 JSON 容易漏掉的形状:`expiresAt` 是**秒**不是毫秒、
|
||||
* `/auth/refresh` 会多回一个文档没写的 `role`、轮换模式下多回一个 `refreshToken`、
|
||||
* `/auth/logout` 把 `{"revoked":bool}` 包在信封里(文档写的是另外两种形状)。
|
||||
*/
|
||||
class AuthDtoRealPayloadTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
// 合成凭据:形状与线上一致(JWT 三段点分;refresh token 是 32 字节 base64url、无填充 = 43 字符),
|
||||
// 但值全是编的,真实凭据绝不进仓库。
|
||||
private val jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
|
||||
".eyJzdWIiOiJ0ZXN0dXNlciIsInJvbGUiOiJvd25lciIsImV4cCI6MTc5MTE3NzU4NX0" +
|
||||
".c2lnbmF0dXJlLXN5bnRoZXRpYw"
|
||||
private val rotatedJwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
|
||||
".eyJzdWIiOiJ0ZXN0dXNlciIsInJvbGUiOiJvd25lciIsImV4cCI6MTc5MTE3NzU4N30" +
|
||||
".c2lnbmF0dXJlLXN5bnRoZXRpYw"
|
||||
private val opaque = "kJ7bQ2mZ9vRt4wXp1sNc6yHd3gLf8aE0uTi5oPq2rVs"
|
||||
private val rotatedOpaque = "mR8cT1nW4yK7dZ0jL3sB6vG9aQ2eH5uXp4rD7fN1oM"
|
||||
|
||||
/** 登录请求发出时的本机 Unix 秒;`expiresAt` 与它的差就是服务端声明的有效期。 */
|
||||
private val loginCapturedAtSeconds = 1_791_091_185L
|
||||
|
||||
// —— 以下 6 段是真实响应体(凭据已替换为合成值) ——
|
||||
|
||||
private val realLogin = """
|
||||
{"data":{"token":"$jwt","expiresAt":1791177585,"role":"owner","refreshToken":"$opaque"}}
|
||||
""".trimIndent()
|
||||
|
||||
private val realRefreshRotated = """
|
||||
{"data":{"token":"$rotatedJwt","expiresAt":1791177587,"role":"owner","refreshToken":"$rotatedOpaque"}}
|
||||
""".trimIndent()
|
||||
|
||||
/**
|
||||
* `AUTH_REFRESH_ROTATE_DISABLED=true` 时服务端**不下发**新凭据。线上环境关不掉这个开关,
|
||||
* 所以这一段是按 `refreshResponse` 的 `omitempty` 与文档形状构造的(`role` 也缺一次,
|
||||
* 因为官方文档恰好漏写了它)。
|
||||
*/
|
||||
private val refreshWithoutRotation = """
|
||||
{"data":{"token":"$rotatedJwt","expiresAt":1791177587,"role":"owner"}}
|
||||
""".trimIndent()
|
||||
|
||||
/** 文档形状:只有 token / expiresAt,没有 role、没有 refreshToken。 */
|
||||
private val refreshDocumentedShape = """
|
||||
{"data":{"token":"$rotatedJwt","expiresAt":1791177587}}
|
||||
""".trimIndent()
|
||||
|
||||
private val realRefreshInvalidGrant = """
|
||||
{"error":{"code":"invalid_grant","message":"invalid refresh token"}}
|
||||
""".trimIndent()
|
||||
|
||||
private val realProtectedUnauthorized = """
|
||||
{"error":{"code":"unauthorized","message":"auth: unauthorized"}}
|
||||
""".trimIndent()
|
||||
|
||||
private val realLogoutRevoked = """{"data":{"revoked":true}}"""
|
||||
|
||||
private val realLogoutNotRevoked = """{"data":{"revoked":false}}"""
|
||||
|
||||
@Test
|
||||
fun `real login payload carries a refresh token and expiresAt in seconds`() {
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(LoginResponse.serializer()), realLogin)
|
||||
.data!!
|
||||
|
||||
assertEquals(jwt, data.token)
|
||||
assertEquals("owner", data.role)
|
||||
|
||||
// expiresAt 是 **Unix 秒**:1_791_177_585 正好是抓包时刻 + 86400(24h)。
|
||||
assertEquals(1_791_177_585L, data.expiresAt)
|
||||
assertEquals(86_400L, data.expiresAt - loginCapturedAtSeconds)
|
||||
// 毫秒会是 ~1.79e12;这里必须是秒,否则客户端算出来的有效期会差 1000 倍。
|
||||
assertTrue("expiresAt 必须是秒", data.expiresAt < 10_000_000_000L)
|
||||
|
||||
// B2 新增:长期凭据确实随登录一起下发。
|
||||
assertEquals(opaque, data.refreshToken)
|
||||
assertEquals("线上 refresh token 是 32 字节 base64url、无填充", 43, opaque.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login payload without a refresh token still decodes`() {
|
||||
// 服务端没接线 refresh 存储时整个键缺失(`omitempty`)——B2 之前的契约。
|
||||
val legacy = """{"data":{"token":"$jwt","expiresAt":1791177585,"role":"owner"}}"""
|
||||
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(LoginResponse.serializer()), legacy)
|
||||
.data!!
|
||||
|
||||
assertEquals(jwt, data.token)
|
||||
assertNull(data.refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real refresh payload rotates the token and also returns the documented-missing role`() {
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(RefreshResponse.serializer()), realRefreshRotated)
|
||||
.data!!
|
||||
|
||||
assertEquals(rotatedJwt, data.token)
|
||||
assertNotEquals(jwt, data.token)
|
||||
assertEquals(1_791_177_587L, data.expiresAt)
|
||||
assertTrue("expiresAt 必须是秒", data.expiresAt < 10_000_000_000L)
|
||||
// 文档漏写了 role,实现一定会给——客户端不能因为文档就没这个字段。
|
||||
assertEquals("owner", data.role)
|
||||
// 轮换模式:新凭据与旧的不同,旧的在同一次交易里作废。
|
||||
assertEquals(rotatedOpaque, data.refreshToken)
|
||||
assertNotEquals(opaque, data.refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh payload without a rotated token keeps the previous credential`() {
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(RefreshResponse.serializer()), refreshWithoutRotation)
|
||||
.data!!
|
||||
|
||||
assertEquals(rotatedJwt, data.token)
|
||||
assertEquals("owner", data.role)
|
||||
// 关掉轮换时服务端不下发新凭据:客户端必须能解成 null 并保留手里那份。
|
||||
assertNull(data.refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `documented refresh shape without role decodes to an empty role`() {
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(RefreshResponse.serializer()), refreshDocumentedShape)
|
||||
.data!!
|
||||
|
||||
assertEquals(rotatedJwt, data.token)
|
||||
assertEquals("", data.role)
|
||||
assertNull(data.refreshToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real refresh rejection is 401 invalid_grant`() {
|
||||
val error = json
|
||||
.decodeFromString(ApiErrorEnvelope.serializer(), realRefreshInvalidGrant)
|
||||
.error!!
|
||||
|
||||
// 未知 / 已吊销 / 已过期 / **已经被用掉** 四种情况被服务端折叠成同一个码,
|
||||
// 因此这个码是终态信号:只能重新登录。
|
||||
assertEquals("invalid_grant", error.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real protected endpoint rejection uses a different code than the refresh one`() {
|
||||
val error = json
|
||||
.decodeFromString(ApiErrorEnvelope.serializer(), realProtectedUnauthorized)
|
||||
.error!!
|
||||
|
||||
// 受保护端点上的 401 是 `unauthorized`(访问令牌坏了)——和 `invalid_grant` 必须区分开:
|
||||
// 前者可以续期,后者只能重登。
|
||||
assertEquals("unauthorized", error.code)
|
||||
assertNotEquals("invalid_grant", error.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real logout bodies decode in the envelope shape that the docs do not describe`() {
|
||||
val revoked = json
|
||||
.decodeFromString(ApiEnvelope.serializer(LogoutResponse.serializer()), realLogoutRevoked)
|
||||
.data!!
|
||||
assertTrue(revoked.revoked)
|
||||
|
||||
val notRevoked = json
|
||||
.decodeFromString(ApiEnvelope.serializer(LogoutResponse.serializer()), realLogoutNotRevoked)
|
||||
.data!!
|
||||
// 幂等:未知 / 已吊销的凭据仍然 200,只是 revoked=false。
|
||||
assertTrue(!notRevoked.revoked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `logout bodies in the two documented shapes decode without throwing`() {
|
||||
// 文档互相矛盾(api-reference 说 `{"ok":true,"revokedCount":n}`,api-client 说 `{"revokedCount":n}`),
|
||||
// 真实实现又是第三种。本端只看 2xx,所以三种形状都必须能宽容解出、不炸。
|
||||
for (body in listOf("""{"ok":true,"revokedCount":3}""", """{"revokedCount":3}""", """{}""")) {
|
||||
val envelope = json.decodeFromString(ApiEnvelope.serializer(LogoutResponse.serializer()), body)
|
||||
assertNull("文档形状没有 data 信封:$body", envelope.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user