diff --git a/books/buildnumber.properties b/books/buildnumber.properties index 9da6d61..67a0475 100644 --- a/books/buildnumber.properties +++ b/books/buildnumber.properties @@ -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 diff --git a/books/src/commonMain/kotlin/net/keinekekse/app/books/Version.kt b/books/src/commonMain/kotlin/net/keinekekse/app/books/Version.kt index c972816..0c5d63b 100644 --- a/books/src/commonMain/kotlin/net/keinekekse/app/books/Version.kt +++ b/books/src/commonMain/kotlin/net/keinekekse/app/books/Version.kt @@ -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" diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.kt b/books/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.kt index fa252f1..c6bb65c 100644 --- a/books/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.kt +++ b/books/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.kt @@ -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) } diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/auth/Authenticator.kt b/books/src/jvmMain/kotlin/net/keinekekse/app/auth/Authenticator.kt index a5155b8..d740367 100644 --- a/books/src/jvmMain/kotlin/net/keinekekse/app/auth/Authenticator.kt +++ b/books/src/jvmMain/kotlin/net/keinekekse/app/auth/Authenticator.kt @@ -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 { + + ) : 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,7 +145,33 @@ class Authenticator( else -> { tokenService.setTokens(null) - return 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 + } } } } @@ -316,10 +384,10 @@ class Authenticator( try { val errorResponse = response.body() 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 diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/main.kt b/books/src/jvmMain/kotlin/net/keinekekse/app/main.kt index 9594205..0932d52 100644 --- a/books/src/jvmMain/kotlin/net/keinekekse/app/main.kt +++ b/books/src/jvmMain/kotlin/net/keinekekse/app/main.kt @@ -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() - 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(named("AppString")) - val authResultState = remember { mutableStateOf(null) } +// val authResultState = remember { mutableStateOf(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() +// 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() +// CoroutineScope(Dispatchers.Default).launch { +// println("requesting '/'") +// test.request("/") +// } // App() - } - - is AuthResult.Fatal -> { - Text("Fataler Fehler bei der Anmeldung: ${result.message} (${result.code})") - Text("Fehlerbeschreibung: ${result.details ?: ""}") - } - - is AuthResult.Success -> { - val test = koinInject() - CoroutineScope(Dispatchers.Default).launch { - println("requesting '/'") - test.request("/") - } - App() - } - } +// } +// } } } // diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/service/HttpClient.kt b/books/src/jvmMain/kotlin/net/keinekekse/app/service/HttpClient.kt index ded4568..761c66a 100644 --- a/books/src/jvmMain/kotlin/net/keinekekse/app/service/HttpClient.kt +++ b/books/src/jvmMain/kotlin/net/keinekekse/app/service/HttpClient.kt @@ -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 { - install(Auth) { - bearer { - loadTokens { - authenticator.tokenService.getTokens() - } - refreshTokens { - authenticator.requestRefreshToken(oldTokens) - authenticator.tokenService.getTokens() +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) + } + } 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 } diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/service/TokenService.kt b/books/src/jvmMain/kotlin/net/keinekekse/app/service/TokenService.kt index 9cc7672..3a4a7d6 100644 --- a/books/src/jvmMain/kotlin/net/keinekekse/app/service/TokenService.kt +++ b/books/src/jvmMain/kotlin/net/keinekekse/app/service/TokenService.kt @@ -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") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index c2fe2cd..4ae0b12 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) }