You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何使用Kotlin与Retrofit2实现@POST请求获取并存储OAuth2令牌

Kotlin + Retrofit2 实现OAuth2令牌获取、存储全流程

1. 前置依赖配置

先在Module级build.gradle.kts中引入需要的依赖,Retrofit 2.9+原生支持Kotlin协程挂起函数,不需要额外引入Call适配器:

dependencies {
    // Retrofit核心
    implementation("com.squareup.retrofit2:retrofit:2.9.0")
    // Gson转换器,用于自动解析JSON响应
    implementation("com.squareup.retrofit2:converter-gson:2.9.0")
    // OkHttp日志拦截器,方便调试请求
    implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
    // Kotlin协程
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
    // 加密SharedPreferences,用于安全存储令牌
    implementation("androidx.security:security-crypto:1.1.0-alpha06")
}

2. @POST接口的正确编写方式

OAuth2令牌接口分两种常见参数格式,根据服务端要求二选一即可:

2.1 先定义令牌响应实体

根据服务端实际返回字段调整字段名,@SerializedName里的值要和接口返回的JSON键名完全一致:

data class TokenResponse(
    @SerializedName("access_token") val accessToken: String,
    @SerializedName("token_type") val tokenType: String,
    @SerializedName("expires_in") val expiresIn: Long,
    @SerializedName("refresh_token") val refreshToken: String? = null
)

2.2 表单提交格式(绝大多数OAuth2标准接口默认格式)

用@FormUrlEncoded+@Field传参,固定grant_type为password对应密码模式:

interface AuthApi {
    @FormUrlEncoded
    @POST("oauth/token") // 替换为自身服务端的令牌接口相对路径
    suspend fun getAccessToken(
        @Field("username") username: String,
        @Field("password") password: String,
        @Field("client_id") clientId: String,
        @Field("client_secret") clientSecret: String,
        @Field("grant_type") grantType: String = "password"
    ): Response<TokenResponse>
}

注意:如果服务端要求将client_id、client_secret放在Basic认证头而非表单参数中,删除对应@Field参数,在方法上加@Header("Authorization")传入Basic 加Base64编码后的clientId:clientSecret字符串即可。

2.3 JSON提交格式(部分自定义接口使用)

如果服务端要求传JSON格式参数,去掉@FormUrlEncoded注解,定义请求实体类用@Body传参:

// 请求实体类
data class TokenRequest(
    val username: String,
    val password: String,
    @SerializedName("client_id") val clientId: String,
    @SerializedName("client_secret") val clientSecret: String,
    @SerializedName("grant_type") val grantType: String = "password"
)

// 接口定义
interface AuthApi {
    @POST("oauth/token")
    suspend fun getAccessToken(@Body request: TokenRequest): Response<TokenResponse>
}

3. Retrofit初始化合理位置

Retrofit和OkHttpClient实例初始化成本高,必须全局单例复用,禁止每次请求新建实例。无依赖注入框架时直接用Kotlin object实现单例即可,用Hilt/Koin的话将对应实例提供为全局单例:

object RetrofitClient {
    // 替换为自身服务端根地址,必须以/结尾
    private const val BASE_URL = "https://your-api-domain.com/"
    private lateinit var tokenStore: TokenStore

    // 应用启动时在Application中调用初始化,传入TokenStore实例
    fun init(store: TokenStore) {
        tokenStore = store
    }

    private val okHttpClient by lazy {
        OkHttpClient.Builder()
            // 令牌自动注入拦截器,放在所有拦截器最前面
            .addInterceptor { chain ->
                val originalRequest = chain.request()
                // 令牌接口本身不需要带token,直接放行
                if (originalRequest.url.encodedPath.contains("oauth/token")) {
                    return@addInterceptor chain.proceed(originalRequest)
                }
                val accessToken = tokenStore.getAccessToken()
                val finalRequest = if (!accessToken.isNullOrEmpty()) {
                    originalRequest.newBuilder()
                        .addHeader("Authorization", "Bearer $accessToken")
                        .build()
                } else {
                    originalRequest
                }
                chain.proceed(finalRequest)
            }
            // 日志拦截器放在最后,才能打印完整的带token请求信息
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
                        else HttpLoggingInterceptor.Level.NONE
            })
            .build()
    }

    private val retrofit by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    // 对外暴露API实例,全局复用
    val authApi: AuthApi by lazy { retrofit.create(AuthApi::class.java) }
    // 其他业务API在此处追加即可
}

4. 接口声明后的完整调用流程

推荐在ViewModel中用协程作用域发起请求,避免内存泄漏:

class LoginViewModel(private val tokenStore: TokenStore) : ViewModel() {
    fun login(username: String, password: String) {
        viewModelScope.launch {
            try {
                val response = RetrofitClient.authApi.getAccessToken(
                    username = username,
                    password = password,
                    clientId = "your_client_id",
                    clientSecret = "your_client_secret"
                )
                if (response.isSuccessful && response.body() != null) {
                    val tokenData = response.body()!!
                    // 存储令牌
                    tokenStore.saveToken(
                        accessToken = tokenData.accessToken,
                        expireAt = System.currentTimeMillis() + tokenData.expiresIn * 1000,
                        refreshToken = tokenData.refreshToken
                    )
                    // 执行登录成功逻辑:跳转主页、拉取用户信息等
                    // 后续所有业务请求会被拦截器自动带上token,不需要每个接口手动传
                } else {
                    // 处理业务错误:账号密码错误、client参数非法等,解析errorBody获取错误提示
                }
            } catch (e: Exception) {
                // 处理网络异常:无网络、连接超时、域名解析失败等
            }
        }
    }
}

扩展:可以额外加一个401拦截器,当接口返回未授权错误时,自动用refreshToken换发新令牌,重新发起失败的请求,不需要用户重复登录。

5. 令牌存储方案

禁止明文存储令牌、禁止硬编码client_secret到APK中(纯客户端应用不适合使用带client_secret的密码模式,容易被反编译窃取密钥),存储优先级如下:

  • 普通业务场景:用Jetpack提供的EncryptedSharedPreferences存储,基于AndroidKeyStore自动加密键值对,root环境下也无法直接读取明文,实现示例:
class TokenStore(context: Context) {
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val encryptedPrefs = EncryptedSharedPreferences.create(
        context,
        "auth_token_store",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun saveToken(accessToken: String, expireAt: Long, refreshToken: String?) {
        encryptedPrefs.edit()
            .putString("KEY_ACCESS_TOKEN", accessToken)
            .putLong("KEY_TOKEN_EXPIRE_AT", expireAt)
            .putString("KEY_REFRESH_TOKEN", refreshToken)
            .apply()
    }

    fun getAccessToken(): String? {
        val expireAt = encryptedPrefs.getLong("KEY_TOKEN_EXPIRE_AT", 0)
        // 提前判断token是否过期,过期返回null触发换发逻辑
        return if (System.currentTimeMillis() < expireAt) {
            encryptedPrefs.getString("KEY_ACCESS_TOKEN", null)
        } else {
            null
        }
    }

    fun clear() {
        encryptedPrefs.edit().clear().apply()
    }
}
  • 高安全需求场景(金融、支付类应用):将令牌用AndroidKeyStore中生成的非对称密钥加密后存储,密钥永远不会出现在系统内存之外,安全等级更高。

内容的提问来源于stack exchange,提问作者Cuyer

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.29 19:15:47