From 9b573243ef10e7321af49b3b6b8b896bb4fcbac0 Mon Sep 17 00:00:00 2001 From: Hartmut Bauer Date: Thu, 12 Feb 2026 03:18:54 +0100 Subject: [PATCH] =?UTF-8?q?Authentication=20aufr=C3=A4umen,=202.=20Schritt?= =?UTF-8?q?=20Umzug=20nach=20:shared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- books/buildnumber.properties | 4 +- .../kotlin/net/keinekekse/app/App.kt | 20 ++ .../net/keinekekse/app/books/Version.kt | 6 +- .../net/keinekekse/app/books/di/KoinApp.kt | 3 +- .../net/keinekekse/app/auth/AuthModule.kt | 84 ------ shared/build.gradle.kts | 15 + .../net/keinekekse/app/auth/AuthModule.kt | 125 ++++++++ .../net/keinekekse/app/auth/Authenticator.kt | 284 ++++++------------ .../net/keinekekse/app/di/KoinModule.kt | 10 + .../net/keinekekse/app/service/HttpClient.kt | 24 +- .../keinekekse/app/service/TokenService.kt | 0 .../net/keinekekse/app/auth/AuthModule.jvm.kt | 33 ++ .../keinekekse/app/auth/AuthModule.macos.kt | 15 + .../keinekekse/app/auth/AuthModule.wasmJs.kt | 15 + 14 files changed, 335 insertions(+), 303 deletions(-) delete mode 100644 books/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.kt create mode 100644 shared/src/commonMain/kotlin/net/keinekekse/app/auth/AuthModule.kt rename {books/src/jvmMain => shared/src/commonMain}/kotlin/net/keinekekse/app/auth/Authenticator.kt (60%) create mode 100644 shared/src/commonMain/kotlin/net/keinekekse/app/di/KoinModule.kt rename {books/src/jvmMain => shared/src/commonMain}/kotlin/net/keinekekse/app/service/HttpClient.kt (61%) rename {books/src/jvmMain => shared/src/commonMain}/kotlin/net/keinekekse/app/service/TokenService.kt (100%) create mode 100644 shared/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.jvm.kt create mode 100644 shared/src/macosMain/kotlin/net/keinekekse/app/auth/AuthModule.macos.kt create mode 100644 shared/src/wasmJsMain/kotlin/net/keinekekse/app/auth/AuthModule.wasmJs.kt diff --git a/books/buildnumber.properties b/books/buildnumber.properties index 54ce895..2232086 100644 --- a/books/buildnumber.properties +++ b/books/buildnumber.properties @@ -1,3 +1,3 @@ #auto generated -#Wed Feb 11 01:28:34 CET 2026 -build.number=233 +#Thu Feb 12 03:10:51 CET 2026 +build.number=238 diff --git a/books/src/commonMain/kotlin/net/keinekekse/app/App.kt b/books/src/commonMain/kotlin/net/keinekekse/app/App.kt index d1585cd..c6cd4e6 100644 --- a/books/src/commonMain/kotlin/net/keinekekse/app/App.kt +++ b/books/src/commonMain/kotlin/net/keinekekse/app/App.kt @@ -21,6 +21,8 @@ import io.ktor.client.statement.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import net.keinekekse.app.auth.AuthResult +import net.keinekekse.app.auth.Authenticator import net.keinekekse.app.books.generated.resources.Res import net.keinekekse.app.books.generated.resources.compose_multiplatform import org.jetbrains.compose.resources.painterResource @@ -40,6 +42,7 @@ fun App() { // AppTheme { */ val test = koinInject() + val authenticator = koinInject() var showContent by remember { mutableStateOf(false) } Column( modifier = Modifier @@ -71,6 +74,23 @@ fun App() { Text("Compose: $greeting") } } + // refresh fkt. nicht! + when (authenticator.logonState.value) { + is AuthResult.Success -> { + Text("Anmeldung erfolgreich") + } + + is AuthResult.None -> { + Text("Keine Anmeldung") + } + + is AuthResult.Error -> { + val error = authenticator.logonState.value as AuthResult.Error + Text("Fehler bei der Anmeldung: ") + Text("${error.message} (${error.code})") + Text(error.details ?: "") + } + } } } } 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 ef03af0..94512de 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.233" + override val fullVersion = "1.0.0+build.238" override val major = 1 override val minor = 0 override val patch = 0 override val preRelease = "" - override val buildMetadata = "build.233" - override val buildDate = "2026-02-11T00:28:34.345856Z" + override val buildMetadata = "build.238" + override val buildDate = "2026-02-12T02:10:51.539449Z" override val displayName = "Books App" override val copyright = "Copyright (c) 2026 Hartmut Bauer" override val name = "books" diff --git a/books/src/commonMain/kotlin/net/keinekekse/app/books/di/KoinApp.kt b/books/src/commonMain/kotlin/net/keinekekse/app/books/di/KoinApp.kt index 4f373cf..6a87e5c 100644 --- a/books/src/commonMain/kotlin/net/keinekekse/app/books/di/KoinApp.kt +++ b/books/src/commonMain/kotlin/net/keinekekse/app/books/di/KoinApp.kt @@ -1,6 +1,7 @@ package net.keinekekse.app.books.di +import net.keinekekse.app.di.KoinModulesShared import org.koin.core.annotation.KoinApplication -@KoinApplication +@KoinApplication(modules = [KoinModules::class, KoinModulesShared::class]) object KoinApp diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.kt b/books/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.kt deleted file mode 100644 index 3fd9866..0000000 --- a/books/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.kt +++ /dev/null @@ -1,84 +0,0 @@ -package net.keinekekse.app.auth - -import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.coroutines.CompletableDeferred -import net.keinekekse.app.config.ConfigProperties -import org.koin.core.annotation.Single -import java.security.MessageDigest -import java.security.SecureRandom -import java.util.* - -private val logger = KotlinLogging.logger {} - -sealed interface AuthResult { - class Success : AuthResult - - // data class Success(val tokens: BearerTokens) : AuthResult - data class Error(val code: String, val message: String, val details: String? = null) : - AuthResult - -// data class Fatal(val code: String, val message: String, val details: String? = null) : -// AuthResult -} - -@Single -fun createKeycloakConfig(config: ConfigProperties): KeycloakConfig { - - // TODO muss aus config geholt werden (<--> DI??) - - val keycloakServerHostname = config["keycloakServer"] ?: "" - val keycloakRealm = config["keycloakRealm"] ?: "" - val keycloakClient = config["keycloakClient"] ?: "" - - return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient) -} - -class KeycloakConfig( - val keycloakServerHostname: String, - keycloakRealm: String, - val keycloakClient: String, -) { - companion object { - const val LOCALHOST = "127.0.0.1" - const val CALLBACK_PATH = "/callback" - const val SCOPE = "openid" - const val PKCE_METHOD = "S256" - - // TODO die Hash-Berechnung ist nicht portabel, die beiden fn müssen je Platform implementiert werden - fun generateRandomString(): String { - val bytes = ByteArray(64) - SecureRandom().nextBytes(bytes) - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) - } - - fun generateCodeChallenge(codeVerifier: String): String { - val bytes = codeVerifier.toByteArray(Charsets.US_ASCII) - val digest = MessageDigest.getInstance("SHA-256").digest(bytes) - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) - } - } - - private val keycloakBaseUrl = "/realms/$keycloakRealm/protocol/openid-connect" - - val authorizationEndpoint = "$keycloakBaseUrl/auth" - val tokenEndpoint = "$keycloakBaseUrl/token" - val certificatesEndpoint = "$keycloakBaseUrl/certs" - val userinfoEndpoint = "$keycloakBaseUrl/userinfo" - -} - -internal class AuthRun(val kc: KeycloakConfig) { - val codeVerifier = KeycloakConfig.generateRandomString() - val codeChallenge = KeycloakConfig.generateCodeChallenge(codeVerifier) - val csrfProtectionState = KeycloakConfig.generateRandomString() - - val resultDeferred = CompletableDeferred() - private var redirectUri_: String? = null - val redirectUri: String - get() = redirectUri_ - ?: throw IllegalStateException("redirectUri not set, can only be when embedded server is running") - - fun buildRedirectUri(port: Int) { - redirectUri_ = "http://${KeycloakConfig.LOCALHOST}:$port${KeycloakConfig.CALLBACK_PATH}" - } -} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index dbe377e..a457ee3 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -27,8 +27,23 @@ kotlin { implementation(libs.compose.material3) implementation(libs.compose.ui) + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.koin.annotations) + + implementation(libs.ktor.client) + implementation(libs.ktor.clientCio) + implementation(libs.ktor.clientAuth) implementation(libs.ktor.serializationJson) implementation(libs.ktor.clientLogging) + implementation(libs.ktor.clientContentNegotiation) + implementation(libs.ktor.serializationJson) + implementation(libs.ktor.serverCore) + implementation(libs.ktor.serverCio) + +// implementation(libs.ktor.serverAuthJwt) // nicht f. js, wasmJs + + implementation(libs.kotlin.logging) } jvmMain.dependencies { diff --git a/shared/src/commonMain/kotlin/net/keinekekse/app/auth/AuthModule.kt b/shared/src/commonMain/kotlin/net/keinekekse/app/auth/AuthModule.kt new file mode 100644 index 0000000..bdad54e --- /dev/null +++ b/shared/src/commonMain/kotlin/net/keinekekse/app/auth/AuthModule.kt @@ -0,0 +1,125 @@ +package net.keinekekse.app.auth + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.* +import kotlinx.coroutines.CompletableDeferred +import net.keinekekse.app.config.ConfigProperties +import org.koin.core.annotation.Single + +private val logger = KotlinLogging.logger {} + +expect fun openInBrowser(url: Url) +expect fun generateRandomString(): String +expect fun generateCodeChallenge(codeVerifier: String): String + +sealed interface AuthResult { + class None : AuthResult + class Success : AuthResult + + data class Error(val code: String, val message: String, val details: String? = null) : + AuthResult +} + +object AuthErrors { + fun noLoginServerError() = + AuthResult.Error("AUTH01", "Der Anmeldeserver ist nicht erreichbar.", "") + + fun loginError(message: String?) = AuthResult.Error( + "AUTH02", "Bei der Anmeldung ist ein Fehler aufgetreten", message ?: "Unbekannter Fehler" + ) + + fun loginCommunicationError(error: String?, description: String?) = AuthResult.Error( + "AUTH03", "Fehler bei der Anmeldung", "${error ?: ""}: ${description ?: ""}" + ) + + fun loginCompromisedError() = AuthResult.Error( + "AUTH04", "Schwerer Fehler", + "CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware." + ) + + fun webserverNotRunningError() = AuthResult.Error( + "AUTH05", "Schwerer Fehler", + "Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich." + ) + + fun requestTokenError(error: String, description: String?, status: HttpStatusCode) = + AuthResult.Error( + "AUTH06", error, "$description ($status)" + ) + + fun requestTokenError(status: HttpStatusCode, description: String) = AuthResult.Error( + "AUTH07", "HTTP-Fehler $status beim Abholen der Zugriffstoken", description + ) + + fun requestTokenCompromiseError() = AuthResult.Error( + "AUTH08", "Schwerer Fehler", + "Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware." + ) + + fun requestTokenUnknownError() = AuthResult.Error( + "AUTH09", "Schwerer Fehler", + "Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware." + ) +} + +@Single +fun createKeycloakConfig(config: ConfigProperties): KeycloakConfig { + + // TODO muss aus config geholt werden (<--> DI??) + + val keycloakServerHostname = config["keycloakServer"] ?: "" + val keycloakRealm = config["keycloakRealm"] ?: "" + val keycloakClient = config["keycloakClient"] ?: "" + + return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient) +} + +class KeycloakConfig( + val keycloakServerHostname: String, + keycloakRealm: String, + val keycloakClient: String, +) { + companion object { + const val LOCALHOST = "127.0.0.1" + const val CALLBACK_PATH = "/callback" + const val SCOPE = "openid" + const val PKCE_METHOD = "S256" + + // TODO die Hash-Berechnung ist nicht portabel, die beiden fn müssen je Platform implementiert werden +// fun generateRandomString(): String { +// val bytes = ByteArray(64) +// SecureRandom().nextBytes(bytes) +// return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) +// } + +// fun generateCodeChallenge(codeVerifier: String): String { +// val bytes = codeVerifier.toByteArray(Charsets.US_ASCII) +// val digest = MessageDigest.getInstance("SHA-256").digest(bytes) +// return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) +// } + } + + private val keycloakBaseUrl = "/realms/$keycloakRealm/protocol/openid-connect" + + val authorizationEndpoint = "$keycloakBaseUrl/auth" + val tokenEndpoint = "$keycloakBaseUrl/token" + val certificatesEndpoint = "$keycloakBaseUrl/certs" + val userinfoEndpoint = "$keycloakBaseUrl/userinfo" + +} + +internal class AuthRun(val kc: KeycloakConfig) { + val codeVerifier = generateRandomString() + val codeChallenge = generateCodeChallenge(codeVerifier) + val csrfProtectionState = generateRandomString() + + val resultDeferred = CompletableDeferred() + private var redirectUri_: String? = null + val redirectUri: String + get() = redirectUri_ + ?: throw IllegalStateException("redirectUri not set, can only be when embedded server is running") + + fun buildRedirectUri(port: Int) { + redirectUri_ = "http://${KeycloakConfig.LOCALHOST}:$port${KeycloakConfig.CALLBACK_PATH}" + } +} diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/auth/Authenticator.kt b/shared/src/commonMain/kotlin/net/keinekekse/app/auth/Authenticator.kt similarity index 60% rename from books/src/jvmMain/kotlin/net/keinekekse/app/auth/Authenticator.kt rename to shared/src/commonMain/kotlin/net/keinekekse/app/auth/Authenticator.kt index aa64e63..8857f44 100644 --- a/books/src/jvmMain/kotlin/net/keinekekse/app/auth/Authenticator.kt +++ b/shared/src/commonMain/kotlin/net/keinekekse/app/auth/Authenticator.kt @@ -15,16 +15,15 @@ import io.ktor.server.cio.* import io.ktor.server.engine.* import io.ktor.server.response.* import io.ktor.server.routing.* -import io.ktor.utils.io.core.Closeable -import kotlinx.coroutines.delay +import io.ktor.utils.io.core.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update 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 kotlin.use private val logger = KotlinLogging.logger {} @@ -46,87 +45,22 @@ class Authenticator( } } + private val _logonState = MutableStateFlow(AuthResult.None()) + val logonState = _logonState.asStateFlow() + override fun close() = httpClient.close() -// suspend fun authenticate(): AuthResult { -// // check if necessary -// logger.debug { "Authenticator: authenticate()" } -// val tokens = tokenService.getTokens() -// if (tokens != null) { -// val access = JWT.decode(tokens.accessToken) -// val refresh = JWT.decode(tokens.refreshToken) -// val now = Date() -// logger.debug { "Now: $now" } -// logger.debug { "Token expires at: ${access.expiresAt}" } -// logger.debug { "Token is expired: ${access.expiresAt.before(Date())}" } -// logger.debug { "Refresh token expires at: ${refresh.expiresAt}" } -// logger.debug { "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) -// return if (result != null) { -// AuthResult.Success() -// } else { -// doAuthentication() -// } -// } -// } else { -// logger.debug { "Authenticator: Token is still valid." } -// return AuthResult.Success() -// } -// } else { -// return doAuthentication() -// } -// } - - private suspend fun authenticate(): AuthResult { - logger.debug { "Authenticator: doAuthentication()" } - val config = AuthRun(keycloakConfig) - if (!testServerConnection(config)) { - return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "") - } - var server: EmbeddedServer? = null - try { - server = createCallbackServer(config).start(wait = false) - val port = server.engine.resolvedConnectors().first().port - config.buildRedirectUri(port) - if (!waitForServer(port)) { - return AuthResult.Error( - "ERR009", - "Schwerer Fehler", - "Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich." - ) - } - - openAuthorizationInBrowser(config) - - return config.resultDeferred.await() - - } catch (e: Exception) { - return AuthResult.Error( - "ERR002", - "Bei der Anmeldung ist ein Fehler aufgetreten", - e.message ?: "Unbekannter Fehler" - ) - } finally { - server?.stop(1000, 2000) - } - } - - suspend fun reAuthenticate(): BearerTokens? { - logger.debug { "Authenticator: reAuthenticate()" } - val authResult = authenticate() - if (authResult is AuthResult.Success) { + suspend fun authenticate(): BearerTokens? { + logger.debug { "Authenticator: authenticate()" } + _logonState.update { doAuthentication() } + if (logonState.value is AuthResult.Success) { return tokenService.getTokens() } return null } suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? { + logger.debug { "Authenticator: requestRefreshToken()" } val config = AuthRun(keycloakConfig) val response = httpClient.submitForm( @@ -142,71 +76,80 @@ class Authenticator( } ) val result = validateRequestTokenResponse(response) - when (result.first) { - is AuthResult.Success -> { - tokenService.setTokens(result.second) - return result.second - } + _logonState.update { result.first } - else -> { - tokenService.setTokens(null) - logger.debug { "Error refreshing tokens: " } - var error = "" - var errorDetails = "" - when (val err = result.first) { - is AuthResult.Error -> { - logger.debug { err.code } - error = err.message ?: "" - errorDetails = err.details ?: "" - } - - is AuthResult.Success -> println("Success ???") - } - logger.debug { "Status: ${response.status}" } - logger.debug { "Error: #$error#" } - logger.debug { "Description: $errorDetails" } - return if (error == "invalid_grant") { - // session expired || token is invalid - logger.debug { "Session expired or token invalid. Re-authenticating..." } - reAuthenticate() - } else { - null - } + return if (result.first is AuthResult.Success) { + logger.debug { "Authenticator: Refresh token successful." } + tokenService.setTokens(result.second) + result.second + } else { + tokenService.setTokens(null) + // session expired || token is invalid + if (result.first is AuthResult.Error + && (result.first as AuthResult.Error).message == "invalid_grant" + ) { + logger.debug { "Authenticator: Session expired or token invalid. Re-authenticating..." } + authenticate() + } else { + null } } } - private fun isPortOpen(port: Int): Boolean = + private suspend fun doAuthentication(): AuthResult { + logger.debug { "Authenticator: doAuthentication()" } + val config = AuthRun(keycloakConfig) + if (!testServerConnection(config)) { + return AuthErrors.noLoginServerError() + } + var server: EmbeddedServer? = null try { - // TODO Socket ist nicht portabel (JVM only) - Socket(KeycloakConfig.LOCALHOST, port).use { true } - } catch (e: Exception) { - // Port is closed or unreachable - logger.debug { "${e.javaClass.simpleName}: ${e.message}" } - false - } + server = createCallbackServer(config).start(wait = false) + val port = server.engine.resolvedConnectors().first().port + config.buildRedirectUri(port) - private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean { - repeat(timeoutSeconds * 10) { - if (isPortOpen(port)) return true - delay(100) + openAuthorizationInBrowser(config) + + return config.resultDeferred.await() + + } catch (e: Exception) { + return AuthErrors.loginError(e.message) + } finally { + server?.stop(1000, 2000) } - return false } +// private fun isPortOpen(port: Int): Boolean = +// try { +// // TODO Socket ist nicht portabel (JVM only) +// Socket(KeycloakConfig.LOCALHOST, port).use { true } +// } catch (e: Exception) { +// // Port is closed or unreachable +// logger.debug { "${e.javaClass.simpleName}: ${e.message}" } +// false +// } +// +// private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean { +// repeat(timeoutSeconds * 10) { +// if (isPortOpen(port)) return true +// delay(100) +// } +// return false +// } + private fun openAuthorizationInBrowser(config: AuthRun) { val authorizationUrl = buildAuthorizationUrl(config) - - // TODO momentan nur für JVM, Rest ist noch zu eruieren - if (!Desktop.isDesktopSupported()) { - throw IllegalStateException("Desktop API not supported (cannot open browser automatically).") - } - val desktop = Desktop.getDesktop() - if (!desktop.isSupported(Desktop.Action.BROWSE)) { - throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).") - } - - desktop.browse(authorizationUrl.toURI()) + openInBrowser(authorizationUrl) +// // TODO momentan nur für JVM, Rest ist noch zu eruieren +// if (!Desktop.isDesktopSupported()) { +// throw IllegalStateException("Desktop API not supported (cannot open browser automatically).") +// } +// val desktop = Desktop.getDesktop() +// if (!desktop.isSupported(Desktop.Action.BROWSE)) { +// throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).") +// } +// +// desktop.browse(authorizationUrl.toURI()) } private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl { @@ -252,8 +195,8 @@ class Authenticator( .replace("'", "'") private fun createGoodByeMessage(authResult: AuthResult): Pair { - var text = "" - var status = HttpStatusCode.BadRequest + var text: String + var status: HttpStatusCode when (authResult) { is AuthResult.Success -> { text = @@ -268,6 +211,11 @@ class Authenticator( status = HttpStatusCode.BadRequest } + is AuthResult.None -> { + text = + "

Fehler: Anmeldeversuch liefert keinen Status, das hätte nicht passieren sollen.

" + status = HttpStatusCode.InternalServerError + } } return Pair(text, status) } @@ -287,18 +235,13 @@ class Authenticator( rc NULL -> app */ if (request.queryParameters["error"] != null) { - return AuthResult.Error( - "ERR004", - "Fehler bei der Anmeldung", - "${request.queryParameters["error"]}: ${request.queryParameters["error_description"] ?: ""}" + return AuthErrors.loginCommunicationError( + request.queryParameters["error"], + request.queryParameters["error_description"] ) } if (request.queryParameters["state"] != csrfProtectionState) { - return AuthResult.Error( - "ERR005", - "Schwerer Fehler", - "CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware." - ) + return AuthErrors.loginCompromisedError() } return AuthResult.Success() } @@ -338,11 +281,7 @@ class Authenticator( private suspend fun requestTokens(config: AuthRun, authCode: String?): AuthResult { if (authCode == null) { - return AuthResult.Error( - "ERR003", - "Schwerer Fehler", - "Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware." - ) + return AuthErrors.requestTokenCompromiseError() } val response: HttpResponse = httpClient.submitForm( url = buildUrl { @@ -376,35 +315,21 @@ class Authenticator( try { val errorResponse = response.body() return Pair( - AuthResult.Error( - "ERR010", - errorResponse.error, - "${errorResponse.errorDescription} (${response.status})" + AuthErrors.requestTokenError( + errorResponse.error, errorResponse.errorDescription, response.status ), null ) - } catch (e: Exception) { + } catch (_: Exception) { // Fehler beim JSON-Konvertieren val errorResponse = response.bodyAsText() - return Pair( - AuthResult.Error( - "ERR007", - "HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken", - errorResponse - ), null - ) + return Pair(AuthErrors.requestTokenError(response.status, errorResponse), null) } } val tokenResponse: TokenResponse = try { response.body() - } catch (e: Exception) { + } catch (_: Exception) { // Fehler beim JSON-Konvertieren - return Pair( - AuthResult.Error( - "ERR008", - "Schwerer Fehler", - "Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware." - ), null - ) + return Pair(AuthErrors.requestTokenUnknownError(), null) } return Pair( @@ -416,31 +341,6 @@ class Authenticator( ) } -// suspend fun requestUserInfo( -// accessToken: String, -// ) { -// val config = AuthenticationConfig(keycloakServerHostname, keycloakRealm, keycloakClient) -// -// val response = httpClient.get( -// url = buildUrl { -// protocol = URLProtocol.HTTPS -// host = config.keycloakServerHostname -// path(config.userinfoEndpoint) -// }, -//// formParameters = parameters { -//// append("grant_type", "refresh_token") -//// append("client_id", config.keycloakClient) -//// append("refresh_token", refreshToken) -//// } -// ) { -// headers { append("Authorization", "Bearer $accessToken") } -// } -// -// println("Status: ${response.status}") -// println("Body: ${response.bodyAsText()}") -// -// } - private suspend fun testServerConnection(config: AuthRun): Boolean { val response: HttpResponse = httpClient.request( url = buildUrl { diff --git a/shared/src/commonMain/kotlin/net/keinekekse/app/di/KoinModule.kt b/shared/src/commonMain/kotlin/net/keinekekse/app/di/KoinModule.kt new file mode 100644 index 0000000..ea35935 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/keinekekse/app/di/KoinModule.kt @@ -0,0 +1,10 @@ +package net.keinekekse.app.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan("net.keinekekse.app") +class KoinModulesShared diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/service/HttpClient.kt b/shared/src/commonMain/kotlin/net/keinekekse/app/service/HttpClient.kt similarity index 61% rename from books/src/jvmMain/kotlin/net/keinekekse/app/service/HttpClient.kt rename to shared/src/commonMain/kotlin/net/keinekekse/app/service/HttpClient.kt index 64b545a..f6b9e89 100644 --- a/books/src/jvmMain/kotlin/net/keinekekse/app/service/HttpClient.kt +++ b/shared/src/commonMain/kotlin/net/keinekekse/app/service/HttpClient.kt @@ -1,6 +1,5 @@ package net.keinekekse.app.service -import com.auth0.jwt.JWT import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.* import io.ktor.client.plugins.* @@ -12,7 +11,6 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import net.keinekekse.app.auth.Authenticator import org.koin.core.annotation.Single -import java.util.* private val logger = KotlinLogging.logger {} @@ -24,32 +22,16 @@ fun createHttpClient(authenticator: Authenticator): HttpClient { bearer { loadTokens { logger.debug { "HTTPClient: loadTokens" } -// tokenService.getTokens() authenticator.tokenService.getTokens() } refreshTokens { logger.debug { "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() - logger.debug { "Now: $now" } - logger.debug { "Token expires at: ${access.expiresAt}" } - logger.debug { "Token is expired: ${access.expiresAt.before(Date())}" } - logger.debug { "Refresh token expires at: ${refresh.expiresAt}" } - logger.debug { "Refresh token is expired: ${refresh.expiresAt.before(Date())}" } - - if (refresh.expiresAt.before(now)) { - logger.debug { "HTTPClient: refresh token expired - reAuthenticate()" } - authenticator.reAuthenticate() - } else { - logger.debug { "HTTPClient: refresh token valid - requestRefreshToken()" } - authenticator.requestRefreshToken(oldTokens) - } + logger.debug { "HTTPClient: refresh token present - requestRefreshToken()" } + authenticator.requestRefreshToken(oldTokens) } else { logger.debug { "HTTPClient: no tokens - reAuthenticate()" } - authenticator.reAuthenticate() + authenticator.authenticate() } } } diff --git a/books/src/jvmMain/kotlin/net/keinekekse/app/service/TokenService.kt b/shared/src/commonMain/kotlin/net/keinekekse/app/service/TokenService.kt similarity index 100% rename from books/src/jvmMain/kotlin/net/keinekekse/app/service/TokenService.kt rename to shared/src/commonMain/kotlin/net/keinekekse/app/service/TokenService.kt diff --git a/shared/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.jvm.kt b/shared/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.jvm.kt new file mode 100644 index 0000000..449e52d --- /dev/null +++ b/shared/src/jvmMain/kotlin/net/keinekekse/app/auth/AuthModule.jvm.kt @@ -0,0 +1,33 @@ +package net.keinekekse.app.auth + +import io.ktor.http.* +import java.awt.Desktop +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.* + +actual fun openInBrowser(url: Url) { + if (!Desktop.isDesktopSupported()) { + throw IllegalStateException("Desktop API not supported (cannot open browser automatically).") + } + val desktop = Desktop.getDesktop() + if (!desktop.isSupported(Desktop.Action.BROWSE)) { + throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).") + } + + desktop.browse(url.toURI()) + +} + +actual fun generateRandomString(): String { + val bytes = ByteArray(64) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) +} + +actual fun generateCodeChallenge(codeVerifier: String): String { + val bytes = codeVerifier.toByteArray(Charsets.US_ASCII) + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + +} diff --git a/shared/src/macosMain/kotlin/net/keinekekse/app/auth/AuthModule.macos.kt b/shared/src/macosMain/kotlin/net/keinekekse/app/auth/AuthModule.macos.kt new file mode 100644 index 0000000..53d3c11 --- /dev/null +++ b/shared/src/macosMain/kotlin/net/keinekekse/app/auth/AuthModule.macos.kt @@ -0,0 +1,15 @@ +package net.keinekekse.app.auth + +import io.ktor.http.* + +actual fun openInBrowser(url: Url) { + TODO("Implement openInBrowser for macOS") +} + +actual fun generateRandomString(): String { + TODO("Not yet implemented") +} + +actual fun generateCodeChallenge(codeVerifier: String): String { + TODO("Not yet implemented") +} diff --git a/shared/src/wasmJsMain/kotlin/net/keinekekse/app/auth/AuthModule.wasmJs.kt b/shared/src/wasmJsMain/kotlin/net/keinekekse/app/auth/AuthModule.wasmJs.kt new file mode 100644 index 0000000..452e12c --- /dev/null +++ b/shared/src/wasmJsMain/kotlin/net/keinekekse/app/auth/AuthModule.wasmJs.kt @@ -0,0 +1,15 @@ +package net.keinekekse.app.auth + +import io.ktor.http.* + +actual fun openInBrowser(url: Url) { + TODO("Implement openInBrowser for WASM/JS") +} + +actual fun generateRandomString(): String { + TODO("Not yet implemented") +} + +actual fun generateCodeChallenge(codeVerifier: String): String { + TODO("Not yet implemented") +}