移动端登录态安全设计(5):OkHttp 登录态闭环:Interceptor、Authenticator、401 自动刷新

发布时间:2026/7/1 2:55:29

移动端登录态安全设计(5):OkHttp 登录态闭环:Interceptor、Authenticator、401 自动刷新 前面几篇我们已经把 Token 的本地安全存储讲清楚了第1篇App 登录时密码到底要不要加密为什么通常走 HTTPS 第2篇Token 拿到后为什么不能明文存 第3篇AES-GCM Android KeystoreAndroid Token 本地安全存储 第4篇DataStore、MMKV、SharedPreferencesToken 到底应该存哪里到这里移动端已经解决了一个核心问题Token 怎么安全保存但是登录态安全不只是“保存 Token”。真正的 App 网络库里还要解决请求时怎么自动加 accessToken accessToken 过期了怎么办 服务端返回 401客户端怎么刷新 Token 多个接口同时 401怎么避免重复刷新 refreshToken 也失效了怎么退出登录这篇文章就讲移动端登录态的 OkHttp 闭环Interceptor 负责加 TokenAuthenticator 负责处理 401TokenManager 负责管理登录态。一、先看完整登录态链路一个比较完整的移动端登录态流程大概是这样用户登录成功 ↓ 后端返回 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。只要请求经过 OkHttpInterceptor 会自动帮你加上。示例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 UnauthorizedOkHttp 可以通过 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 )TokenManagerclass 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如果刷新后重试仍然 401Authenticator 可能再次被调用。如果不限制就可能无限循环请求接口 ↓ 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 MutableSharedFlowUnit( 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 MutableSharedFlowUnit( 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 统一加。错误 2Interceptor 里直接读 DataStore不推荐。建议 App 启动时恢复 Token 到内存Interceptor 从 TokenManager 读内存。错误 3401 后每个请求都刷新 Token错误。必须加锁同一时间只允许一个刷新请求。错误 4刷新成功只保存 accessToken不完整。如果后端返回新的 refreshToken也要一起保存。错误 5refreshToken 接口也触发 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 负责请求前加 TokenAuthenticator 负责 401 后刷新 Token。再完整一点TokenManager 管登录态SecureTokenStore 管安全存储AuthInterceptor 管加 HeaderTokenAuthenticator 管 401 刷新LoginStateManager 管登录失效通知。这套结构搭好之后移动端网络库才真正具备企业级登录态管理能力。

相关新闻