Initial auth support(ws currently broken, bad login dialog)

This commit is contained in:
Syer10
2025-10-15 16:23:11 -04:00
parent 0245ef25fe
commit f0a9f7aff1
21 changed files with 790 additions and 139 deletions

View File

@@ -16,13 +16,17 @@ import ca.gosyer.jui.domain.migration.interactor.RunMigrations
import ca.gosyer.jui.domain.migration.service.MigrationPreferences
import ca.gosyer.jui.domain.reader.service.ReaderPreferences
import ca.gosyer.jui.domain.server.Http
import ca.gosyer.jui.domain.server.HttpNoAuth
import ca.gosyer.jui.domain.server.httpClient
import ca.gosyer.jui.domain.server.httpClientNoAuth
import ca.gosyer.jui.domain.server.service.ServerHostPreferences
import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.source.service.CatalogPreferences
import ca.gosyer.jui.domain.ui.service.UiPreferences
import ca.gosyer.jui.domain.updates.interactor.UpdateChecker
import ca.gosyer.jui.domain.updates.service.UpdatePreferences
import ca.gosyer.jui.domain.user.interactor.UserRefreshUI
import ca.gosyer.jui.domain.user.service.UserPreferences
import kotlinx.serialization.json.Json
import me.tatarka.inject.annotations.Provides
@@ -39,6 +43,8 @@ interface SharedDomainComponent : CoreComponent {
val http: Http
val httpNoAuth: HttpNoAuth
val serverPreferences: ServerPreferences
val extensionPreferences: ExtensionPreferences
@@ -59,12 +65,30 @@ interface SharedDomainComponent : CoreComponent {
val json: Json
val userRefreshUI: UserRefreshUI
@get:AppScope
@get:Provides
val lazyUserRefreshUIFactory: Lazy<UserRefreshUI>
get() = lazy { userRefreshUI }
@AppScope
@Provides
fun httpFactory(
serverPreferences: ServerPreferences,
userPreferences: UserPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json,
) = httpClient(serverPreferences, json)
): Http = httpClient(serverPreferences, userPreferences, userRefreshUI, json)
@AppScope
@Provides
fun httpNoAuthFactory(
serverPreferences: ServerPreferences,
userPreferences: UserPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json,
): HttpNoAuth = httpClientNoAuth(serverPreferences, userPreferences, userRefreshUI, json)
@get:AppScope
@get:Provides
@@ -108,6 +132,11 @@ interface SharedDomainComponent : CoreComponent {
val updatePreferencesFactory: UpdatePreferences
get() = UpdatePreferences(preferenceFactory.create("update"))
@get:AppScope
@get:Provides
val userPreferencesFactory: UserPreferences
get() = UserPreferences(preferenceFactory.create("user"))
@get:AppScope
@get:Provides
val serverHostPreferencesFactory: ServerHostPreferences

View File

@@ -6,10 +6,13 @@
package ca.gosyer.jui.domain.server
import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.domain.build.BuildKonfig
import ca.gosyer.jui.domain.server.model.Auth
import ca.gosyer.jui.domain.server.model.Proxy
import ca.gosyer.jui.domain.server.service.ServerPreferences
import ca.gosyer.jui.domain.user.interactor.UserRefreshUI
import ca.gosyer.jui.domain.user.service.UserPreferences
import com.diamondedge.logging.logging
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
@@ -17,6 +20,8 @@ import io.ktor.client.engine.HttpClientEngineConfig
import io.ktor.client.engine.HttpClientEngineFactory
import io.ktor.client.engine.ProxyBuilder
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.api.Send
import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
import io.ktor.client.plugins.auth.providers.DigestAuthCredentials
import io.ktor.client.plugins.auth.providers.basic
@@ -27,6 +32,7 @@ import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.http.HttpStatusCode
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
import io.ktor.http.Url
@@ -44,10 +50,65 @@ import io.ktor.client.plugins.auth.Auth as AuthPlugin
typealias Http = StateFlow<HttpClient>
typealias HttpNoAuth = StateFlow<HttpClient>
expect val Engine: HttpClientEngineFactory<HttpClientEngineConfig>
expect fun HttpClientConfig<HttpClientEngineConfig>.configurePlatform()
private val log = logging()
private class SimpleAuthPluginConfig {
var simpleSessionPreference: Preference<String>? = null
}
private val SimpleAuthPlugin = createClientPlugin("SimpleAuthPlugin", ::SimpleAuthPluginConfig) {
val simpleSessionPreference = pluginConfig.simpleSessionPreference!!
on(Send) { request ->
val session = simpleSessionPreference.get()
if (session.isNotEmpty()) {
request.headers.append("Cookie", "JSESSIONID=$session")
}
proceed(request)
}
}
private class UIAuthPluginConfig {
var uiAccessTokenPreference: Preference<String>? = null
var userRefreshUI: Lazy<UserRefreshUI>? = null
}
private val UIAuthPlugin = createClientPlugin("UIAuthPlugin", ::UIAuthPluginConfig) {
val uiAccessTokenPreference = pluginConfig.uiAccessTokenPreference!!
val userRefreshUI = pluginConfig.userRefreshUI!!
on(Send) { request ->
val token = uiAccessTokenPreference.get()
if (token.isNotEmpty()) {
request.headers.append("Authorization", "Bearer $token")
val originalCall = proceed(request)
if (originalCall.response.status == HttpStatusCode.Unauthorized) {
log.warn { "Token expired, refreshing..." }
val newToken = userRefreshUI.value.await()
if (newToken != null) {
request.headers.remove("Authorization")
request.headers.append("Authorization", "Bearer $newToken")
proceed(request)
} else {
originalCall
}
} else {
originalCall
}
} else {
proceed(request)
}
}
}
private fun getHttpClient(
serverUrl: Url,
proxy: Proxy,
@@ -58,6 +119,9 @@ private fun getHttpClient(
auth: Auth,
authUsername: String,
authPassword: String,
uiAccessTokenPreference: Preference<String>,
simpleSessionPreference: Preference<String>,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json
): HttpClient {
return HttpClient(Engine) {
@@ -113,6 +177,13 @@ private fun getHttpClient(
}
}
}
Auth.SIMPLE -> install(SimpleAuthPlugin) {
this.simpleSessionPreference = simpleSessionPreference
}
Auth.UI -> install(UIAuthPlugin) {
this.uiAccessTokenPreference = uiAccessTokenPreference
this.userRefreshUI = userRefreshUI
}
}
install(HttpTimeout) {
connectTimeoutMillis = 30.seconds.inWholeMilliseconds
@@ -143,6 +214,8 @@ private fun getHttpClient(
@OptIn(DelicateCoroutinesApi::class)
fun httpClient(
serverPreferences: ServerPreferences,
userPreferences: UserPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json,
): Http = combine(
serverPreferences.serverUrl().stateIn(GlobalScope),
@@ -156,30 +229,85 @@ fun httpClient(
serverPreferences.authPassword().stateIn(GlobalScope),
) {
getHttpClient(
it[0] as Url,
it[1] as Proxy,
it[2] as String,
it[3] as Int,
it[4] as String,
it[5] as Int,
it[6] as Auth,
it[7] as String,
it[8] as String,
json,
serverUrl = it[0] as Url,
proxy = it[1] as Proxy,
proxyHttpHost = it[2] as String,
proxyHttpPort = it[3] as Int,
proxySocksHost = it[4] as String,
proxySocksPort = it[5] as Int,
auth = it[6] as Auth,
authUsername = it[7] as String,
authPassword = it[8] as String,
uiAccessTokenPreference = userPreferences.uiAccessToken(),
simpleSessionPreference = userPreferences.simpleSession(),
userRefreshUI = userRefreshUI,
json = json,
)
}.stateIn(
GlobalScope,
SharingStarted.Eagerly,
getHttpClient(
serverPreferences.serverUrl().get(),
serverPreferences.proxy().get(),
serverPreferences.proxyHttpHost().get(),
serverPreferences.proxyHttpPort().get(),
serverPreferences.proxySocksHost().get(),
serverPreferences.proxySocksPort().get(),
serverPreferences.auth().get(),
serverPreferences.authUsername().get(),
serverPreferences.authPassword().get(),
json,
serverUrl = serverPreferences.serverUrl().get(),
proxy = serverPreferences.proxy().get(),
proxyHttpHost = serverPreferences.proxyHttpHost().get(),
proxyHttpPort = serverPreferences.proxyHttpPort().get(),
proxySocksHost = serverPreferences.proxySocksHost().get(),
proxySocksPort = serverPreferences.proxySocksPort().get(),
auth = serverPreferences.auth().get(),
authUsername = serverPreferences.authUsername().get(),
authPassword = serverPreferences.authPassword().get(),
uiAccessTokenPreference = userPreferences.uiAccessToken(),
simpleSessionPreference = userPreferences.simpleSession(),
userRefreshUI = userRefreshUI,
json = json,
)
)
@OptIn(DelicateCoroutinesApi::class)
fun httpClientNoAuth(
serverPreferences: ServerPreferences,
userPreferences: UserPreferences,
userRefreshUI: Lazy<UserRefreshUI>,
json: Json,
): Http = combine(
serverPreferences.serverUrl().stateIn(GlobalScope),
serverPreferences.proxy().stateIn(GlobalScope),
serverPreferences.proxyHttpHost().stateIn(GlobalScope),
serverPreferences.proxyHttpPort().stateIn(GlobalScope),
serverPreferences.proxySocksHost().stateIn(GlobalScope),
serverPreferences.proxySocksPort().stateIn(GlobalScope),
) {
getHttpClient(
serverUrl = it[0] as Url,
proxy = it[1] as Proxy,
proxyHttpHost = it[2] as String,
proxyHttpPort = it[3] as Int,
proxySocksHost = it[4] as String,
proxySocksPort = it[5] as Int,
auth = Auth.NONE,
authUsername = "",
authPassword = "",
uiAccessTokenPreference = userPreferences.uiAccessToken(),
simpleSessionPreference = userPreferences.simpleSession(),
userRefreshUI = userRefreshUI,
json = json,
)
}.stateIn(
GlobalScope,
SharingStarted.Eagerly,
getHttpClient(
serverUrl = serverPreferences.serverUrl().get(),
proxy = serverPreferences.proxy().get(),
proxyHttpHost = serverPreferences.proxyHttpHost().get(),
proxyHttpPort = serverPreferences.proxyHttpPort().get(),
proxySocksHost = serverPreferences.proxySocksHost().get(),
proxySocksPort = serverPreferences.proxySocksPort().get(),
auth = Auth.NONE,
authUsername = "",
authPassword = "",
uiAccessTokenPreference = userPreferences.uiAccessToken(),
simpleSessionPreference = userPreferences.simpleSession(),
userRefreshUI = userRefreshUI,
json = json,
)
)

View File

@@ -15,4 +15,6 @@ enum class Auth {
NONE,
BASIC,
DIGEST,
SIMPLE,
UI,
}

View File

@@ -0,0 +1,38 @@
package ca.gosyer.jui.domain.user.interactor
import ca.gosyer.jui.domain.user.service.UserPreferences
import ca.gosyer.jui.domain.user.service.UserRepository
import com.diamondedge.logging.logging
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
@Inject
class UserLoginSimple(
private val userRepository: UserRepository,
private val userPreferences: UserPreferences,
) {
suspend fun await(
username: String,
password: String,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(username, password)
.catch {
onError(it)
log.warn(it) { "Failed to login with user $username" }
}
.singleOrNull()
fun asFlow(
username: String,
password: String,
) = userRepository.loginSimple(username, password)
.onEach {
userPreferences.simpleSession().set(it)
}
companion object {
private val log = logging()
}
}

View File

@@ -0,0 +1,39 @@
package ca.gosyer.jui.domain.user.interactor
import ca.gosyer.jui.domain.user.service.UserPreferences
import ca.gosyer.jui.domain.user.service.UserRepository
import com.diamondedge.logging.logging
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
@Inject
class UserLoginUI(
private val userRepository: UserRepository,
private val userPreferences: UserPreferences,
) {
suspend fun await(
username: String,
password: String,
onError: suspend (Throwable) -> Unit = {},
) = asFlow(username, password)
.catch {
onError(it)
log.warn(it) { "Failed to login with user $username" }
}
.singleOrNull()
fun asFlow(
username: String,
password: String,
) = userRepository.loginUI(username, password)
.onEach {
userPreferences.uiRefreshToken().set(it.refreshToken)
userPreferences.uiAccessToken().set(it.accessToken)
}
companion object {
private val log = logging()
}
}

View File

@@ -0,0 +1,33 @@
package ca.gosyer.jui.domain.user.interactor
import ca.gosyer.jui.domain.user.service.UserPreferences
import com.diamondedge.logging.logging
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
@Inject
class UserLogout(
private val userPreferences: UserPreferences,
) {
suspend fun await(
onError: suspend (Throwable) -> Unit = {},
) = asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to logout user" }
}
.singleOrNull()
fun asFlow() = flow {
userPreferences.uiRefreshToken().set("")
userPreferences.uiAccessToken().set("")
userPreferences.simpleSession().set("")
emit(Unit)
}
companion object {
private val log = logging()
}
}

View File

@@ -0,0 +1,33 @@
package ca.gosyer.jui.domain.user.interactor
import ca.gosyer.jui.domain.user.service.UserPreferences
import ca.gosyer.jui.domain.user.service.UserRepository
import com.diamondedge.logging.logging
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
@Inject
class UserRefreshUI(
private val userRepository: UserRepository,
private val userPreferences: UserPreferences,
) {
suspend fun await(
onError: suspend (Throwable) -> Unit = {},
) = asFlow()
.catch {
onError(it)
log.warn(it) { "Failed to refresh user" }
}
.singleOrNull()
fun asFlow() = userRepository.refreshUI(userPreferences.uiRefreshToken().get())
.onEach {
userPreferences.uiAccessToken().set(it)
}
companion object {
private val log = logging()
}
}

View File

@@ -0,0 +1,6 @@
package ca.gosyer.jui.domain.user.model
data class LoginData(
val refreshToken: String,
val accessToken: String,
)

View File

@@ -0,0 +1,14 @@
package ca.gosyer.jui.domain.user.service
import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore
class UserPreferences(
private val preferenceStore: PreferenceStore,
) {
fun uiRefreshToken(): Preference<String> = preferenceStore.getString("ui_refresh_token", "")
fun uiAccessToken(): Preference<String> = preferenceStore.getString("ui_refresh_token", "")
fun simpleSession(): Preference<String> = preferenceStore.getString("simple_session", "")
}

View File

@@ -0,0 +1,13 @@
package ca.gosyer.jui.domain.user.service
import ca.gosyer.jui.domain.user.model.LoginData
import kotlinx.coroutines.flow.Flow
interface UserRepository {
fun loginUI(username: String, password: String): Flow<LoginData>
fun refreshUI(refreshToken: String): Flow<String>
fun loginSimple(username: String, password: String): Flow<String>
}

View File

@@ -9,6 +9,7 @@ package ca.gosyer.jui.domain.server.service
import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore
import ca.gosyer.jui.domain.server.service.host.ServerHostPreference
import ca.gosyer.jui.domain.settings.model.AuthMode
actual class ServerHostPreferences actual constructor(
private val preferenceStore: PreferenceStore,
@@ -17,46 +18,48 @@ actual class ServerHostPreferences actual constructor(
// IP
private val ip = ServerHostPreference.IP(preferenceStore)
fun ip(): Preference<String> = ip.preference()
private val port = ServerHostPreference.Port(preferenceStore)
fun port(): Preference<Int> = port.preference()
// Root
private val rootPath = ServerHostPreference.RootPath(preferenceStore)
fun rootPath(): Preference<String> = rootPath.preference()
// Downloader
private val downloadPath = ServerHostPreference.DownloadPath(preferenceStore)
fun downloadPath(): Preference<String> = downloadPath.preference()
// Backup
private val backupPath = ServerHostPreference.BackupPath(preferenceStore)
fun backupPath(): Preference<String> = backupPath.preference()
// LocalSource
private val localSourcePath = ServerHostPreference.LocalSourcePath(preferenceStore)
fun localSourcePath(): Preference<String> = localSourcePath.preference()
// Authentication
private val basicAuthEnabled = ServerHostPreference.BasicAuthEnabled(preferenceStore)
@Deprecated("")
fun basicAuthEnabled(): Preference<Boolean> = basicAuthEnabled.preference()
private val basicAuthUsername = ServerHostPreference.BasicAuthUsername(preferenceStore)
@Deprecated("")
fun basicAuthUsername(): Preference<String> = basicAuthUsername.preference()
private val basicAuthPassword = ServerHostPreference.BasicAuthPassword(preferenceStore)
@Deprecated("")
fun basicAuthPassword(): Preference<String> = basicAuthPassword.preference()
// Authentication
private val authMode = ServerHostPreference.AuthMode(preferenceStore)
fun authMode(): Preference<AuthMode> = authMode.preference()
private val authUsername = ServerHostPreference.AuthUsername(preferenceStore)
fun authUsername(): Preference<String> = authUsername.preference()
private val authPassword = ServerHostPreference.AuthPassword(preferenceStore)
fun authPassword(): Preference<String> = authPassword.preference()
fun properties(): Array<String> =
listOf(
ip,
@@ -65,9 +68,9 @@ actual class ServerHostPreferences actual constructor(
downloadPath,
backupPath,
localSourcePath,
basicAuthEnabled,
basicAuthUsername,
basicAuthPassword,
authMode,
authUsername,
authPassword,
).mapNotNull {
it.getProperty()
}.plus(

View File

@@ -8,6 +8,8 @@ package ca.gosyer.jui.domain.server.service.host
import ca.gosyer.jui.core.prefs.Preference
import ca.gosyer.jui.core.prefs.PreferenceStore
import ca.gosyer.jui.core.prefs.getEnum
import ca.gosyer.jui.domain.settings.model.AuthMode as ServerAuthMode
sealed class ServerHostPreference<T : Any> {
protected abstract val propertyName: String
@@ -63,6 +65,16 @@ sealed class ServerHostPreference<T : Any> {
override fun preference(): Preference<Boolean> = preferenceStore.getBoolean(propertyName, defaultValue)
}
sealed class ObjectServerHostPreference<T : Any>(
override val preferenceStore: PreferenceStore,
override val propertyName: String,
override val defaultValue: T,
override val serverValue: T = defaultValue,
private val getObject: (String, T) -> Preference<T>,
) : ServerHostPreference<T>() {
override fun preference(): Preference<T> = getObject(propertyName, defaultValue)
}
// Root
class RootPath(
preferenceStore: PreferenceStore,
@@ -116,27 +128,43 @@ sealed class ServerHostPreference<T : Any> {
)
// Authentication
@Deprecated("UseAuthMode")
class BasicAuthEnabled(
preferenceStore: PreferenceStore,
) : BooleanServerHostPreference(
preferenceStore,
"basicAuthEnabled",
false,
)
) : BooleanServerHostPreference(preferenceStore, "basicAuthEnabled", false)
@Deprecated("UseAuthUsername")
class BasicAuthUsername(
preferenceStore: PreferenceStore,
) : StringServerHostPreference(
preferenceStore,
"basicAuthUsername",
"",
)
) : StringServerHostPreference(preferenceStore, "basicAuthUsername", "")
@Deprecated("UseAuthPassword")
class BasicAuthPassword(
preferenceStore: PreferenceStore,
) : StringServerHostPreference(preferenceStore, "basicAuthPassword", "")
class AuthMode(
preferenceStore: PreferenceStore,
) : ObjectServerHostPreference<ServerAuthMode>(
preferenceStore,
"authMode",
ServerAuthMode.NONE,
getObject = { propertyName, default ->
preferenceStore.getEnum(propertyName, default)
}
)
class AuthUsername(
preferenceStore: PreferenceStore,
) : StringServerHostPreference(
preferenceStore,
"basicAuthPassword",
"",
)
preferenceStore,
"authUsername",
"",
)
class AuthPassword(
preferenceStore: PreferenceStore,
) : StringServerHostPreference(
preferenceStore,
"authPassword",
"",
)
}