位置:首页 > Kotlin > Android响应式身份验证架构设计与实现

Android响应式身份验证架构设计与实现

时间:2026-08-15  |  作者:星河游者  |  阅读:0

本文译自「An Android auth architecture with encrypted tokens, auto-refresh, and reactive na vigation」,原文链接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

[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

//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-na vigation")
    //Jetpack Na vigation
    implementation("androidx.na vigation3:na vigation3-ui:1.1.2")
    implementation("androidx.lifecycle:lifecycle-viewmodel-na vigation3: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

<uses-permission android:name="android.permission.INTERNET" />
    <application
        android:name=".SecureAuthApplication"
    ...>
  application>

加密令牌存储

TokenDataStore 使用来自 androidx.datastore.tinkAeadSerializer 封装了 DataStore

实际的加密密钥是由 AndroidKeysetManager 管理的 AES-256-GCM 密钥集。它被一个始终存储在 Android Keystore 中的密钥封装,而 Android Keystore 在大多数设备上都由硬件支持。

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.ja va
)
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

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 并切换图。它对“已登录”的含义没有任何自己的看法:

when (state) {
    AuthState.Loading         -> SplashContent()
    AuthState.Unauthenticated -> AuthNa vGraph(authViewModel = authViewModel)
    AuthState.Authenticated   -> MainNa vGraph(authViewModel = authViewModel)
}

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

任何 JWT 应用程序中都有一个很典型的失败模式:经过身份验证的请求已经发出,但访问令牌刚好过期。

这时,请求需要在刷新后透明地重试。同时,每个存储库方法又不应该知道刷新逻辑。

Ktor 的 Auth { bearer { ... } } 提供者,正是为了解决这个问题。

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 -> {
                    // sa ve 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 的编译时安全性变得更有价值。

  • Na vigation 3 优于经典的 Na vigation Compose :较新,并且 API 表面仍在解决中,但基于“Na vKey”的返回堆栈比字符串路由更自然地适合类型安全、状态驱动的图形切换。

  • Tink + DataStore over **EncryptedSharedPreferences**:主要是关于不构建维护模式 API。

  • **Result** 跨越存储库边界抛出异常:每个 AuthRepository 方法都会返回一个 Result 以及人类可读的失败消息,因此 ViewModel 永远不需要捕获任何内容,它只是在成功或失败时进行模式匹配。

免责声明:文中图文均来自网络,如有侵权请联系删除,心愿游戏发布此文仅为传递信息,不代表心愿游戏认同其观点或证实其描述。

相关文章

更多

精选合集

更多

大家都在玩

热门话题

大家都在看

更多