Authentication aufräumen, 2. Schritt Umzug nach :shared
This commit is contained in:
1 parent
677d57fb3c
commit
9b573243ef
14 files changed
+335
-303
No files matched your search
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
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}" }
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user