本文译自「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.tink 的 AeadSerializer 封装了 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 永远不需要捕获任何内容,它只是在成功或失败时进行模式匹配。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!