从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() }
    }
}
相关推荐
换元不配限7 分钟前
Android 架构演进实战:MVC → MVP → MVVM → MVI
android·架构·mvc·mvvm·mvp·mvi
mmsx2 小时前
Android Dialog 插槽模式:用一套壳统一全应用的弹窗风格
android·java·dialog·开发语言
码上有光2 小时前
Linux:进程间通信——匿名管道通信、命名管道通信
android·java·linux·linux通信·匿名通信·命名通信
爱笑鱼4 小时前
Android 系统启动机制(九):system_server 已经运行,为什么还不能说 Android 启动完成?
android
mmsx4 小时前
Android 上的 AI 对话链路:流式响应、SSE 解析与三级降级
android·kotlin
阿俊-全栈开发4 小时前
LikeShop单商户高级版定时任务实现:订单超时关闭与自动确认收货
android·likeshop·likeshop开源商城
倔强的石头1065 小时前
【Transformer】Tokenization详解_BPE_WordPiece_SentencePiece
android·深度学习·transformer
haerapi5 小时前
仓库拣货路线不只靠最短边:S 形启发式的反例记录
android·开发语言·javascript
其实防守也摸鱼15 小时前
Fastjson 反序列化漏洞(JNDI注入)
android·数据库·安全·oracle·自动化·fastjson·反序列化
狗凯之家源码网16 小时前
微博图床 PHP 系统搭建与实战应用指南
android·开发语言·php