前面几篇我们已经把 Token 的本地安全存储讲清楚了:
第1篇:App 登录时密码到底要不要加密?为什么通常走 HTTPS?
第2篇:Token 拿到后,为什么不能明文存?
第3篇:AES-GCM + Android Keystore:Android Token 本地安全存储
第4篇:DataStore、MMKV、SharedPreferences:Token 到底应该存哪里?
到这里,移动端已经解决了一个核心问题:
Token 怎么安全保存?
但是登录态安全不只是"保存 Token"。
真正的 App 网络库里,还要解决:
请求时怎么自动加 accessToken?
accessToken 过期了怎么办?
服务端返回 401,客户端怎么刷新 Token?
多个接口同时 401,怎么避免重复刷新?
refreshToken 也失效了,怎么退出登录?
这篇文章就讲移动端登录态的 OkHttp 闭环:
Interceptor 负责加 Token,Authenticator 负责处理 401,TokenManager 负责管理登录态。
一、先看完整登录态链路
一个比较完整的移动端登录态流程大概是这样:
用户登录成功
↓
后端返回 accessToken + refreshToken
↓
客户端加密保存 Token
↓
TokenManager 恢复 Token 到内存
↓
OkHttp Interceptor 给请求添加 Authorization
↓
后端校验 accessToken
↓
如果 accessToken 有效,正常返回数据
↓
如果 accessToken 过期,后端返回 401
↓
OkHttp Authenticator 尝试用 refreshToken 刷新
↓
刷新成功,保存新 Token,重新发起原请求
↓
刷新失败,清空 Token,跳登录页
这一整套才叫:
移动端登录态闭环
如果只做了 Interceptor 加 Token,没有处理 401 自动刷新,那么用户体验会很差。
如果只做了 401 刷新,但没有并发控制,那么首页多个接口同时过期时,可能会同时发起多个刷新请求。
如果只做了刷新,但刷新失败不清登录态,就会出现登录状态混乱。
所以登录态不是一个点,而是一条链。
二、Interceptor 负责什么?
OkHttp 的 Interceptor 主要负责拦截请求和响应。
在登录态场景中,最常见的作用是:
给请求统一添加 Authorization Header
比如:
Authorization: Bearer accessToken
也就是说,业务接口不用每个都手动传 Token。
只要请求经过 OkHttp,Interceptor 会自动帮你加上。
示例:
class AuthInterceptor(
private val tokenManager: TokenManager
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val accessToken = tokenManager.getAccessTokenFromMemory()
val newRequest = if (accessToken.isNullOrBlank()) {
originalRequest
} else {
originalRequest.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
}
return chain.proceed(newRequest)
}
}
这段代码的职责很清楚:
1. 从 TokenManager 拿 accessToken
2. 如果 Token 不为空,就加 Authorization Header
3. 继续执行请求
注意,Interceptor 不应该关心:
Token 存在哪里
Token 怎么加密
Token 怎么刷新
Token 什么时候失效
这些都应该交给 TokenManager 和 Authenticator。
三、Authenticator 负责什么?
Interceptor 是请求前加 Token。
Authenticator 是收到 401 后处理认证失败。
当后端返回:
HTTP/1.1 401 Unauthorized
OkHttp 可以通过 Authenticator 尝试重新认证。
在登录态场景中,它通常负责:
用 refreshToken 刷新 accessToken
刷新成功后,重新构造原请求
刷新失败后,返回 null,让请求失败
伪流程:
业务请求返回 401
↓
Authenticator 被调用
↓
读取 refreshToken
↓
请求刷新 Token 接口
↓
刷新成功:保存新 Token,重试原请求
↓
刷新失败:清空登录态,返回 null
所以 Interceptor 和 Authenticator 的分工是:
Interceptor:
请求前加 Token。
Authenticator:
401 后刷新 Token。
四、为什么不能只在 Interceptor 里处理 401?
有些项目会在 Interceptor 里判断响应码:
val response = chain.proceed(request)
if (response.code == 401) {
// refresh token
}
这种方式不是不能做,但容易把 Interceptor 写得很重。
因为 Interceptor 本来已经负责加 Header,如果再处理:
401 判断
refreshToken 调用
刷新并发锁
重试请求
退出登录
最后就会变成一个巨大的网络拦截器。
更清晰的职责划分是:
AuthInterceptor:
只负责加 Authorization。
TokenAuthenticator:
只负责 401 后刷新 Token 和重试请求。
TokenManager:
负责 Token 保存、读取、清理。
LoginStateManager:
负责登录态失效通知。
这样结构更清楚,也更容易维护。
五、TokenManager 应该承担什么职责?
TokenManager 是登录态的核心管理类。
它不应该只是一个简单的 SharedPreferences 工具类。
它应该负责:
保存 Token
读取 Token
恢复 Token 到内存
获取 accessToken
获取 refreshToken
更新 Token
清空 Token
判断是否登录
示例:
data class TokenEntity(
val accessToken: String,
val refreshToken: String,
val expiresAt: Long
)
TokenManager:
class TokenManager(
private val secureTokenStore: SecureTokenStore
) {
@Volatile
private var memoryToken: TokenEntity? = null
suspend fun saveToken(token: TokenEntity) {
memoryToken = token
secureTokenStore.saveToken(token)
}
suspend fun restoreToken(): TokenEntity? {
val token = secureTokenStore.getToken()
memoryToken = token
return token
}
fun getAccessTokenFromMemory(): String? {
return memoryToken?.accessToken
}
fun getRefreshTokenFromMemory(): String? {
return memoryToken?.refreshToken
}
suspend fun getToken(): TokenEntity? {
memoryToken?.let {
return it
}
return restoreToken()
}
suspend fun clearToken() {
memoryToken = null
secureTokenStore.clearToken()
}
}
这里有一个重点:
accessToken 可以放一份内存缓存。
refreshToken 必须加密持久化。
因为 OkHttp Interceptor 是同步接口,直接在里面读 DataStore 会很别扭。
所以更推荐:
App 启动时恢复 Token 到内存
请求时 Interceptor 从内存拿 accessToken
刷新成功后更新内存和本地密文
退出登录时同时清内存和本地密文
六、刷新 Token 接口怎么设计?
一般刷新接口大概是:
POST /auth/refresh
请求:
{
"refreshToken": "xxx"
}
响应:
{
"accessToken": "new_access_token",
"refreshToken": "new_refresh_token",
"expiresAt": 1780000000000
}
更推荐服务端做 refreshToken 轮换:
每次刷新成功,都返回新的 accessToken 和新的 refreshToken。
旧 refreshToken 立即失效。
这样可以降低 refreshToken 泄漏后的风险。
移动端拿到新的 Token 后,要立刻:
1. 更新内存 Token
2. 加密保存新 Token
3. 使用新 accessToken 重试原请求
七、Authenticator 基础实现
先看一个基础版本:
class TokenAuthenticator(
private val tokenManager: TokenManager,
private val authApi: AuthApi,
private val loginStateManager: LoginStateManager
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// 避免无限重试
if (responseCount(response) >= 2) {
return null
}
val newToken = runBlocking {
refreshToken()
} ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer ${newToken.accessToken}")
.build()
}
private suspend fun refreshToken(): TokenEntity? {
val token = tokenManager.getToken() ?: return null
return runCatching {
val result = authApi.refreshToken(
RefreshTokenRequest(token.refreshToken)
)
val newToken = TokenEntity(
accessToken = result.accessToken,
refreshToken = result.refreshToken,
expiresAt = result.expiresAt
)
tokenManager.saveToken(newToken)
newToken
}.getOrElse {
tokenManager.clearToken()
loginStateManager.notifyLoginExpired()
null
}
}
private fun responseCount(response: Response): Int {
var count = 1
var priorResponse = response.priorResponse
while (priorResponse != null) {
count++
priorResponse = priorResponse.priorResponse
}
return count
}
}
这个版本能表达基本思路:
401 后刷新 Token
刷新成功后重试原请求
刷新失败后清 Token
避免无限重试
但它还不够完整。
因为真实项目里会遇到一个经典问题:
多个接口同时 401 怎么办?
八、多个接口同时 401 的问题
假设首页同时请求 5 个接口:
/user/info
/order/list
/message/count
/config
/banner
这时 accessToken 刚好过期。
于是 5 个接口同时返回 401。
如果每个请求都进入 Authenticator,然后都去刷新 Token,就会出现:
请求 A 发起 refreshToken
请求 B 发起 refreshToken
请求 C 发起 refreshToken
请求 D 发起 refreshToken
请求 E 发起 refreshToken
这会带来几个问题:
1. 重复刷新,浪费请求。
2. 如果后端 refreshToken 轮换,第一次刷新成功后旧 refreshToken 失效。
3. 后面几个刷新请求还拿旧 refreshToken 去刷新,可能全部失败。
4. 客户端状态混乱。
5. 可能误触发退出登录。
所以 401 自动刷新必须加锁。
核心原则是:
同一时间只允许一个刷新请求执行,其他 401 请求等待刷新结果。
九、刷新 Token 必须加锁
可以在 TokenAuthenticator 里加一个锁。
因为 OkHttp 的 Authenticator 是同步接口,可以用 synchronized 或 Mutex + runBlocking。
这里先用比较直观的 synchronized 版本。
class TokenAuthenticator(
private val tokenManager: TokenManager,
private val authApi: AuthApi,
private val loginStateManager: LoginStateManager
) : Authenticator {
private val refreshLock = Any()
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) {
return null
}
synchronized(refreshLock) {
val requestToken = response.request.header("Authorization")
?.removePrefix("Bearer ")
?.trim()
val currentToken = tokenManager.getAccessTokenFromMemory()
// 说明别的请求已经刷新成功了
if (!currentToken.isNullOrBlank() && currentToken != requestToken) {
return response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
}
val newToken = runBlocking {
refreshTokenInternal()
} ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer ${newToken.accessToken}")
.build()
}
}
private suspend fun refreshTokenInternal(): TokenEntity? {
val token = tokenManager.getToken()
if (token?.refreshToken.isNullOrBlank()) {
tokenManager.clearToken()
loginStateManager.notifyLoginExpired()
return null
}
return runCatching {
val result = authApi.refreshToken(
RefreshTokenRequest(token!!.refreshToken)
)
val newToken = TokenEntity(
accessToken = result.accessToken,
refreshToken = result.refreshToken,
expiresAt = result.expiresAt
)
tokenManager.saveToken(newToken)
newToken
}.getOrElse {
tokenManager.clearToken()
loginStateManager.notifyLoginExpired()
null
}
}
private fun responseCount(response: Response): Int {
var count = 1
var priorResponse = response.priorResponse
while (priorResponse != null) {
count++
priorResponse = priorResponse.priorResponse
}
return count
}
}
这里有一个关键判断:
if (!currentToken.isNullOrBlank() && currentToken != requestToken) {
return response.request.newBuilder()
.header("Authorization", "Bearer $currentToken")
.build()
}
它的意思是:
当前失败请求用的是旧 accessToken。
但是内存里的 accessToken 已经变成新的了。
说明其他请求已经刷新成功。
当前请求不需要再刷新,直接用新 Token 重试即可。
这个判断非常重要。
它可以避免多个 401 请求重复刷新。
十、为什么要判断 responseCount?
如果刷新后重试仍然 401,Authenticator 可能再次被调用。
如果不限制,就可能无限循环:
请求接口
↓
401
↓
刷新 Token
↓
重试请求
↓
还是 401
↓
再次刷新
↓
再次重试
↓
死循环
所以要限制重试次数。
if (responseCount(response) >= 2) {
return null
}
意思是:
当前请求已经重试过一次了,就不要继续刷新了。
返回 null 后,OkHttp 会把原来的 401 返回给上层。
上层可以统一识别登录态失效。
十一、哪些接口不能触发刷新?
不是所有 401 都应该触发刷新。
比如:
登录接口返回 401
刷新 Token 接口返回 401
退出登录接口返回 401
注册接口返回 401
验证码接口返回 401
这些接口不应该再触发 refresh。
否则可能出现:
refreshToken 接口 401
↓
Authenticator 又调用 refreshToken
↓
又 401
↓
死循环
可以通过请求 tag 或 path 判断。
示例:
private fun shouldSkipAuth(request: Request): Boolean {
val path = request.url.encodedPath
return path.contains("/auth/login") ||
path.contains("/auth/refresh") ||
path.contains("/auth/logout") ||
path.contains("/auth/register")
}
在 Authenticator 里:
override fun authenticate(route: Route?, response: Response): Request? {
if (shouldSkipAuth(response.request)) {
return null
}
if (responseCount(response) >= 2) {
return null
}
// refresh token
}
这样可以避免认证接口之间互相触发。
十二、刷新接口不要共用同一个带 Authenticator 的 OkHttpClient
这是一个很容易忽视的坑。
如果 refreshToken 接口也使用同一个 OkHttpClient,而这个 client 上挂了 TokenAuthenticator。
当 refreshToken 接口自己返回 401 时,可能会再次触发 Authenticator。
所以更稳的做法是:
业务接口 OkHttpClient:
带 AuthInterceptor
带 TokenAuthenticator
刷新 Token 接口 OkHttpClient:
可以不带 TokenAuthenticator
或者明确跳过认证逻辑
也就是说,刷新接口最好有清晰边界。
不要让 refreshToken 请求自己又触发 refreshToken。
十三、runBlocking 能不能用?
Authenticator 的接口是同步的:
override fun authenticate(route: Route?, response: Response): Request?
而你项目里的 Token 存储、刷新接口可能是 suspend 函数。
这时很多人会在 Authenticator 里用:
runBlocking {
refreshToken()
}
能不能用?
可以用,但要谨慎。
因为 Authenticator 本身运行在 OkHttp 的线程里,runBlocking 会阻塞当前线程。
所以要注意:
1. 不要在里面做很重的操作。
2. refreshToken 接口不要再依赖当前卡住的请求链导致死锁。
3. 刷新逻辑要尽快返回。
4. 避免多个请求同时 runBlocking 刷新,所以必须加锁。
更稳的工程做法是:
TokenManager 内存缓存 accessToken。
refreshToken 使用独立 OkHttpClient。
Authenticator 内部只做必要同步刷新。
如果团队想完全避免 suspend,可以给 refreshToken 单独提供同步 API。
十四、刷新成功后要更新哪里?
刷新成功后,不是只更新内存就完了。
应该同时更新:
1. 内存 Token
2. 本地加密 Token
3. 后续请求使用的新 accessToken
流程:
refreshToken 成功
↓
拿到 newAccessToken + newRefreshToken
↓
tokenManager.saveToken(newToken)
↓
内部更新 memoryToken
↓
内部 AES-GCM 加密保存到 DataStore / MMKV
↓
Authenticator 用新 accessToken 重试原请求
这里有一个重点:
如果服务端返回了新的 refreshToken,客户端必须保存新的 refreshToken。
不要只保存 accessToken。
否则下一次刷新时,还拿旧 refreshToken,可能会失败。
十五、刷新失败后怎么处理?
刷新失败通常意味着:
refreshToken 过期
refreshToken 被服务端删除
用户在其他设备改密
后台踢下线
服务端 Token 版本变了
账号状态异常
这时客户端应该:
1. 清空内存 Token
2. 清空本地加密 Token
3. 通知登录态失效
4. 跳转登录页
5. 避免重复弹多个登录失效弹窗
示例:
class LoginStateManager {
private val _loginExpiredEvent = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1
)
val loginExpiredEvent = _loginExpiredEvent.asSharedFlow()
fun notifyLoginExpired() {
_loginExpiredEvent.tryEmit(Unit)
}
}
上层统一监听:
lifecycleScope.launch {
loginStateManager.loginExpiredEvent.collect {
// 清页面栈
// 跳转登录页
// 提示登录已过期
}
}
注意:
登录失效事件要统一处理。
不要每个接口自己弹登录失效。
否则首页 5 个接口同时失败,可能弹 5 次。
十六、如何避免重复弹登录失效?
可以在 LoginStateManager 里做一个状态保护:
class LoginStateManager {
@Volatile
private var hasNotifiedExpired = false
private val _loginExpiredEvent = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1
)
val loginExpiredEvent = _loginExpiredEvent.asSharedFlow()
fun notifyLoginExpired() {
if (hasNotifiedExpired) {
return
}
hasNotifiedExpired = true
_loginExpiredEvent.tryEmit(Unit)
}
fun reset() {
hasNotifiedExpired = false
}
}
登录成功后:
loginStateManager.reset()
这样可以避免多个请求同时触发登录失效事件。
十七、服务端应该怎么配合?
客户端 401 自动刷新不是客户端单方面能完成的。
服务端也要有清晰语义。
建议后端区分:
accessToken 过期:
返回 401,允许客户端用 refreshToken 刷新。
refreshToken 过期:
刷新接口返回 401 或业务错误码,客户端退出登录。
Token 被踢下线:
返回明确错误码,比如 TOKEN_KICKED。
密码已修改:
返回明确错误码,比如 TOKEN_VERSION_EXPIRED。
账号被冻结:
返回明确错误码,比如 ACCOUNT_DISABLED。
客户端拿到这些错误后,可以统一处理:
刷新成功:重试原请求
刷新失败:清 Token,回登录页
被踢下线:清 Token,提示账号已在其他设备登录
密码修改:清 Token,提示请重新登录
账号冻结:清 Token,提示账号状态异常
如果后端只返回一个模糊的 401,客户端就很难做精细化处理。
十八、401 和业务错误码怎么配合?
有些公司喜欢所有错误都返回 HTTP 200,然后通过 code 判断:
{
"code": 401,
"msg": "token expired"
}
有些公司喜欢标准 HTTP 状态码:
HTTP/1.1 401 Unauthorized
两种都能做,但要统一。
如果使用 OkHttp Authenticator,最好让服务端对认证失败返回真正的 HTTP 401。
因为 Authenticator 是基于 HTTP 401 触发的。
如果服务端一直返回 HTTP 200,那 Authenticator 不会自动触发。
这时只能在业务拦截器里解析 body code,再手动处理刷新。
所以如果你想利用 OkHttp Authenticator,后端最好这样设计:
accessToken 无效 / 过期:
HTTP 401
refreshToken 失效:
HTTP 401 或明确业务错误码
权限不足:
HTTP 403
业务失败:
HTTP 200 + 业务 code,或者使用规范错误状态
不要把认证失败、权限不足、业务异常全部混成一个 code。
十九、Interceptor 和 Authenticator 的完整组合
OkHttpClient 可以这样配置:
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
.addInterceptor(loggingInterceptor)
.build()
注意日志拦截器要脱敏:
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
redactHeader("Authorization")
redactHeader("Cookie")
}
如果你有自己的日志系统,也要统一处理:
Authorization
accessToken
refreshToken
password
Cookie
验证码
不要一边加密存储 Token,一边在日志里把 Token 打出来。
二十、推荐的整体结构
最终结构可以这样:
OkHttpClient
├── AuthInterceptor
│ └── TokenManager.getAccessTokenFromMemory()
│
├── TokenAuthenticator
│ ├── 监听 401
│ ├── 加锁刷新 Token
│ ├── 保存新 Token
│ └── 刷新失败通知登录失效
│
└── LoggingInterceptor
└── 敏感 Header 脱敏
TokenManager
├── memoryToken
├── saveToken()
├── restoreToken()
├── getAccessTokenFromMemory()
└── clearToken()
SecureTokenStore
├── AES-GCM 加密 Token
├── Android Keystore 保护 AES key
└── DataStore / MMKV 保存密文
这套结构的重点是:
拦截器只加 Header
Authenticator 只处理 401
TokenManager 管登录态
SecureTokenStore 管安全存储
LoginStateManager 管登录失效事件
职责清楚,后面才好维护。
二十一、常见错误总结
错误 1:业务代码到处手动加 Token
不推荐。
应该由 AuthInterceptor 统一加。
错误 2:Interceptor 里直接读 DataStore
不推荐。
建议 App 启动时恢复 Token 到内存,Interceptor 从 TokenManager 读内存。
错误 3:401 后每个请求都刷新 Token
错误。
必须加锁,同一时间只允许一个刷新请求。
错误 4:刷新成功只保存 accessToken
不完整。
如果后端返回新的 refreshToken,也要一起保存。
错误 5:refreshToken 接口也触发 Authenticator
容易死循环。
刷新接口要跳过认证刷新逻辑,或者使用独立 OkHttpClient。
错误 6:刷新失败不清 Token
错误。
刷新失败应该清空内存 Token 和本地密文 Token,并通知登录失效。
错误 7:不限制重试次数
容易无限循环。
要用 responseCount 限制重试。
错误 8:日志打印 Authorization
严重问题。
Authorization、accessToken、refreshToken 必须脱敏。
二十二、最终落地清单
这一篇可以落成一个清单:
1. 使用 AuthInterceptor 统一添加 Authorization Header。
2. 使用 TokenAuthenticator 统一处理 HTTP 401。
3. TokenManager 统一管理 Token,不让业务层直接读写本地存储。
4. Token 本地使用 AES-GCM + Android Keystore 加密保存。
5. App 启动时从本地密文恢复 Token 到内存。
6. Interceptor 优先从内存读取 accessToken。
7. Authenticator 里限制重试次数,避免死循环。
8. 刷新 Token 时必须加锁,避免多个接口同时刷新。
9. 刷新成功后同时更新 accessToken 和 refreshToken。
10. 刷新失败后清空内存 Token 和本地密文 Token。
11. refreshToken 接口不要再次触发 Authenticator。
12. 登录失效事件统一通知,避免多个页面重复处理。
13. OkHttp 日志和本地日志必须脱敏 Authorization / Token。
14. 后端要明确区分 accessToken 过期、refreshToken 失效、踢下线、权限不足。
二十三、总结
OkHttp 登录态闭环,不是简单地给请求加一个 Header。
完整闭环应该是:
登录成功保存 Token
↓
Token 加密落地
↓
App 启动恢复 Token 到内存
↓
Interceptor 自动加 Authorization
↓
业务接口返回 401
↓
Authenticator 自动刷新 Token
↓
刷新成功后重试原请求
↓
刷新失败后清 Token 并回登录页
Interceptor 和 Authenticator 的分工可以压缩成一句话:
Interceptor 负责请求前加 Token,Authenticator 负责 401 后刷新 Token。
再完整一点:
TokenManager 管登录态,SecureTokenStore 管安全存储,AuthInterceptor 管加 Header,TokenAuthenticator 管 401 刷新,LoginStateManager 管登录失效通知。
这套结构搭好之后,移动端网络库才真正具备企业级登录态管理能力。