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

+15
View File
@@ -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 {
@@ -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<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}"
}
}
@@ -0,0 +1,413 @@
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.*
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
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
}
}
private val _logonState = MutableStateFlow<AuthResult>(AuthResult.None())
val logonState = _logonState.asStateFlow()
override fun close() = httpClient.close()
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(
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)
_logonState.update { result.first }
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 suspend fun doAuthentication(): AuthResult {
logger.debug { "Authenticator: doAuthentication()" }
val config = AuthRun(keycloakConfig)
if (!testServerConnection(config)) {
return AuthErrors.noLoginServerError()
}
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
try {
server = createCallbackServer(config).start(wait = false)
val port = server.engine.resolvedConnectors().first().port
config.buildRedirectUri(port)
openAuthorizationInBrowser(config)
return config.resultDeferred.await()
} catch (e: Exception) {
return AuthErrors.loginError(e.message)
} finally {
server?.stop(1000, 2000)
}
}
// 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)
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 {
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: String
var status: HttpStatusCode
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
}
is AuthResult.None -> {
text =
"<html><body><h1>Fehler: Anmeldeversuch liefert keinen Status, das hätte nicht passieren sollen.</h1></body></html>"
status = HttpStatusCode.InternalServerError
}
}
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 AuthErrors.loginCommunicationError(
request.queryParameters["error"],
request.queryParameters["error_description"]
)
}
if (request.queryParameters["state"] != csrfProtectionState) {
return AuthErrors.loginCompromisedError()
}
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 AuthErrors.requestTokenCompromiseError()
}
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(
AuthErrors.requestTokenError(
errorResponse.error, errorResponse.errorDescription, response.status
), null
)
} catch (_: Exception) {
// Fehler beim JSON-Konvertieren
val errorResponse = response.bodyAsText()
return Pair(AuthErrors.requestTokenError(response.status, errorResponse), null)
}
}
val tokenResponse: TokenResponse = try {
response.body<TokenResponse>()
} catch (_: Exception) {
// Fehler beim JSON-Konvertieren
return Pair(AuthErrors.requestTokenUnknownError(), null)
}
return Pair(
AuthResult.Success(),
BearerTokens(
tokenResponse.accessToken,
tokenResponse.refreshToken
)
)
}
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) }
*/
}
@@ -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
@@ -0,0 +1,69 @@
package net.keinekekse.app.service
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
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" }
authenticator.tokenService.getTokens()
}
refreshTokens {
logger.debug { "HTTPClient: refreshTokens" }
if (oldTokens != null) {
logger.debug { "HTTPClient: refresh token present - requestRefreshToken()" }
authenticator.requestRefreshToken(oldTokens)
} else {
logger.debug { "HTTPClient: no tokens - reAuthenticate()" }
authenticator.authenticate()
}
}
}
}
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
}
@@ -0,0 +1,22 @@
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}" }
}
}
@@ -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)
}
@@ -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")
}
@@ -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")
}