#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
+258 -154

No files matched your search

+2 -2
View File
@@ -1,3 +1,3 @@
#auto generated
#Thu Feb 05 01:16:28 CET 2026
build.number=190
#Fri Feb 06 16:36:58 CET 2026
build.number=212
@@ -4,13 +4,13 @@ import net.keinekekse.version.Version
object VERSION: Version {
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 minor = 0
override val patch = 0
override val preRelease = ""
override val buildMetadata = "build.190"
override val buildDate = "2026-02-05T00:16:28.206204Z"
override val buildMetadata = "build.212"
override val buildDate = "2026-02-06T15:36:58.060625Z"
override val displayName = "Books App"
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
override val name = "books"
@@ -1,6 +1,7 @@
package net.keinekekse.app.auth
import kotlinx.coroutines.CompletableDeferred
import net.keinekekse.app.config.ConfigProperties
import org.koin.core.annotation.Single
import java.security.MessageDigest
import java.security.SecureRandom
@@ -18,13 +19,13 @@ sealed interface AuthResult {
}
@Single
fun createKeycloakConfig(): KeycloakConfig {
fun createKeycloakConfig(config: ConfigProperties): KeycloakConfig {
// TODO muss aus config geholt werden (<--> DI??)
val keycloakServerHostname = "kec.keinekekse.net"
val keycloakRealm = "app-server-realm"
val keycloakClient = "pkce-book-app"
val keycloakServerHostname = config["keycloakServer"] ?: ""
val keycloakRealm = config["keycloakRealm"] ?: ""
val keycloakClient = config["keycloakClient"] ?: ""
return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
}
@@ -1,5 +1,6 @@
package net.keinekekse.app.auth
import com.auth0.jwt.JWT
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
@@ -18,36 +19,69 @@ import io.ktor.utils.io.core.Closeable
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.keinekekse.app.service.TokenService
import org.koin.core.annotation.Single
import java.awt.Desktop
import java.net.Socket
import java.util.*
import kotlin.use
@Single
class Authenticator(
// @Property("kc_server") keycloakServerHostname: String,
// @Property("kc_realm") keycloakRealm: String,
// @Property("kc_client") keycloakClient: String,
val tokenService: TokenService,
private val keycloakConfig: KeycloakConfig,
) : Closeable {
private val httpClient = HttpClient {
install(ContentNegotiation) { json() }
install(ContentNegotiation) {
json(
Json { ignoreUnknownKeys = true }
)
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
// private val keycloakConfig =
// KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
override fun close() = httpClient.close()
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)) {
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
}
@@ -58,7 +92,7 @@ class Authenticator(
config.buildRedirectUri(port)
if (!waitForServer(port)) {
return AuthResult.Fatal(
"ERR008",
"ERR009",
"Schwerer Fehler",
"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? {
val config = AuthRun(keycloakConfig)
@@ -103,10 +145,36 @@ class Authenticator(
else -> {
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
}
}
}
}
private fun isPortOpen(port: Int): Boolean =
try {
@@ -316,10 +384,10 @@ class Authenticator(
try {
val errorResponse = response.body<TokenErrorResponse>()
return Pair(
AuthResult.Error(
"ERR006",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
"${errorResponse.error}: ${errorResponse.errorDescription ?: ""}"
AuthResult.Fatal(
"ERR010",
errorResponse.error,
"${errorResponse.errorDescription} (${response.status})"
), null
)
} catch (e: Exception) {
@@ -327,7 +395,7 @@ class Authenticator(
val errorResponse = response.bodyAsText()
return Pair(
AuthResult.Error(
"ERR006",
"ERR007",
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
errorResponse
), null
@@ -340,7 +408,7 @@ class Authenticator(
// Fehler beim JSON-Konvertieren
return Pair(
AuthResult.Fatal(
"ERR007",
"ERR008",
"Schwerer Fehler",
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
), null
@@ -1,28 +1,9 @@
package net.keinekekse.app
//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.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.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
//@PropertyValue("kc_server")
@@ -38,126 +19,118 @@ fun main() = application {
startKoin<KoinApp>()
val config: ConfigProperties = koinInject()
val settings: Settings = koinInject()
// val config: ConfigProperties = koinInject()
// val settings: Settings = koinInject()
// val settings: Settings = PreferencesSettings(delegate)
//
// settings.put("kc_server", "kec.keinekekse.net")
println("Properties: ${config["keycloakServer"]}")
println("Settings: ${settings.get("kc_server", "default")}")
// println(Preferences.userRoot())
// println("System properties:")
// System.getProperties().forEach { println("${it.key}=${it.value}") }
// println("")
// keycloakServer = "kec.keinekekse.net"
// keycloakRealm = "app-server-realm"
// keycloakClient = "pkce-book-app"
// settings.putInt("intValue", 42)
// val sett = settings.export()
// settings.putInt("intValue", 4242)
// println("Properties: ${config["keycloakServer"]}")
// println("Settings: ${settings.get("kc_server", "default")}")
// println(settings.export())
// settings.import(sett)
// println("Settings: ${settings.getInt("intValue", -1)}")
// 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) {
// TODO - (~) Verständnis für App-Aufbau und DI
// - (x) das object muss global angelegt werden -> DI singleton
// - (x) globale Speicherung der Tokens
// - Tokens persistieren? Multiplatform! (evtl. library)
// - (x) globaler httpClient mit Konfig für Tokens -> DI
// - (x) Error-Handling: 401 -> refresh -> 401 -> reauth [implizit]
// Authenticator(
// "kec.keinekekse.net",
// "app-server-realm",
// "pkce-book-app",
// ).use {
val result = authenticator.authenticate()
when (result) {
is AuthResult.Success -> {
// println("Access Token: ${result.tokens?.accessToken}")
// println("Refresh Token: ${result.tokens?.refreshToken}")
// delay(3000)
/*
// klappt, gibt aber nicht mehr her, als ohnehin im Token steht
it.requestUserInfo(result.tokens!!.accessToken)
*/
/*
// funktioniert -> jetzt fehlt die DI-Mimik für die compose app
when (val res = it.requestRefreshToken(result.tokens)) {
is AuthResult.Success -> {
println("Access Token: ${res.tokens?.accessToken}")
println("Refresh Token: ${res.tokens?.refreshToken}")
}
is AuthResult.Error -> {
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
}
is AuthResult.Fatal -> {
println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
}
}
*/
}
else -> {}
}
result
// LaunchedEffect(Unit) {
// authResultState.value = withContext(Dispatchers.IO) {
// // TODO - (~) Verständnis für App-Aufbau und DI
// // - (x) das object muss global angelegt werden -> DI singleton
// // - (x) globale Speicherung der Tokens
// // - Tokens persistieren? Multiplatform! (evtl. library)
// // - (x) globaler httpClient mit Konfig für Tokens -> DI
// // - (x) Error-Handling: 401 -> refresh -> 401 -> reauth [implizit]
//
// val result = authenticator.authenticate()
//
// when (result) {
// is AuthResult.Success -> {
//// println("Access Token: ${result.tokens?.accessToken}")
//// println("Refresh Token: ${result.tokens?.refreshToken}")
//
//// delay(3000)
// /*
// // klappt, gibt aber nicht mehr her, als ohnehin im Token steht
// it.requestUserInfo(result.tokens!!.accessToken)
// */
// /*
// // funktioniert -> jetzt fehlt die DI-Mimik für die compose app
// when (val res = it.requestRefreshToken(result.tokens)) {
// is AuthResult.Success -> {
// println("Access Token: ${res.tokens?.accessToken}")
// println("Refresh Token: ${res.tokens?.refreshToken}")
// }
//
// is AuthResult.Error -> {
// println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
// }
//
// is AuthResult.Fatal -> {
// println("Refresh Token fehlgeschlagen ${res.code}: ${res.details ?: ""}")
//
// }
// }
// */
// }
//
// else -> {}
// }
//
// result
// // }
// }
// }
}
}
Window(
onCloseRequest = ::exitApplication,
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()
}
}
// 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
import com.auth0.jwt.JWT
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.*
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 org.koin.core.annotation.Single
import java.util.*
@OptIn(ExperimentalSerializationApi::class)
@Single
fun createHttpClient(authenticator: Authenticator) = HttpClient {
fun createHttpClient(authenticator: Authenticator): HttpClient {
val client = HttpClient {
install(Auth) {
bearer {
loadTokens {
println("HTTPClient: loadTokens")
// tokenService.getTokens()
authenticator.tokenService.getTokens()
}
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.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 {
private var tokens: BearerTokens? = null
// TODO persist tokens
fun getTokens(): BearerTokens? = tokens
fun setTokens(tokens: BearerTokens? = null) {
this.tokens = tokens
println("set tokens")
+1
View File
@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler) // sonst fkt. Composable-Import nicht
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.koin.compiler)
}