mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 00:56:26 +08:00
feat(server): passkey pre-refactor (#15060)
#### PR Dependency Tree * **PR #15060** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * OpenApp native sign-in and native session exchange (JWT) for mobile & desktop. * Centralized short-lived auth challenge store for one-time tokens. * Encrypted per-endpoint token storage and native token handlers (Android, iOS, Electron). * **Improvements** * Richer auth-method reporting (password, magic link, OAuth, passkey) and improved sign-in flows. * Hardened magic-link, OAuth, and session issuance; JWT-backed sessions and websocket JWT support. * UX tweaks: form-based password submit, OTP autocomplete, adjusted captcha flow. * **Bug Fixes** * Expanded tests and auth-state resets to avoid cross-test leakage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
+3
-26
@@ -1,9 +1,6 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebView
|
||||
import app.affine.pro.service.CookieStore
|
||||
import app.affine.pro.utils.dataStore
|
||||
import app.affine.pro.utils.get
|
||||
import app.affine.pro.utils.getCurrentServerBaseUrl
|
||||
import app.affine.pro.utils.logger.FileTree
|
||||
import com.getcapacitor.Bridge
|
||||
@@ -11,7 +8,6 @@ import com.getcapacitor.WebViewListener
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -23,30 +19,11 @@ object AuthInitializer {
|
||||
bridge.removeWebViewListener(this)
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
try {
|
||||
val server = bridge.getCurrentServerBaseUrl().toHttpUrl()
|
||||
val sessionCookieStr = AFFiNEApp.context().dataStore
|
||||
.get(server.host + CookieStore.AFFINE_SESSION)
|
||||
val userIdCookieStr = AFFiNEApp.context().dataStore
|
||||
.get(server.host + CookieStore.AFFINE_USER_ID)
|
||||
val csrfCookieStr = AFFiNEApp.context().dataStore
|
||||
.get(server.host + CookieStore.AFFINE_CSRF_TOKEN)
|
||||
if (sessionCookieStr.isEmpty() || userIdCookieStr.isEmpty() || csrfCookieStr.isEmpty()) {
|
||||
Timber.i("[init] user has not signed in yet.")
|
||||
return@launch
|
||||
}
|
||||
Timber.i("[init] user already signed in.")
|
||||
val cookies = listOf(
|
||||
Cookie.parse(server, sessionCookieStr)
|
||||
?: error("Parse session cookie fail:[ cookie = $sessionCookieStr ]"),
|
||||
Cookie.parse(server, userIdCookieStr)
|
||||
?: error("Parse user id cookie fail:[ cookie = $userIdCookieStr ]"),
|
||||
Cookie.parse(server, csrfCookieStr)
|
||||
?: error("Parse csrf token cookie fail:[ cookie = $csrfCookieStr ]"),
|
||||
FileTree.get()?.checkAndUploadOldLogs(
|
||||
bridge.getCurrentServerBaseUrl().toHttpUrl()
|
||||
)
|
||||
CookieStore.saveCookies(server.host, cookies)
|
||||
FileTree.get()?.checkAndUploadOldLogs(server)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "[init] load persistent cookies fail.")
|
||||
Timber.w(e, "[init] auth initializer fail.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+212
-9
@@ -1,8 +1,16 @@
|
||||
package app.affine.pro.plugin
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import app.affine.pro.AFFiNEApp
|
||||
import app.affine.pro.service.AuthHttp
|
||||
import app.affine.pro.service.CookieStore
|
||||
import app.affine.pro.service.OkHttp
|
||||
import app.affine.pro.utils.dataStore
|
||||
import app.affine.pro.utils.del
|
||||
import app.affine.pro.utils.get
|
||||
import app.affine.pro.utils.set
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
@@ -10,6 +18,7 @@ import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.Request
|
||||
@@ -17,10 +26,90 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@CapacitorPlugin(name = "Auth")
|
||||
class AuthPlugin : Plugin() {
|
||||
private fun canonicalEndpoint(endpoint: String): String = try {
|
||||
val url = endpoint.toHttpUrl()
|
||||
val port = if (url.port == HttpUrl.defaultPort(url.scheme)) "" else ":${url.port}"
|
||||
"${url.scheme}://${url.host}$port"
|
||||
} catch (_: Exception) {
|
||||
endpoint
|
||||
}
|
||||
|
||||
private fun tokenKey(endpoint: String) = "auth-token:${canonicalEndpoint(endpoint)}"
|
||||
private fun legacyTokenKey(endpoint: String) = "auth-token:$endpoint"
|
||||
private val tokenCipher = TokenCipher()
|
||||
|
||||
@PluginMethod
|
||||
fun readEndpointToken(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
val key = tokenKey(endpoint)
|
||||
val legacyKey = legacyTokenKey(endpoint)
|
||||
val store = AFFiNEApp.context().dataStore
|
||||
val storedKey = key.takeIf { store.get(it).isNotEmpty() }
|
||||
?: legacyKey.takeIf { it != key && store.get(it).isNotEmpty() }
|
||||
val storedToken = storedKey?.let { store.get(it) }?.takeIf { it.isNotEmpty() }
|
||||
val token = storedToken?.let {
|
||||
tokenCipher.decrypt(it) ?: tokenCipher.legacyPlaintext(it)
|
||||
}
|
||||
if (
|
||||
storedToken != null &&
|
||||
token != null &&
|
||||
(storedKey != key || !tokenCipher.isEncrypted(storedToken))
|
||||
) {
|
||||
store.set(key, tokenCipher.encrypt(token))
|
||||
storedKey?.let {
|
||||
if (it != key) {
|
||||
store.del(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
call.resolve(JSObject().put("token", token))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to read endpoint token.", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun writeEndpointToken(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
val token = call.getStringEnsure("token")
|
||||
AFFiNEApp.context().dataStore.set(
|
||||
tokenKey(endpoint),
|
||||
tokenCipher.encrypt(token)
|
||||
)
|
||||
call.resolve(JSObject().put("ok", true))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to write endpoint token.", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun deleteEndpointToken(call: PluginCall) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
AFFiNEApp.context().dataStore.del(tokenKey(endpoint))
|
||||
AFFiNEApp.context().dataStore.del(legacyTokenKey(endpoint))
|
||||
call.resolve(JSObject().put("ok", true))
|
||||
} catch (e: Exception) {
|
||||
call.reject("Failed to delete endpoint token.", null, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun signInMagicLink(call: PluginCall) {
|
||||
@@ -32,6 +121,11 @@ class AuthPlugin : Plugin() {
|
||||
processSignIn(call, SignInMethod.Oauth)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun signInOpenApp(call: PluginCall) {
|
||||
processSignIn(call, SignInMethod.OpenApp)
|
||||
}
|
||||
|
||||
@SuppressLint("BuildListAdds")
|
||||
@PluginMethod
|
||||
fun signInPassword(call: PluginCall) {
|
||||
@@ -43,21 +137,22 @@ class AuthPlugin : Plugin() {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val endpoint = call.getStringEnsure("endpoint")
|
||||
val csrfToken = CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_CSRF_TOKEN)
|
||||
val token = call.getString("token")
|
||||
val request = Request.Builder()
|
||||
.url("$endpoint/api/auth/sign-out")
|
||||
.post("".toRequestBody("application/json".toMediaTypeOrNull()))
|
||||
.apply {
|
||||
if (csrfToken != null) {
|
||||
addHeader("x-affine-csrf-token", csrfToken)
|
||||
if (token != null) {
|
||||
addHeader("Authorization", "Bearer $token")
|
||||
}
|
||||
}
|
||||
.build()
|
||||
OkHttp.client.newCall(request).executeAsync().use { response ->
|
||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
if (response.code >= 400) {
|
||||
call.reject(response.body.string())
|
||||
return@launch
|
||||
}
|
||||
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
|
||||
Timber.i("Sign out success.")
|
||||
call.resolve(JSObject().put("ok", true))
|
||||
}
|
||||
@@ -69,7 +164,7 @@ class AuthPlugin : Plugin() {
|
||||
}
|
||||
|
||||
private enum class SignInMethod {
|
||||
Password, Oauth, MagicLink
|
||||
Password, Oauth, MagicLink, OpenApp
|
||||
}
|
||||
|
||||
private fun processSignIn(call: PluginCall, method: SignInMethod) {
|
||||
@@ -92,6 +187,7 @@ class AuthPlugin : Plugin() {
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
.url("$endpoint/api/auth/sign-in")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
if (verifyToken != null) {
|
||||
requestBuilder.addHeader("x-captcha-token", verifyToken)
|
||||
@@ -117,6 +213,7 @@ class AuthPlugin : Plugin() {
|
||||
|
||||
Request.Builder()
|
||||
.url("$endpoint/api/oauth/callback")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
.build()
|
||||
}
|
||||
@@ -136,19 +233,41 @@ class AuthPlugin : Plugin() {
|
||||
|
||||
Request.Builder()
|
||||
.url("$endpoint/api/auth/magic-link")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
.build()
|
||||
}
|
||||
|
||||
SignInMethod.OpenApp -> {
|
||||
val code = call.getStringEnsure("code")
|
||||
val body = JSONObject()
|
||||
.apply { put("code", code) }
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaTypeOrNull())
|
||||
|
||||
Request.Builder()
|
||||
.url("$endpoint/api/auth/open-app/sign-in")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
OkHttp.client.newCall(request).executeAsync().use { response ->
|
||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
if (response.code >= 400) {
|
||||
call.reject(response.body.string())
|
||||
return@launch
|
||||
}
|
||||
CookieStore.getCookie(endpoint.toHttpUrl(), CookieStore.AFFINE_SESSION)?.let {
|
||||
val exchangeCode = JSONObject(response.body.string()).optString("exchangeCode").takeIf { it.isNotEmpty() }
|
||||
if (exchangeCode == null) {
|
||||
Timber.w("$method sign in fail, exchange code not found.")
|
||||
call.reject("$method sign in fail, exchange code not found")
|
||||
return@launch
|
||||
}
|
||||
val token = exchangeSession(endpoint, exchangeCode)
|
||||
token.takeIf { it.isNotEmpty() }?.let {
|
||||
CookieStore.clearAuthCookies(endpoint.toHttpUrl().host)
|
||||
Timber.i("$method sign in success.")
|
||||
Timber.d("Update session [$it]")
|
||||
call.resolve(JSObject().put("token", it))
|
||||
} ?: run {
|
||||
Timber.w("$method sign in fail, token not found.")
|
||||
@@ -161,4 +280,88 @@ class AuthPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun exchangeSession(endpoint: String, code: String): String {
|
||||
val body = JSONObject()
|
||||
.apply { put("code", code) }
|
||||
.toString()
|
||||
.toRequestBody("application/json".toMediaTypeOrNull())
|
||||
val request = Request.Builder()
|
||||
.url("$endpoint/api/auth/native/exchange")
|
||||
.addHeader("x-affine-client-kind", "native")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
AuthHttp.client.newCall(request).executeAsync().use { response ->
|
||||
if (response.code >= 400) {
|
||||
throw Exception(response.body.string())
|
||||
}
|
||||
return JSONObject(response.body.string()).optString("token")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TokenCipher {
|
||||
private val alias = "affine-native-auth-token"
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
|
||||
fun encrypt(plaintext: String): String {
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
|
||||
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||
return listOf(
|
||||
"v1",
|
||||
Base64.encodeToString(cipher.iv, Base64.NO_WRAP),
|
||||
Base64.encodeToString(ciphertext, Base64.NO_WRAP),
|
||||
).joinToString(":")
|
||||
}
|
||||
|
||||
fun decrypt(encoded: String): String? {
|
||||
val parts = encoded.split(":")
|
||||
if (parts.size != 3 || parts[0] != "v1") {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val iv = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
val ciphertext = Base64.decode(parts[2], Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
secretKey(),
|
||||
GCMParameterSpec(128, iv)
|
||||
)
|
||||
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Failed to decrypt auth token.")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isEncrypted(value: String) = value.startsWith("v1:")
|
||||
|
||||
fun legacyPlaintext(value: String) =
|
||||
value.takeIf { !isEncrypted(it) && it.isNotBlank() }
|
||||
|
||||
private fun secretKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry)?.let {
|
||||
return it.secretKey
|
||||
}
|
||||
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
"AndroidKeyStore"
|
||||
)
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
keyGenerator.init(spec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
|
||||
+27
-3
@@ -3,6 +3,7 @@ package app.affine.pro.service
|
||||
import app.affine.pro.AFFiNEApp
|
||||
import app.affine.pro.CapacitorConfig
|
||||
import app.affine.pro.utils.dataStore
|
||||
import app.affine.pro.utils.del
|
||||
import app.affine.pro.utils.set
|
||||
import com.google.firebase.crashlytics.ktx.crashlytics
|
||||
import com.google.firebase.ktx.Firebase
|
||||
@@ -50,6 +51,20 @@ object OkHttp {
|
||||
|
||||
}
|
||||
|
||||
object AuthHttp {
|
||||
val client = OkHttpClient.Builder()
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.addInterceptor {
|
||||
it.proceed(
|
||||
it.request()
|
||||
.newBuilder()
|
||||
.addHeader("x-affine-version", CapacitorConfig.getAffineVersion())
|
||||
.build()
|
||||
)
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
object CookieStore {
|
||||
|
||||
const val AFFINE_SESSION = "affine_session"
|
||||
@@ -61,9 +76,6 @@ object CookieStore {
|
||||
fun saveCookies(host: String, cookies: List<Cookie>) {
|
||||
_cookies[host] = cookies
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
cookies.find { it.name == AFFINE_SESSION }?.let {
|
||||
AFFiNEApp.context().dataStore.set(host + AFFINE_SESSION, it.toString())
|
||||
}
|
||||
cookies.find { it.name == AFFINE_USER_ID }?.let {
|
||||
Timber.d("Update user id [${it.value}]")
|
||||
AFFiNEApp.context().dataStore.set(host + AFFINE_USER_ID, it.toString())
|
||||
@@ -77,6 +89,18 @@ object CookieStore {
|
||||
|
||||
fun getCookies(host: String) = _cookies[host] ?: emptyList()
|
||||
|
||||
fun clearAuthCookies(host: String) {
|
||||
val cookies = _cookies[host] ?: emptyList()
|
||||
_cookies[host] = cookies.filter {
|
||||
it.name != AFFINE_SESSION && it.name != AFFINE_USER_ID && it.name != AFFINE_CSRF_TOKEN
|
||||
}
|
||||
MainScope().launch(Dispatchers.IO) {
|
||||
AFFiNEApp.context().dataStore.del(host + AFFINE_USER_ID)
|
||||
AFFiNEApp.context().dataStore.del(host + AFFINE_CSRF_TOKEN)
|
||||
Firebase.crashlytics.setUserId("")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCookie(url: HttpUrl, name: String) = url.host
|
||||
.let { _cookies[it] }
|
||||
?.find { cookie -> cookie.name == name }
|
||||
|
||||
+7
-1
@@ -17,6 +17,12 @@ suspend fun DataStore<Preferences>.set(key: String, value: String) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataStore<Preferences>.del(key: String) {
|
||||
edit {
|
||||
it.remove(stringPreferencesKey(key))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataStore<Preferences>.get(key: String) = data.map {
|
||||
it[stringPreferencesKey(key)] ?: ""
|
||||
}.first()
|
||||
}.first()
|
||||
|
||||
Reference in New Issue
Block a user