响应式的Android身份验证架构

本文译自「An Android auth architecture with encrypted tokens, auto-refresh, and reactive navigation」,原文链接proandroiddev.com/an-android-...,由Shivam Agrawal发布于2026年6月27日。

简介

大多数"身份验证演示"会将令牌存储在 SharedPreferences 中,然后就万事大吉了。这种方法确实有效,但当你提出三个真实应用无法回避的问题时,问题就出现了:

  • 如果在请求进行过程中访问令牌过期会发生什么?

  • 冷启动时,存储的令牌已经失效会发生什么?

  • 如何让 UI 知道在即时登录成功后跳转到主屏幕------无论在应 用内的任何位置,无需重启 Activity

在构建 [android-secure-auth-architecture](https://github.com/shiwal25/android-secure-auth-architecture) 的过程中,我不断遇到这些问题。这是一个参考实现,它将身份验证视为一个响应式的加密数据流,而不是一个需要检查的标志。它使用 Jetpack Compose、Ktor、Koin 和 Google Tink 构建,本文将详细介绍使其最终成型的四个关键部分。

_⚠️ 这只是一个架构展示,并非最终产品。后端是一个独立的服务,并未包含在内------_BASE_URL_ 指向一个占位符。重点在于展示各个组件如何协同工作,而不是提供一个即插即用的库。

依赖项

gradle/libs.versions.toml

toml 复制代码
[versions]
koin = "4.2.2"
[libraries]
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }

gradle/build.gradle.kts

kotlin 复制代码
//koin
    implementation(libs.koin.androidx.compose)
    implementation(libs.koin.android)
    implementation(platform("io.insert-koin:koin-bom:4.2.1"))
    implementation("io.insert-koin:koin-android")
    implementation("io.insert-koin:koin-androidx-compose")
    implementation("io.insert-koin:koin-androidx-compose-navigation")
    //Jetpack Navigation
    implementation("androidx.navigation3:navigation3-ui:1.1.2")
    implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0")
    //LifeCyle and ViewModel
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
    //Ktor
    implementation(platform("io.ktor:ktor-bom:3.5.0"))
    implementation("io.ktor:ktor-client-android")
    implementation("io.ktor:ktor-client-content-negotiation")
    implementation("io.ktor:ktor-serialization-kotlinx-json")
    implementation("io.ktor:ktor-client-logging")
    implementation("io.ktor:ktor-client-auth")
    //Serialization
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
    //DataStore
    implementation("androidx.security:security-crypto:1.1.0")
    implementation("androidx.datastore:datastore:1.3.0-alpha09")
    implementation("androidx.datastore:datastore-tink:1.3.0-alpha07")
    implementation("com.google.crypto.tink:tink-android:1.21.0")
    //Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")

Manifest

xml 复制代码
<uses-permission android:name="android.permission.INTERNET" />
    <application
        android:name=".SecureAuthApplication"
    ...>
  </application>

加密令牌存储

TokenDataStore 使用来自 androidx.datastore.tinkAeadSerializer 封装了 DataStore<AuthPreferences>。实际的加密密钥是由 AndroidKeysetManager 管理的 AES-256-GCM 密钥集,它被一个始终存储在 Android Keystore 中的密钥封装,而 Android Keystore 在大多数设备上都由硬件支持。

kotlin 复制代码
val keysetHandle = AndroidKeysetManager.Builder()
    .withSharedPref(context, KEYSET_NAME, KEYSET_PREFS_NAME)
    .withKeyTemplate(KeyTemplate.createFrom(PredefinedAeadParameters.AES256_GCM))
    .withMasterKeyUri(MASTER_KEY_URI)
    .build()
    .keysetHandle
val aead: Aead = keysetHandle.getPrimitive(
    RegistryConfiguration.get(), Aead::class.java
)
val encryptedSerializer = AeadSerializer(
    aead = aead,
    wrappedSerializer = AuthPreferencesSerializer,
    associatedData = DATASTORE_FILE_NAME.encodeToByteArray()
)

EncryptedSharedPreferences 目前处于维护模式,并且基于已弃用的旧版 Security Crypto 库。DataStore + Tink 可以提供相同的由 Keystore 支持的 AEAD 加密,但它基于 DataStore 的 Flow 协程原生 API,这在应用程序的其他部分能够正确地将身份验证状态视为流而不是轮询值时至关重要。

响应式身份验证状态

AuthViewModel 直接收集 TokenDataStore.accessToken

kotlin 复制代码
private fun observeTokenForAuthState() {
    viewModelScope.launch {
        tokenDataStore.accessToken.collect { token ->
            if (isFirstEmission) {
                isFirstEmission = false
                handleStartup(token)
            } else {
                _authState.value = if (token != null) {
                    AuthState.Authenticated
                } else {
                    AuthState.Unauthenticated
                }
            }
        }
    }
}

登录、注销和静默刷新会话都会产生完全相同的效果:同一个 Flow 会发出新的数据,并由同一个收集器捕获。登录后没有单独的"现在导航到主页"调用,将令牌保存到 DataStore 本身就是导航触发器。这是我最满意的设计部分:它将三个不同的"用户身份验证状态更改"代码路径合并为一个。

第一次发出的数据通过 handleStartup() 进行特殊处理,因为冷启动有一个其他情况没有的问题:存储的令牌当前是否仍然有效?JwtUtils 在本地解码 JWT 有效负载(无需网络调用),并使用 30 秒的缓冲区检查 exp 声明,这样请求就不会与即将过期的令牌发生竞争。如果已过期,ViewModel 会触发刷新,然后再决定用户应该进入主屏幕还是登录屏幕。

根导航只是监视 authState 并切换图------它对"已登录"的含义没有任何自己的看法:

kotlin 复制代码
when (state) {
    AuthState.Loading         -> SplashContent()
    AuthState.Unauthenticated -> AuthNavGraph(authViewModel = authViewModel)
    AuthState.Authenticated   -> MainNavGraph(authViewModel = authViewModel)
}

自动刷新,通过 Ktor 的 **Auth** 插件

任何 JWT 应用程序中有趣的失败模式:经过身份验证的请求发出,访问令牌刚刚过期,并且请求需要在刷新后透明地重试,而每个存储库方法都不知道刷新逻辑。 Ktor 的 Auth { bearer { ... } } 提供者正是处理这个问题的。

kotlin 复制代码
install(Auth) {
    bearer {
        loadTokens {
            // read current access + refresh token from DataStore
        }
        refreshTokens {
            val response = plainClient.post(refreshUrl) {
                setBody(RefreshRequest(refreshToken = storedRefreshToken))
            }
            when (response.status) {
                HttpStatusCode.OK -> {
                    // save new tokens, return them as BearerTokens
                }
                HttpStatusCode.Unauthorized,
                HttpStatusCode.Forbidden -> {
                    tokenDataStore.clearSession()
                    null
                }
                else -> null
            }
        }
        sendWithoutRequest { request -> request.url.host == BACKEND_HOST }
    }
}

这里有三个深思熟虑的决定:

  • **刷新调用使用普通客户端,而不是经过身份验证的客户端。**否则刷新请求本身可能会触发另一次刷新尝试并递归。
  • 刷新期间的 5xx 不会清除会话。 只有"401"/"403"(服务器明确拒绝刷新令牌)才会使用户注销。暂时性服务器错误只会导致该请求失败;用户保持登录状态并可以重试。
  • 前面提到的 30 秒过期缓冲区 是防止整个事情争分夺秒的原因。

两个 HTTP 客户端

HttpClientFactory 构建一个 plain 客户端(无身份验证 --- 用于 /login/register/refresh)和一个单独的 auth 客户端(安装了 Auth 插件 --- 用于需要登录用户的所有内容)。 Koin 通过命名限定符将这些连接到正确的存储库中,因此从结构上来说,不知道如何刷新令牌的客户端意外调用经过身份验证的端点是不可能的。 DI 图本身强制执行边界,而不是依赖代码审查来捕获它。

我做出的权衡,并将大规模重新考虑

  • Koin over Hilt :在单独的早期开发过程中迭代速度更快;没有代码生成,并且运行时 DI 图很容易根据该项目的规模进行推理。随着模块数量的增加,Hilt 的编译时安全性变得更有价值。
  • Navigation 3 优于经典的 Navigation Compose :较新,并且 API 表面仍在解决中,但基于"NavKey"的返回堆栈比字符串路由更自然地适合类型安全、状态驱动的图形切换。
  • Tink + DataStore over **EncryptedSharedPreferences**:主要是关于不构建维护模式 API。
  • **Result<Unit>** 跨越存储库边界抛出异常 :每个 AuthRepository 方法都会返回一个 Result<Unit> 以及人类可读的失败消息,因此 ViewModel 永远不需要捕获任何内容,它只是在成功或失败时进行模式匹配。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
喵都学不动了2 小时前
Android 自动化测试完全指南(新人版)
android
码云骑士2 小时前
76-全量微调vs-LoRA-vs-QLoRA-三种微调方式对比与选型
android
光头闪亮亮2 小时前
Fyne ( go跨平台GUI )项目实战-WebView 组件开发技术详解
android·go
嵌入式小周3 小时前
Genymotion 安卓模拟器在 Intel 芯片 Mac 上的运行(附带下载方式)
android·macos
zzq77975 小时前
Android 16 API 36 升级后 APP 加固兼容性问题解析
android·开发语言·安全·kotlin·安卓·安全架构
2601_961391466 小时前
KMP全栈开发:从Android到AI Agent的技术演进与实践
android·人工智能
2501_9159184114 小时前
深入对比iOS开发中常用性能监控工具的底层原理与优缺点分析
android·ios·小程序·https·uni-app·iphone·webview
my_power52016 小时前
android中Activity生命周期函数的职责
android
Sirens.16 小时前
从参考 iCost 到做自己的 OneLedger:一个 Android 本地记账 App 的开发记录
android·kotlin·room·jetpack compose·记账 app