如何使用Kotlin与Retrofit2实现@POST请求获取并存储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

