#1 Setings ist erledigt

This commit is contained in:
Hartmut Bauer committed 2026-02-06 16:40:40 +01:00
1 parent 632d7c34ef
commit 698490a6f8
8 files changed
+260 -156

No files matched your search

+2 -2
View File
@@ -1,3 +1,3 @@
#auto generated #auto generated
#Thu Feb 05 01:16:28 CET 2026 #Fri Feb 06 16:36:58 CET 2026
build.number=190 build.number=212
@@ -4,13 +4,13 @@ import net.keinekekse.version.Version
object VERSION: Version { object VERSION: Version {
override val version = "1.0.0" override val version = "1.0.0"
override val fullVersion = "1.0.0+build.190" override val fullVersion = "1.0.0+build.212"
override val major = 1 override val major = 1
override val minor = 0 override val minor = 0
override val patch = 0 override val patch = 0
override val preRelease = "" override val preRelease = ""
override val buildMetadata = "build.190" override val buildMetadata = "build.212"
override val buildDate = "2026-02-05T00:16:28.206204Z" override val buildDate = "2026-02-06T15:36:58.060625Z"
override val displayName = "Books App" override val displayName = "Books App"
override val copyright = "Copyright (c) 2026 Hartmut Bauer" override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "books" override val name = "books"
@@ -1,6 +1,7 @@
package net.keinekekse.app.auth package net.keinekekse.app.auth
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import net.keinekekse.app.config.ConfigProperties
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
@@ -18,13 +19,13 @@ sealed interface AuthResult {
} }
@Single @Single
fun createKeycloakConfig(): KeycloakConfig { fun createKeycloakConfig(config: ConfigProperties): KeycloakConfig {
// TODO muss aus config geholt werden (<--> DI??) // TODO muss aus config geholt werden (<--> DI??)
val keycloakServerHostname = "kec.keinekekse.net" val keycloakServerHostname = config["keycloakServer"] ?: ""
val keycloakRealm = "app-server-realm" val keycloakRealm = config["keycloakRealm"] ?: ""
val keycloakClient = "pkce-book-app" val keycloakClient = config["keycloakClient"] ?: ""
return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient) return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
} }
@@ -1,5 +1,6 @@
package net.keinekekse.app.auth package net.keinekekse.app.auth
import com.auth0.jwt.JWT
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.plugins.* import io.ktor.client.plugins.*
@@ -18,36 +19,69 @@ import io.ktor.utils.io.core.Closeable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.keinekekse.app.service.TokenService import net.keinekekse.app.service.TokenService
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import java.awt.Desktop import java.awt.Desktop
import java.net.Socket import java.net.Socket
import java.util.*
import kotlin.use import kotlin.use
@Single @Single
class Authenticator( class Authenticator(
// @Property("kc_server") keycloakServerHostname: String,
// @Property("kc_realm") keycloakRealm: String,
// @Property("kc_client") keycloakClient: String,
val tokenService: TokenService, val tokenService: TokenService,
private val keycloakConfig: KeycloakConfig, private val keycloakConfig: KeycloakConfig,
) : Closeable {
) : Closeable {
private val httpClient = HttpClient { private val httpClient = HttpClient {
install(ContentNegotiation) { json() } install(ContentNegotiation) {
json(
Json { ignoreUnknownKeys = true }
)
}
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 10000 requestTimeoutMillis = 10000
connectTimeoutMillis = 5000 connectTimeoutMillis = 5000
} }
} }
// private val keycloakConfig =
// KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
override fun close() = httpClient.close() override fun close() = httpClient.close()
suspend fun authenticate(): AuthResult { suspend fun authenticate(): AuthResult {
val config = AuthRun(keycloakConfig) // check if necessary
val tokens = tokenService.getTokens()
if (tokens != null) {
val access = JWT.decode(tokens.accessToken)
val refresh = JWT.decode(tokens.refreshToken)
val now = Date()
println("Now: $now")
println("Token expires at: ${access.expiresAt}")
println("Token is expired: ${access.expiresAt.before(Date())}")
println("Refresh token expires at: ${refresh.expiresAt}")
println("Refresh token is expired: ${refresh.expiresAt.before(Date())}")
if (access.expiresAt.before(now)) {
// access_token ungültig
if (refresh.expiresAt.before(now)) {
// refresh_token ungültig
return doAuthentication()
} else {
val result = requestRefreshToken(tokens)
if (result != null) {
return AuthResult.Success()
} else {
return doAuthentication()
}
}
} else {
return AuthResult.Success()
}
} else {
return doAuthentication()
}
}
private suspend fun doAuthentication(): AuthResult {
val config = AuthRun(keycloakConfig)
if (!testServerConnection(config)) { if (!testServerConnection(config)) {
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "") return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
} }
@@ -58,7 +92,7 @@ class Authenticator(
config.buildRedirectUri(port) config.buildRedirectUri(port)
if (!waitForServer(port)) { if (!waitForServer(port)) {
return AuthResult.Fatal( return AuthResult.Fatal(
"ERR008", "ERR009",
"Schwerer Fehler", "Schwerer Fehler",
"Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich." "Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich."
) )
@@ -79,6 +113,14 @@ class Authenticator(
} }
} }
suspend fun reAuthenticate(): BearerTokens? {
val authResult = authenticate()
if (authResult is AuthResult.Success) {
return tokenService.getTokens()
}
return null
}
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? { suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
val config = AuthRun(keycloakConfig) val config = AuthRun(keycloakConfig)
@@ -103,10 +145,36 @@ class Authenticator(
else -> { else -> {
tokenService.setTokens(null) tokenService.setTokens(null)
println("Error refreshing tokens: ")
var error = ""
var errorDetails = ""
when (val err = result.first) {
is AuthResult.Fatal -> {
println(err.code)
error = err.message ?: ""
errorDetails = err.details ?: ""
}
is AuthResult.Error -> {
println(err.code)
error = err.message ?: ""
errorDetails = err.details ?: ""
}
is AuthResult.Success -> println("Success ???")
}
println("Status: ${response.status}")
println("Error: $error")
println("Description: $errorDetails")
if (error == "invalid_grant") {
// session expired || token is invalid
return reAuthenticate()
} else {
return null return null
} }
} }
} }
}
private fun isPortOpen(port: Int): Boolean = private fun isPortOpen(port: Int): Boolean =
try { try {
@@ -316,10 +384,10 @@ class Authenticator(
try { try {
val errorResponse = response.body<TokenErrorResponse>() val errorResponse = response.body<TokenErrorResponse>()
return Pair( return Pair(
AuthResult.Error( AuthResult.Fatal(
"ERR006", "ERR010",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken", errorResponse.error,
"${errorResponse.error}: ${errorResponse.errorDescription ?: ""}" "${errorResponse.errorDescription} (${response.status})"
), null ), null
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -327,7 +395,7 @@ class Authenticator(
val errorResponse = response.bodyAsText() val errorResponse = response.bodyAsText()
return Pair( return Pair(
AuthResult.Error( AuthResult.Error(
"ERR006", "ERR007",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken", "HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
errorResponse errorResponse
), null ), null
@@ -340,7 +408,7 @@ class Authenticator(
// Fehler beim JSON-Konvertieren // Fehler beim JSON-Konvertieren
return Pair( return Pair(
AuthResult.Fatal( AuthResult.Fatal(
"ERR007", "ERR008",
"Schwerer Fehler", "Schwerer Fehler",
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware." "Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
), null ), null
@@ -1,28 +1,9 @@
package net.keinekekse.app package net.keinekekse.app
//import org.koin.plugin.module.dsl.startKoin //import org.koin.plugin.module.dsl.startKoin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.keinekekse.app.auth.AuthResult
import net.keinekekse.app.auth.Authenticator
import net.keinekekse.app.books.di.KoinApp import net.keinekekse.app.books.di.KoinApp
import net.keinekekse.app.config.ConfigProperties
import net.keinekekse.app.config.Settings
import net.keinekekse.app.ui.ProgressIndicator
import org.koin.compose.koinInject
import org.koin.plugin.module.dsl.startKoin import org.koin.plugin.module.dsl.startKoin
//@PropertyValue("kc_server") //@PropertyValue("kc_server")
@@ -38,126 +19,118 @@ fun main() = application {
startKoin<KoinApp>() startKoin<KoinApp>()
val config: ConfigProperties = koinInject() // val config: ConfigProperties = koinInject()
val settings: Settings = koinInject() // val settings: Settings = koinInject()
// val settings: Settings = PreferencesSettings(delegate) // val settings: Settings = PreferencesSettings(delegate)
// //
// settings.put("kc_server", "kec.keinekekse.net") // settings.putInt("intValue", 42)
println("Properties: ${config["keycloakServer"]}") // val sett = settings.export()
println("Settings: ${settings.get("kc_server", "default")}") // settings.putInt("intValue", 4242)
// println(Preferences.userRoot()) // println("Properties: ${config["keycloakServer"]}")
// println("Settings: ${settings.get("kc_server", "default")}")
// println("System properties:") // println(settings.export())
// System.getProperties().forEach { println("${it.key}=${it.value}") } // settings.import(sett)
// println("") // println("Settings: ${settings.getInt("intValue", -1)}")
// keycloakServer = "kec.keinekekse.net"
// keycloakRealm = "app-server-realm"
// keycloakClient = "pkce-book-app"
// val inj = koinInject<String>(named("AppString")) // val inj = koinInject<String>(named("AppString"))
val authResultState = remember { mutableStateOf<AuthResult?>(null) } // val authResultState = remember { mutableStateOf<AuthResult?>(null) }
//
// val authenticator: Authenticator = koinInject()
val authenticator: Authenticator = koinInject() // LaunchedEffect(Unit) {
// authResultState.value = withContext(Dispatchers.IO) {
LaunchedEffect(Unit) { // // TODO - (~) Verständnis für App-Aufbau und DI
authResultState.value = withContext(Dispatchers.IO) { // // - (x) das object muss global angelegt werden -> DI singleton
// TODO - (~) Verständnis für App-Aufbau und DI // // - (x) globale Speicherung der Tokens
// - (x) das object muss global angelegt werden -> DI singleton // // - Tokens persistieren? Multiplatform! (evtl. library)
// - (x) globale Speicherung der Tokens // // - (x) globaler httpClient mit Konfig für Tokens -> DI
// - Tokens persistieren? Multiplatform! (evtl. library) // // - (x) Error-Handling: 401 -> refresh -> 401 -> reauth [implizit]
// - (x) globaler httpClient mit Konfig für Tokens -> DI //
// - (x) Error-Handling: 401 -> refresh -> 401 -> reauth [implizit] // val result = authenticator.authenticate()
// Authenticator( //
// "kec.keinekekse.net", // when (result) {
// "app-server-realm", // is AuthResult.Success -> {
// "pkce-book-app", //// println("Access Token: ${result.tokens?.accessToken}")
// ).use { //// println("Refresh Token: ${result.tokens?.refreshToken}")
//
val result = authenticator.authenticate() //// delay(3000)
// /*
when (result) { // // klappt, gibt aber nicht mehr her, als ohnehin im Token steht
is AuthResult.Success -> { // it.requestUserInfo(result.tokens!!.accessToken)
// println("Access Token: ${result.tokens?.accessToken}") // */
// println("Refresh Token: ${result.tokens?.refreshToken}") // /*
// // funktioniert -> jetzt fehlt die DI-Mimik für die compose app
// delay(3000) // when (val res = it.requestRefreshToken(result.tokens)) {
/* // is AuthResult.Success -> {
// klappt, gibt aber nicht mehr her, als ohnehin im Token steht // println("Access Token: ${res.tokens?.accessToken}")
it.requestUserInfo(result.tokens!!.accessToken) // println("Refresh Token: ${res.tokens?.refreshToken}")
*/ // }
/* //
// funktioniert -> jetzt fehlt die DI-Mimik für die compose app // is AuthResult.Error -> {
when (val res = it.requestRefreshToken(result.tokens)) { // println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
is AuthResult.Success -> { // }
println("Access Token: ${res.tokens?.accessToken}") //
println("Refresh Token: ${res.tokens?.refreshToken}") // is AuthResult.Fatal -> {
} // println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
//
is AuthResult.Error -> { // }
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}") // }
} // */
// }
is AuthResult.Fatal -> { //
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}") // else -> {}
// }
} //
} // result
*/ // // }
} // }
// }
else -> {}
}
result
// }
}
}
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "app", title = "app",
) { ) {
when (val result = authResultState.value) {
null -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Warten auf Anmeldung...")
// Icon(
// imageVector = Icons.Default.HourglassTop,
// contentDescription = "Home",
// modifier = Modifier.padding(top = 16.dp).size(32.dp)
// )
ProgressIndicator()
}
}
is AuthResult.Error -> {
// TODO show an error UI (and maybe a retry button)
Text("Fehler bei der Anmeldung: ${result.message} (${result.code})")
Text("Fehlerbeschreibung: ${result.details ?: ""}")
// App()
}
is AuthResult.Fatal -> {
Text("Fataler Fehler bei der Anmeldung: ${result.message} (${result.code})")
Text("Fehlerbeschreibung: ${result.details ?: ""}")
}
is AuthResult.Success -> {
val test = koinInject<TestAuth>()
CoroutineScope(Dispatchers.Default).launch {
println("requesting '/'")
test.request("/")
}
App() App()
} // when (val result = authResultState.value) {
} // null -> {
// Column(
// modifier = Modifier.fillMaxSize(),
// horizontalAlignment = Alignment.CenterHorizontally,
// verticalArrangement = Arrangement.Center
// ) {
// Text("Warten auf Anmeldung...")
//
//// Icon(
//// imageVector = Icons.Default.HourglassTop,
//// contentDescription = "Home",
//// modifier = Modifier.padding(top = 16.dp).size(32.dp)
//// )
// ProgressIndicator()
// }
// }
//
// is AuthResult.Error -> {
// // TODO show an error UI (and maybe a retry button)
// Text("Fehler bei der Anmeldung: ${result.message} (${result.code})")
// Text("Fehlerbeschreibung: ${result.details ?: ""}")
//// App()
// }
//
// is AuthResult.Fatal -> {
// Text("Fataler Fehler bei der Anmeldung: ${result.message} (${result.code})")
// Text("Fehlerbeschreibung: ${result.details ?: ""}")
// }
//
// is AuthResult.Success -> {
// val test = koinInject<TestAuth>()
// CoroutineScope(Dispatchers.Default).launch {
// println("requesting '/'")
// test.request("/")
// }
// App()
// }
// }
} }
} }
// //
@@ -1,22 +1,84 @@
package net.keinekekse.app.service package net.keinekekse.app.service
import com.auth0.jwt.JWT
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.* import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.* import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import net.keinekekse.app.auth.Authenticator import net.keinekekse.app.auth.Authenticator
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import java.util.*
@OptIn(ExperimentalSerializationApi::class)
@Single @Single
fun createHttpClient(authenticator: Authenticator) = HttpClient { fun createHttpClient(authenticator: Authenticator): HttpClient {
val client = HttpClient {
install(Auth) { install(Auth) {
bearer { bearer {
loadTokens { loadTokens {
println("HTTPClient: loadTokens")
// tokenService.getTokens()
authenticator.tokenService.getTokens() authenticator.tokenService.getTokens()
} }
refreshTokens { refreshTokens {
println("HTTPClient: refreshTokens")
// val tokens = authenticator.tokenService.getTokens()
if (oldTokens != null) {
val access = JWT.decode(oldTokens?.accessToken)
val refresh = JWT.decode(oldTokens?.refreshToken)
val now = Date()
println("Now: $now")
println("Token expires at: ${access.expiresAt}")
println("Token is expired: ${access.expiresAt.before(Date())}")
println("Refresh token expires at: ${refresh.expiresAt}")
println("Refresh token is expired: ${refresh.expiresAt.before(Date())}")
if (refresh.expiresAt.before(now)) {
println("refresh token expired - HOW TO authenticate")
authenticator.reAuthenticate()
} else {
println("refresh token valid - request new tokens")
authenticator.requestRefreshToken(oldTokens) authenticator.requestRefreshToken(oldTokens)
authenticator.tokenService.getTokens() }
} else {
println("no tokens - authenticate")
authenticator.reAuthenticate()
} }
} }
} }
}
install(ContentNegotiation) {
json(Json {
prettyPrint = true
allowTrailingComma = true
ignoreUnknownKeys = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
client.plugin(HttpSend).intercept { request ->
println("HTTPClient: intercept ${request.url}")
val originalCall = execute(request)
println("HTTPClient: response status ${originalCall.response.status}")
originalCall
// val originalCall = execute(request)
// if (originalCall.response.status.value !in 100..399) {
// execute(request)
// } else {
// originalCall
// }
}
return client
} }
@@ -7,9 +7,8 @@ import org.koin.core.annotation.Single
class TokenService { class TokenService {
private var tokens: BearerTokens? = null private var tokens: BearerTokens? = null
// TODO persist tokens
fun getTokens(): BearerTokens? = tokens fun getTokens(): BearerTokens? = tokens
fun setTokens(tokens: BearerTokens? = null) { fun setTokens(tokens: BearerTokens? = null) {
this.tokens = tokens this.tokens = tokens
println("set tokens") println("set tokens")
+1
View File
@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler) // sonst fkt. Composable-Import nicht alias(libs.plugins.composeCompiler) // sonst fkt. Composable-Import nicht
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.koin.compiler) alias(libs.plugins.koin.compiler)
} }