Authentication aufräumen, 2. Schritt Umzug nach :shared

This commit is contained in:
Hartmut Bauer committed 2026-02-12 03:18:54 +01:00
1 parent 677d57fb3c
commit 9b573243ef
14 files changed
+335 -303

No files matched your search

+2 -2
View File
@@ -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
@@ -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<TestAuth>()
val authenticator = koinInject<Authenticator>()
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 ?: "")
}
}
}
}
}
@@ -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"
@@ -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
@@ -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<AuthResult>()
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}"
}
}
@@ -1,513 +0,0 @@
package net.keinekekse.app.auth
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
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 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 {}
@Single
class Authenticator(
val tokenService: TokenService,
private val keycloakConfig: KeycloakConfig,
) : Closeable {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(
Json { ignoreUnknownKeys = true }
)
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
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<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = 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) {
return tokenService.getTokens()
}
return null
}
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
val config = AuthRun(keycloakConfig)
val response = httpClient.submitForm(
url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.tokenEndpoint
}.toString(),
formParameters = parameters {
append("grant_type", "refresh_token")
append("client_id", config.kc.keycloakClient)
append("refresh_token", tokens?.refreshToken ?: "")
}
)
val result = validateRequestTokenResponse(response)
when (result.first) {
is AuthResult.Success -> {
tokenService.setTokens(result.second)
return result.second
}
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
}
}
}
}
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())
}
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.authorizationEndpoint
parameters.append("response_type", "code")
parameters.append("client_id", config.kc.keycloakClient)
parameters.append("redirect_uri", config.redirectUri)
parameters.append("scope", KeycloakConfig.SCOPE)
parameters.append("state", config.csrfProtectionState)
parameters.append("code_challenge", config.codeChallenge)
parameters.append("code_challenge_method", KeycloakConfig.PKCE_METHOD)
}
private fun createCallbackServer(config: AuthRun) =
embeddedServer(CIO, port = 0, host = KeycloakConfig.LOCALHOST) {
routing {
get(KeycloakConfig.CALLBACK_PATH) {
val code = call.request.queryParameters["code"]
var authResult =
validateAuthorizationRequest(call.request, config.csrfProtectionState)
if (authResult is AuthResult.Success) {
authResult = requestTokens(config, code)
}
val (text, status) = createGoodByeMessage(authResult)
call.respondText(text, contentType = ContentType.Text.Html, status = status)
if (!config.resultDeferred.isCompleted)
config.resultDeferred.complete(authResult)
}
}
}
private fun htmlEscape(value: String): String =
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
private fun createGoodByeMessage(authResult: AuthResult): Pair<String, HttpStatusCode> {
var text = ""
var status = HttpStatusCode.BadRequest
when (authResult) {
is AuthResult.Success -> {
text =
"<html><body><h1>Login erfolgreich! Du kannst das Fenster schließen.</h1></body></html>"
status = HttpStatusCode.OK
}
is AuthResult.Error -> {
text = "<html><body><h1>Fehler bei der Anmeldung.</h1><h2>${
htmlEscape(authResult.message)
}</h2><h3>${htmlEscape(authResult.details ?: "")}</h3></body></html>"
status = HttpStatusCode.BadRequest
}
}
return Pair(text, status)
}
private fun validateAuthorizationRequest(
request: RoutingRequest,
csrfProtectionState: String,
): AuthResult {
/*
- state != csrfProt -> Fehler: Kommunikation mit dem Anmeldeserver hat nicht geklappt,
könnte auf Malware hindeuten, Abbruch
throw Fatal error authenticating user -> exit app
- wenn error =! null -> error + error_description anzeigen
rc NULL == keine auth -> app muss entscheiden
- code == null -> Fehler: Server liefert ungültige Daten,
könnte auf Probleme bei der Netzwerkverbindung oder beim Anmeldeserver hindeuten
rc NULL -> app
*/
if (request.queryParameters["error"] != null) {
return AuthResult.Error(
"ERR004",
"Fehler bei der Anmeldung",
"${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 AuthResult.Success()
}
/* Antwort von Keycloak:
{
"access_token": "eyJhbG ...
"expires_in": 300,
"refresh_expires_in": 0,
"refresh_token": "eyJhb ...
"token_type": "Bearer",
"id_token": "eyJ ...
"not-before-policy": 1769817270,
"session_state": "YhlNjIuW-lk1aQwwD9xm4edJ",
"scope": "openid profile email"
}
*/
@Serializable
data class TokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("expires_in") val expiresIn: Int,
@SerialName("refresh_expires_in") val refreshExpiresIn: Int,
@SerialName("refresh_token") val refreshToken: String,
@SerialName("token_type") val tokenType: String,
@SerialName("id_token") val idToken: String?,
@SerialName("not-before-policy") val notBeforePolicy: Int?,
@SerialName("session_state") val sessionState: String?,
val scope: String,
)
@Serializable
data class TokenErrorResponse(
@SerialName("error") val error: String,
@SerialName("error_description") val errorDescription: String?,
)
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."
)
}
val response: HttpResponse = httpClient.submitForm(
url = buildUrl {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.tokenEndpoint
}.toString(),
formParameters = parameters {
append("grant_type", "authorization_code")
append("client_id", config.kc.keycloakClient)
append("code", authCode)
append("redirect_uri", config.redirectUri)
append("code_verifier", config.codeVerifier)
}
)
val result = validateRequestTokenResponse(response)
when (result.first) {
is AuthResult.Success -> {
tokenService.setTokens(result.second)
}
else -> {
tokenService.setTokens(null)
}
}
return result.first
}
suspend fun validateRequestTokenResponse(response: HttpResponse): Pair<AuthResult, BearerTokens?> {
if (response.status != HttpStatusCode.OK) {
try {
val errorResponse = response.body<TokenErrorResponse>()
return Pair(
AuthResult.Error(
"ERR010",
errorResponse.error,
"${errorResponse.errorDescription} (${response.status})"
), null
)
} catch (e: 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
)
}
}
val tokenResponse: TokenResponse = try {
response.body<TokenResponse>()
} catch (e: 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(
AuthResult.Success(),
BearerTokens(
tokenResponse.accessToken,
tokenResponse.refreshToken
)
)
}
// 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 {
protocol = URLProtocol.HTTPS
host = config.kc.keycloakServerHostname
encodedPath = config.kc.certificatesEndpoint
}
)
return response.status.value < 400
}
/*
access token:
{
"exp": 1769839803,
"iat": 1769839503,
"auth_time": 1769839502,
"jti": "onrtac:f0ea9436-470d-7d1d-fd9d-b43d2f075c46",
"iss": "https://kec.keinekekse.net/realms/app-server-realm",
"aud": "account",
"sub": "b5b40fa3-468f-443c-8eb0-e220bdd353bc",
"typ": "Bearer",
"azp": "pkce-book-app",
"sid": "n_TeXWIUSV66OkYwoc0D1og9",
"acr": "1",
"allowed-origins": [
"http://127.0.0.1",
"https://kas.keinekekse.net"
],
"realm_access": {
"roles": [
"offline_access",
"default-roles-app-server-realm",
"uma_authorization",
"book-admin"
]
},
"resource_access": {
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid profile email",
"email_verified": true,
"name": "Hartmut Bauer",
"preferred_username": "hb42",
"given_name": "Hartmut",
"family_name": "Bauer",
"email": "hb242@pm.me"
}
-> realm_access:
implementation(libs.ktor.serverAuthJwt)
val decod = JWT().decodeJwt(tokenResponse.accessToken!!)
// claims, die nur String enthalten: getClaim("claim-name")
// für andere: "{" -> asMap<String, Any> => cast!
// "[" -> asList(Class<T>)
val roles: List<String> = decod.getClaim("realm_access").asMap()["roles"] as List<String>
roles.forEach { println(it) }
*/
}
@@ -1,87 +0,0 @@
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.*
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.*
private val logger = KotlinLogging.logger {}
@OptIn(ExperimentalSerializationApi::class)
@Single
fun createHttpClient(authenticator: Authenticator): HttpClient {
val client = HttpClient {
install(Auth) {
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)
}
} else {
logger.debug { "HTTPClient: no tokens - reAuthenticate()" }
authenticator.reAuthenticate()
}
}
}
}
install(ContentNegotiation) {
json(Json {
prettyPrint = true
allowTrailingComma = true
ignoreUnknownKeys = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 5000
}
}
client.plugin(HttpSend).intercept { request ->
logger.debug { "HTTPCli: intercept ${request.url}" }
val originalCall = execute(request)
logger.debug { "HTTPCli: response status ${originalCall.response.status}" }
originalCall
// val originalCall = execute(request)
// if (originalCall.response.status.value !in 100..399) {
// execute(request)
// } else {
// originalCall
// }
}
return client
}
@@ -1,22 +0,0 @@
package net.keinekekse.app.service
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.plugins.auth.providers.*
import org.koin.core.annotation.Single
private val logger = KotlinLogging.logger {}
@Single
class TokenService {
private var tokens: BearerTokens? = null
fun getTokens(): BearerTokens? = tokens
fun setTokens(tokens: BearerTokens? = null) {
this.tokens = tokens
logger.debug { "set tokens" }
logger.debug { "Access Token: ${tokens?.accessToken}" }
logger.debug { "Refresh Token: ${tokens?.refreshToken}" }
}
}