Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

登录鉴权与账号体系

“账号是所有业务的基石, 一次优秀的登录系统设计, 需要兼顾安全防护, 无缝体验以及应对多设备, 状态同步的复杂性.”

面试策略: 把这道题当作架构设计题来答. 不要只讲发送密码存一下 token, 要从 双 Token 体系, 状态机控制, 安全存储 三个维度, 展现对登录流程严密性的思考.

一, Token 与鉴权基础 (OAuth2/JWT)

移动端通常不再使用传统的 Session-Cookie 模型 (有 CSRF 风险和跨端限制), 主流方案是基于 Token 的鉴权机制.

  • JWT (JSON Web Token): 自包含的令牌, 服务端签发后无需查询数据库即可校验 (只要验证签名).
    • 结构: Header (算法)+ Payload (用户 ID / 过期时间)+ Signature (防篡改签名).
    • 弱点: 一旦签发, 在过期前服务端难以主动使其失效 (除非引入黑名单, 但这会失去 JWT 无状态的优势).
  • OAuth2 核心流程: 只做授权, 通过授权码 + PKCE 换取 Access Token 访问受保护资源; 微信 / Google “登录” 的身份认证由构建于其上的 OIDC (id_token / userinfo) 承担, 不要把 OAuth2 说成认证协议.

二, 双 Token 体系与无感刷新

为解决 JWT 无法撤销与长期有效带来的安全风险, 业界标准是采用 双 Token (Access Token + Refresh Token) 机制:

  1. Access Token (AT): 生命周期极短 (如 2 小时), 每次网络请求放在 Header 中 (Authorization: Bearer <token>).即使泄漏, 风险时间也短.
  2. Refresh Token (RT): 生命周期较长 (如 30 天), 仅用于获取新的 AT.绝不能在普通业务接口中传输.

无感刷新流程设计 (并发拦截控制): 当业务请求收到 401 Unauthorized(AT 过期) 时:

  • 网络层拦截器捕获到 401.
  • 挂起当前及后续其他需要鉴权的请求.
  • 发起使用 RT 换取新 AT 的请求.
  • 刷新成功后, 保存新 token, 并自动重试刚才挂起的业务请求.
  • 如果 RT 也过期 (返回特定错误), 则清空本地登录状态, 跳转到登录页.

三, 登录状态机管理

客户端的登录状态往往很乱 (如闪屏页, 其他请求触发掉线), 必须引入状态机或单向数据流来集中管理.

未登录 (LoggedOut) 
  ↓ (输入账号密码/授权)
登录中 (LoggingIn) → 失败回 [未登录]
  ↓ (获取 Token 成功)
已登录 (LoggedIn) 
  ↓ (401触发刷新)
刷新中 (Refreshing) → 成功回 [已登录],失败去 [未登录]

使用单一可信源 (如全局的 StateFlow/LiveData) 来分发当前状态, UI 根据这个状态决定是展示个人中心还是弹出登录框. 避免到处散落 if (isLogin()).

四, 本地安全存储与风险对抗

Token 是用户的钥匙, 保存在本地必须做安全防护:

存储方案风险级别防御手段 / 面试加分项
明文 SharedPreferences / SQLite文件泄露后 token 可直接使用不保存长期高价值凭据; 日志和备份同样要治理
把固定 secret 写进客户端做自定义加密secret 可被提取, 难以轮换不把混淆 / 分段当密钥管理; 使用版本化 envelope encryption 与服务端撤销
Android Keystore + 受控密文存储可降低密钥导出风险, 但保护等级因设备/属性而异读取 security level/必要时 attestation, 设计软件级设备降级; EncryptedSharedPreferences 已弃用, 不作为新项目默认方案

设备绑定与风控检查: 登录不仅仅是密码匹配. 服务端通常还会结合你收集的设备指纹判定: 异地登录, 新设备登录, 同一设备高频切换账号等, 需要触发短信验证码, 滑块验证等二次认证机制.

五, 多设备登录与单点登录 (SSO)

  • 单点登录 (SSO): 企业内部多 App 互通登录状态. 通常由一个主应用 / 网页提供统一鉴权, 返回临时 Ticket, 其他端用 Ticket 去认证中心换自己的 Token.
  • 多设备互踢:
    • 服务端维护一张 [用户ID - 设备ID - Token] 映射表.
    • 用户在 B 设备登录, 服务端使得 A 设备的 Token 失效, 或者通过 WebSocket/Push 主动推一条 “踢出下线” 指令给 A 设备.
    • A 设备收到通知, 清空本地状态, 弹窗提示 “您的账号在其他设备登录”.

六, Token 轮换, 并发刷新与撤销

  • Access token 短期有效; refresh token 采用 rotation 时, 每次刷新返回新 RT 并使旧 RT 失效. 服务端检测旧 RT 重放后可撤销该 token family.
  • 客户端刷新必须 single-flight: 并发 401 只允许一个刷新请求, 其他请求等待同一结果; 刷新完成后最多重放一次原请求, 防止循环 401.
  • 登出, 改密, 设备解绑和高风险事件要由服务端撤销会话; 本地删 token 不能替代服务端撤销.
  • JWT 的 exp/nbf/iat 校验要考虑设备时钟偏差. 安全决策以服务端时间为准, 客户端只做体验优化; 退避计时优先单调时钟.
  • 重试写请求必须有业务幂等键; 不要在 authenticator/interceptor 中无条件重放不可重复 body.
  • token 存储与日志遵循最小暴露, 崩溃日志, 埋点和 URL 中不得包含完整 token.

OAuth 2.0 授权码 + PKCE 的移动端边界

上下文片段, 不是可独立运行示例: 移动 App 属于公开客户端, 无法安全保存 client_secret. 第三方登录应优先使用授权码流程加 PKCE, 而不是把密码交给 App 或使用隐式流程.

App: 生成高熵 code_verifier
App: code_challenge = BASE64URL(SHA-256(code_verifier))
App -> 浏览器/授权服务器: authorization request + code_challenge(S256) + state + redirect_uri
授权服务器 -> App redirect_uri: code + state
App -> token endpoint: code + 原始 code_verifier + redirect_uri
授权服务器: 校验 code,redirect_uri,code_challenge,签发 token
  • state 用于将回调与本次发起请求关联, 回调时必须校验; PKCE 防止截获授权码的一方在没有 code_verifier 时兑换 token.
  • 使用系统浏览器或受信任的授权会话承载登录, 不在 WebView 中收集第三方账号密码; 回调 URI 必须是预登记的精确地址, 不能接受客户端任意传入的重定向地址.
  • 授权码只能兑换一次. 用户取消, state 不匹配, 回调被重复触发或网络超时都回到可重新登录的终态, 不能把未知结果当作已登录.

JWT 校验责任与刷新 single-flight

资源服务至少验证签名算法白名单, 签发者, 受众, 过期时间和必要声明; 不能只 “解码 payload” 后相信 userId. 客户端可读取过期声明优化体验, 但授权结论由服务端签名校验和会话撤销记录决定.

// 上下文片段:TokenRepository 由单进程内的网络层共享.
private val refreshMutex = Mutex()
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

private data class RefreshFlight(val generation: Long, val deferred: Deferred<Result<Token>>)
private var inFlight: RefreshFlight? = null

suspend fun refreshOnce(expectedGeneration: Long): Result<Token> {
    val shared = refreshMutex.withLock {
        inFlight?.takeIf { it.generation == expectedGeneration }?.deferred ?: run {
            val deferred = repositoryScope.async {
                val refreshToken = tokenStore.refreshToken(expectedGeneration)
                    ?: return@async Result.failure(LoginRequired())
                api.refresh(refreshToken).onSuccess { token ->
                    tokenStore.replaceAtomically(expectedGeneration, token)
                }
            }
            val created = RefreshFlight(expectedGeneration, deferred)
            deferred.invokeOnCompletion {
                repositoryScope.launch {
                    refreshMutex.withLock {
                        if (inFlight?.generation == created.generation &&
                            inFlight?.deferred === created.deferred) inFlight = null
                    }
                }
            }
            inFlight = created
            deferred
        }
    }
    return shared.await() // 取消仅终止当前等待,不取消或注销共享刷新任务
}

共享刷新任务由独立 repositoryScope 所有, 等待者只 await(); 调用方取消只终止自身等待, 既不取消也不从 inFlight 注销该任务. 任务自身成功或失败完成后, completion handler 才在短 Mutex 临界区按 generation + Deferred identity 清理. Mutex 不包住网络请求, 因此不是把所有刷新串行化; 所有并发 401 仍共享同一个成功或失败结果, refresh-token rotation 不会并发刷新. generation 在登出, 账号切换或 RT 轮换失效时递增, 新会话创建自己的 flight, 旧任务完成不能覆盖新会话. 原子替换指 AT, 轮换 RT 及版本号在同一持久化事务提交; 原请求最多重放一次. 写请求仍需业务幂等键, 见支付订单与状态机.

多进程与弃用存储迁移

DataStore 和进程内 StateFlow 都不是跨进程一致性协议. 若推送/SDK 使用独立进程, 应指定唯一 “账号状态所有者”, 通过受权限保护的 Binder/ContentProvider 或服务端重新鉴权同步版本化快照; 不得让两个进程同时用同一 RT 刷新. 退出登录时递增会话版本, 接收方发现版本变化即关闭旧连接和内存缓存.

新项目不要把已弃用的 EncryptedSharedPreferences 当默认方案. 可将版本化密文放在普通受控存储中, 把 AES 密钥交给 Android Keystore 管理. 系统备份恢复时 Keystore 密钥通常不能随应用数据恢复; 密文无法解密时删除本地凭据并重新认证, 绝不降级为明文. 设备信号只供服务端风控辅助, 不能恢复 token 或替代登录.

高频面试题

Q1: Access Token 和 Refresh Token 的机制是什么? 为什么要用两个 Token? 为了平衡安全与用户体验. 单一长效 Token 泄漏风险极大且难以撤销; 短效 Token 频繁过期会让用户反复登录体验极差. 双 Token 用短效 Access Token 降低泄漏后的损失窗口, 用长效 Refresh Token 实现静默续期, 且 Refresh Token 只发往专门的刷新接口, 截获概率低.

Q2: 在协程 / RxJava 拦截器中, 怎么处理多并发请求导致的多次 Token 刷新问题? 利用并发锁或者协程 Mutex. 当第一个 401 触发刷新时, 上锁, 后续的 401 请求判断正在刷新中, 则挂起等待. 刷新成功后, 通知所有等待的请求用新 Token 重试; 如果刷新失败, 则全部抛出未登录异常跳转登录页.

Q3: App 卸载重装后, 如何保持依然处于登录状态 (免密登录)? 默认重新认证. 卸载会删除应用数据; 即使系统备份恢复了密文, 关联 Keystore 密钥通常不可恢复, 解密失败必须清理并重新登录. 设备信号只能辅助服务端评估风险, 不能作为稳定免密凭证.

学完能做什么与练习

你应能画出授权码 + PKCE, 会话 generation 和刷新共享任务的边界. 练习: 让三个并发请求同时收到 401, 取消其中一个等待者, 再在 refresh 未完成时登出; 预期证据是只发起一次刷新, 取消不注销或取消共享任务, 其余等待者共享同一结果, 旧任务不能写回新 generation, 且本地密文解密失败时进入登录页.

易错点 / 追问

  • Token 存在内存中没有持久化: 导致 App 被系统杀进程重启后状态丢失.
  • 未处理多进程的 Token 同步: 很多 App 有独立推送或后台进程, 单进程刷新了 Token 没通知其他进程, 导致 401 死循环.
  • 退出登录未通知服务端: 本地清空了 Token, 但 JWT 仍然没有过期, 截获这段 JWT 的黑客仍能继续请求 (应当让服务端将该 Token 暂时加入黑名单).