从dorachat-auth的角度看登录认证

开源项目

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() }
    }
}
相关推荐
Fate_I_C1 小时前
View Binding的基础使用
android·kotlin·viewbinding
zhangphil1 小时前
Android Coil 3 extend ImageRequest‘s custom method/function,Kotlin
android·kotlin
星河漫步Lu1 小时前
QT6中五步完成Android的环境配置
android·qt
UXbot2 小时前
AI 原型工具对比(2026):从文字描述到完整 App 界面的 5 款主流平台评测
android·前端·ios·交互·软件构建
三少爷的鞋3 小时前
Android Clean Architecture 中 Use Case 只能有一个方法吗?
android
思麟呀3 小时前
MySQL复合查询与内外连接
android·数据库·mysql
程序员陆业聪12 小时前
两次Flutter全屏白踩坑复盘:Layout的静默失败,以及AI结对编程的认知盲区
android
程序员陆业聪13 小时前
Compose Strong Skipping Mode 的真相:它并不会让你的类型变 Stable
android
shaoming377617 小时前
浏览器动作开发:地址栏图标点击事件、弹出页面设计
android·mysql·adb