开源项目
github.com/dora4/dorac... 求支持!先star再看,以示尊敬。
前言
有人说,登录认证还不简单吗?不就是输入用户名、密码,然后点击登录按钮的事情吗?非也,登录认证要做好还真不是件容易的事情。别着急,由我娓娓道来。
登录流程
一般项目
市面上大多数项目的登录流程,概括为客户端发送用户名和密码,服务端返回带访问令牌的用户信息,然后客户端在调用接口的时候,把访问令牌带上,完事。 代码表现为,在请求拦截器中带上访问令牌。
kt
.header("Authorization", "Bearer $accessToken")
这在安全性要求不是特别高的系统,没问题,完全ok。退出登录,则发送一个注销登录的事件。
追求质量的项目
对质量有点追求的项目,不仅有访问令牌,还有刷新令牌。那么何为刷新令牌?有何作用?记住,刷新令牌是在访问令牌过期后,给访问令牌续期用的,一般至少是一周以上的有效期。而访问令牌通常在一天以内,甚至是10分钟。而只有访问令牌的项目则只有一个相当于刷新令牌有效期时长的访问令牌。就拿一周来举例,一周内,这个访问令牌可以一直使用,过期后,则需要用户输入密码重新登录。这样做有什么弊端呢?一旦黑客拿到了你的访问令牌,就相当于有了一周的操作你账户的时间,这么长时间在金融产品中是非常危险的。意味着黑客可以在完全不经过你同意的情况下,把你的钱全部转走。这时,刷新令牌就派上用场了。好比访问令牌只有15分钟的有效期,这也就意味着,黑客最多有15分钟的时间操作你账户,因为你的刷新令牌平时是不用的,只会在用到的时候一次性有效。而你在这段时间内,使用刷新令牌刷新访问令牌,则黑客之前窃取的访问令牌则会立即失效。刷新令牌刷新访问令牌的同时,一般也会签发一个新的刷新令牌给你,要不然就失去了刷新令牌的意义。然后注销登录也是有讲究的。
kt
package com.dorachat.auth
import com.google.gson.Gson
import dora.util.ToastUtils
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.json.JSONObject
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class AuthInterceptor : Interceptor {
private val refreshLock = ReentrantLock()
private fun shouldRefresh(
response: Response,
request: Request
): Boolean {
val config = DoraChatSDK.getConfig()
if (config?.autoRefreshToken != true) return false
if (response.code != 401) return false
val path = request.url.encodedPath
if (path.contains("/auth/refresh")) return false
if (TokenStore.refreshToken().isNullOrEmpty()) return false
return true
}
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
val accessToken = TokenStore.accessToken()
if (!accessToken.isNullOrEmpty()) {
request = request.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
}
val response = chain.proceed(request)
if (response.code == 401) {
if (!shouldRefresh(response, request)) {
return signOut(request)
}
response.close()
refreshLock.withLock {
val latest = TokenStore.accessToken()
if (!latest.isNullOrEmpty() && request.header("Authorization") != "Bearer $latest") {
request = request.newBuilder()
.header("Authorization", "Bearer $latest")
.build()
return chain.proceed(request)
}
val refreshToken = TokenStore.refreshToken() ?: return signOut(request)
val newAccess = refreshAccessToken(refreshToken) ?: return signOut(request)
request = request.newBuilder()
.header("Authorization", "Bearer $newAccess")
.build()
return chain.proceed(request)
}
}
return response
}
private fun refreshAccessToken(refreshToken: String): String? {
return try {
val req = ReqToken(refreshToken)
val body = SecureRequestBuilder.build(req, SecureRequestBuilder.SecureMode.ENC)
val json = Gson().toJson(body)
val requestBody = json.toRequestBody("application/json".toMediaType())
val baseUrl = DoraChatSDK.getConfig()?.apiBaseUrl
val request = Request.Builder()
.url("${baseUrl}auth/refresh")
.post(requestBody)
.build()
val client = OkHttpClient()
client.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return null
val str = resp.body?.string() ?: return null
val root = JSONObject(str)
val code = root.optString("code")
val msg = root.optString("msg")
if (code != ApiCode.SUCCESS) {
if (code == ApiCode.ERROR_SIGN_IN_EXPIRED) {
ToastUtils.showLong(msg)
signOut(request)
}
return null
}
val data = root.optJSONObject("data") ?: return null
val newAccessToken = data.optString("accessToken")
val newRefreshToken = data.optString("refreshToken")
if (newAccessToken.isNullOrEmpty() || newRefreshToken.isNullOrEmpty()) return null
TokenStore.save(newAccessToken, newRefreshToken)
newAccessToken
}
} catch (e: Exception) {
null
}
}
private fun signOut(request: Request): Response {
TokenStore.clear()
SignInExpiredBus.postOnce()
return Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("Token expired")
.body("{}".toResponseBody())
.build()
}
}
会使用到ReentrantLock重入锁,来保证多个接口同时调用失效的时候,只会在第一个进入锁的线程完成注销登录整个流程,后面进来的就会被拦截掉。
优化点
后端除了处理完整的访问令牌和刷新令牌逻辑外,还应该做到以下几点。
- 设备风控(判断是不是真机,记录设备ID等)
- 行为风控(IP频率、异地登录、高频切换账号、深夜异常活跃等)
- 登录限流
- 加密请求和请求签名验证
- 多设备管理,踢账号
- OAuth授权登录
- SSO单点登录
客户端则还需要做的事情有。
- 用户行为验证,区分真人和脚本(如极验)
- 校验证书指纹
- 代码混淆(ProGuard/R8)
proguard
-keepclassmembers class com.dorachat.auth.ReqBody {
<fields>;
}
-keepclassmembers class com.dorachat.auth.ApiResult {
<fields>;
}
-keepclassmembers class com.dorachat.auth.DoraUser {
<fields>;
}
-keepclassmembers class com.dorachat.auth.DoraUserInfo {
<fields>;
}
-keepclassmembers class com.dorachat.auth.BaseReq {
<fields>;
}
-keep class * extends com.dorachat.auth.BaseReq {
<fields>;
}
-keep class com.dorachat.auth.SignInEvent {
*;
}
-keep class com.dorachat.auth.SignOutEvent {
*;
}
- 本地token安全,如使用
EncryptedSharedPreferences
kt
package com.dorachat.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
internal object TokenStore {
private const val PREFERENCE_NAME = "secure_token"
private const val KEY_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token"
private lateinit var prefs: SharedPreferences
fun init(context: Context) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs = EncryptedSharedPreferences.create(
context,
PREFERENCE_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
fun accessToken(): String? = prefs.getString(KEY_ACCESS, null)
fun refreshToken(): String? = prefs.getString(KEY_REFRESH, null)
fun save(access: String, refresh: String? = null) {
prefs.edit {
putString(KEY_ACCESS, access)
refresh?.let { putString(KEY_REFRESH, it) }
}
}
fun clear() {
prefs.edit { clear() }
}
}