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
|
#auto generated
|
||||||
#Wed Feb 11 01:28:34 CET 2026
|
#Thu Feb 12 03:10:51 CET 2026
|
||||||
build.number=233
|
build.number=238
|
||||||
@@ -21,6 +21,8 @@ import io.ktor.client.statement.*
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
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.Res
|
||||||
import net.keinekekse.app.books.generated.resources.compose_multiplatform
|
import net.keinekekse.app.books.generated.resources.compose_multiplatform
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
@@ -40,6 +42,7 @@ fun App() {
|
|||||||
// AppTheme {
|
// AppTheme {
|
||||||
*/
|
*/
|
||||||
val test = koinInject<TestAuth>()
|
val test = koinInject<TestAuth>()
|
||||||
|
val authenticator = koinInject<Authenticator>()
|
||||||
var showContent by remember { mutableStateOf(false) }
|
var showContent by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -71,6 +74,23 @@ fun App() {
|
|||||||
Text("Compose: $greeting")
|
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 {
|
object VERSION: Version {
|
||||||
override val version = "1.0.0"
|
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 major = 1
|
||||||
override val minor = 0
|
override val minor = 0
|
||||||
override val patch = 0
|
override val patch = 0
|
||||||
override val preRelease = ""
|
override val preRelease = ""
|
||||||
override val buildMetadata = "build.233"
|
override val buildMetadata = "build.238"
|
||||||
override val buildDate = "2026-02-11T00:28:34.345856Z"
|
override val buildDate = "2026-02-12T02:10:51.539449Z"
|
||||||
override val displayName = "Books App"
|
override val displayName = "Books App"
|
||||||
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
|
override val copyright = "Copyright (c) 2026 Hartmut Bauer"
|
||||||
override val name = "books"
|
override val name = "books"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package net.keinekekse.app.books.di
|
package net.keinekekse.app.books.di
|
||||||
|
|
||||||
|
import net.keinekekse.app.di.KoinModulesShared
|
||||||
import org.koin.core.annotation.KoinApplication
|
import org.koin.core.annotation.KoinApplication
|
||||||
|
|
||||||
@KoinApplication
|
@KoinApplication(modules = [KoinModules::class, KoinModulesShared::class])
|
||||||
object KoinApp
|
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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,8 +27,23 @@ kotlin {
|
|||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.compose.ui)
|
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.serializationJson)
|
||||||
implementation(libs.ktor.clientLogging)
|
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 {
|
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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-192
@@ -15,16 +15,15 @@ import io.ktor.server.cio.*
|
|||||||
import io.ktor.server.engine.*
|
import io.ktor.server.engine.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.utils.io.core.Closeable
|
import io.ktor.utils.io.core.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import net.keinekekse.app.service.TokenService
|
import net.keinekekse.app.service.TokenService
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import java.awt.Desktop
|
|
||||||
import java.net.Socket
|
|
||||||
import kotlin.use
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@@ -46,87 +45,22 @@ class Authenticator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _logonState = MutableStateFlow<AuthResult>(AuthResult.None())
|
||||||
|
val logonState = _logonState.asStateFlow()
|
||||||
|
|
||||||
override fun close() = httpClient.close()
|
override fun close() = httpClient.close()
|
||||||
|
|
||||||
// suspend fun authenticate(): AuthResult {
|
suspend fun authenticate(): BearerTokens? {
|
||||||
// // check if necessary
|
logger.debug { "Authenticator: authenticate()" }
|
||||||
// logger.debug { "Authenticator: authenticate()" }
|
_logonState.update { doAuthentication() }
|
||||||
// val tokens = tokenService.getTokens()
|
if (logonState.value is AuthResult.Success) {
|
||||||
// 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 tokenService.getTokens()
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
|
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
|
||||||
|
logger.debug { "Authenticator: requestRefreshToken()" }
|
||||||
val config = AuthRun(keycloakConfig)
|
val config = AuthRun(keycloakConfig)
|
||||||
|
|
||||||
val response = httpClient.submitForm(
|
val response = httpClient.submitForm(
|
||||||
@@ -142,71 +76,80 @@ class Authenticator(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
val result = validateRequestTokenResponse(response)
|
val result = validateRequestTokenResponse(response)
|
||||||
when (result.first) {
|
_logonState.update { result.first }
|
||||||
is AuthResult.Success -> {
|
|
||||||
tokenService.setTokens(result.second)
|
|
||||||
return result.second
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
return if (result.first is AuthResult.Success) {
|
||||||
tokenService.setTokens(null)
|
logger.debug { "Authenticator: Refresh token successful." }
|
||||||
logger.debug { "Error refreshing tokens: " }
|
tokenService.setTokens(result.second)
|
||||||
var error = ""
|
result.second
|
||||||
var errorDetails = ""
|
} else {
|
||||||
when (val err = result.first) {
|
tokenService.setTokens(null)
|
||||||
is AuthResult.Error -> {
|
// session expired || token is invalid
|
||||||
logger.debug { err.code }
|
if (result.first is AuthResult.Error
|
||||||
error = err.message ?: ""
|
&& (result.first as AuthResult.Error).message == "invalid_grant"
|
||||||
errorDetails = err.details ?: ""
|
) {
|
||||||
}
|
logger.debug { "Authenticator: Session expired or token invalid. Re-authenticating..." }
|
||||||
|
authenticate()
|
||||||
is AuthResult.Success -> println("Success ???")
|
} else {
|
||||||
}
|
null
|
||||||
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 =
|
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 {
|
try {
|
||||||
// TODO Socket ist nicht portabel (JVM only)
|
server = createCallbackServer(config).start(wait = false)
|
||||||
Socket(KeycloakConfig.LOCALHOST, port).use { true }
|
val port = server.engine.resolvedConnectors().first().port
|
||||||
} catch (e: Exception) {
|
config.buildRedirectUri(port)
|
||||||
// Port is closed or unreachable
|
|
||||||
logger.debug { "${e.javaClass.simpleName}: ${e.message}" }
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
|
openAuthorizationInBrowser(config)
|
||||||
repeat(timeoutSeconds * 10) {
|
|
||||||
if (isPortOpen(port)) return true
|
return config.resultDeferred.await()
|
||||||
delay(100)
|
|
||||||
|
} 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) {
|
private fun openAuthorizationInBrowser(config: AuthRun) {
|
||||||
val authorizationUrl = buildAuthorizationUrl(config)
|
val authorizationUrl = buildAuthorizationUrl(config)
|
||||||
|
openInBrowser(authorizationUrl)
|
||||||
// TODO momentan nur für JVM, Rest ist noch zu eruieren
|
// // TODO momentan nur für JVM, Rest ist noch zu eruieren
|
||||||
if (!Desktop.isDesktopSupported()) {
|
// if (!Desktop.isDesktopSupported()) {
|
||||||
throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
// throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
||||||
}
|
// }
|
||||||
val desktop = Desktop.getDesktop()
|
// val desktop = Desktop.getDesktop()
|
||||||
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
// if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||||
throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
// throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
desktop.browse(authorizationUrl.toURI())
|
// desktop.browse(authorizationUrl.toURI())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
|
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
|
||||||
@@ -252,8 +195,8 @@ class Authenticator(
|
|||||||
.replace("'", "'")
|
.replace("'", "'")
|
||||||
|
|
||||||
private fun createGoodByeMessage(authResult: AuthResult): Pair<String, HttpStatusCode> {
|
private fun createGoodByeMessage(authResult: AuthResult): Pair<String, HttpStatusCode> {
|
||||||
var text = ""
|
var text: String
|
||||||
var status = HttpStatusCode.BadRequest
|
var status: HttpStatusCode
|
||||||
when (authResult) {
|
when (authResult) {
|
||||||
is AuthResult.Success -> {
|
is AuthResult.Success -> {
|
||||||
text =
|
text =
|
||||||
@@ -268,6 +211,11 @@ class Authenticator(
|
|||||||
status = HttpStatusCode.BadRequest
|
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)
|
return Pair(text, status)
|
||||||
}
|
}
|
||||||
@@ -287,18 +235,13 @@ class Authenticator(
|
|||||||
rc NULL -> app
|
rc NULL -> app
|
||||||
*/
|
*/
|
||||||
if (request.queryParameters["error"] != null) {
|
if (request.queryParameters["error"] != null) {
|
||||||
return AuthResult.Error(
|
return AuthErrors.loginCommunicationError(
|
||||||
"ERR004",
|
request.queryParameters["error"],
|
||||||
"Fehler bei der Anmeldung",
|
request.queryParameters["error_description"]
|
||||||
"${request.queryParameters["error"]}: ${request.queryParameters["error_description"] ?: ""}"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (request.queryParameters["state"] != csrfProtectionState) {
|
if (request.queryParameters["state"] != csrfProtectionState) {
|
||||||
return AuthResult.Error(
|
return AuthErrors.loginCompromisedError()
|
||||||
"ERR005",
|
|
||||||
"Schwerer Fehler",
|
|
||||||
"CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return AuthResult.Success()
|
return AuthResult.Success()
|
||||||
}
|
}
|
||||||
@@ -338,11 +281,7 @@ class Authenticator(
|
|||||||
|
|
||||||
private suspend fun requestTokens(config: AuthRun, authCode: String?): AuthResult {
|
private suspend fun requestTokens(config: AuthRun, authCode: String?): AuthResult {
|
||||||
if (authCode == null) {
|
if (authCode == null) {
|
||||||
return AuthResult.Error(
|
return AuthErrors.requestTokenCompromiseError()
|
||||||
"ERR003",
|
|
||||||
"Schwerer Fehler",
|
|
||||||
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
val response: HttpResponse = httpClient.submitForm(
|
val response: HttpResponse = httpClient.submitForm(
|
||||||
url = buildUrl {
|
url = buildUrl {
|
||||||
@@ -376,35 +315,21 @@ class Authenticator(
|
|||||||
try {
|
try {
|
||||||
val errorResponse = response.body<TokenErrorResponse>()
|
val errorResponse = response.body<TokenErrorResponse>()
|
||||||
return Pair(
|
return Pair(
|
||||||
AuthResult.Error(
|
AuthErrors.requestTokenError(
|
||||||
"ERR010",
|
errorResponse.error, errorResponse.errorDescription, response.status
|
||||||
errorResponse.error,
|
|
||||||
"${errorResponse.errorDescription} (${response.status})"
|
|
||||||
), null
|
), null
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Fehler beim JSON-Konvertieren
|
// Fehler beim JSON-Konvertieren
|
||||||
val errorResponse = response.bodyAsText()
|
val errorResponse = response.bodyAsText()
|
||||||
return Pair(
|
return Pair(AuthErrors.requestTokenError(response.status, errorResponse), null)
|
||||||
AuthResult.Error(
|
|
||||||
"ERR007",
|
|
||||||
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
|
|
||||||
errorResponse
|
|
||||||
), null
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val tokenResponse: TokenResponse = try {
|
val tokenResponse: TokenResponse = try {
|
||||||
response.body<TokenResponse>()
|
response.body<TokenResponse>()
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// Fehler beim JSON-Konvertieren
|
// Fehler beim JSON-Konvertieren
|
||||||
return Pair(
|
return Pair(AuthErrors.requestTokenUnknownError(), null)
|
||||||
AuthResult.Error(
|
|
||||||
"ERR008",
|
|
||||||
"Schwerer Fehler",
|
|
||||||
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
|
||||||
), null
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Pair(
|
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 {
|
private suspend fun testServerConnection(config: AuthRun): Boolean {
|
||||||
val response: HttpResponse = httpClient.request(
|
val response: HttpResponse = httpClient.request(
|
||||||
url = buildUrl {
|
url = buildUrl {
|
||||||
@@ -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
|
||||||
+3
-21
@@ -1,6 +1,5 @@
|
|||||||
package net.keinekekse.app.service
|
package net.keinekekse.app.service
|
||||||
|
|
||||||
import com.auth0.jwt.JWT
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.plugins.*
|
import io.ktor.client.plugins.*
|
||||||
@@ -12,7 +11,6 @@ import kotlinx.serialization.ExperimentalSerializationApi
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import net.keinekekse.app.auth.Authenticator
|
import net.keinekekse.app.auth.Authenticator
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@@ -24,32 +22,16 @@ fun createHttpClient(authenticator: Authenticator): HttpClient {
|
|||||||
bearer {
|
bearer {
|
||||||
loadTokens {
|
loadTokens {
|
||||||
logger.debug { "HTTPClient: loadTokens" }
|
logger.debug { "HTTPClient: loadTokens" }
|
||||||
// tokenService.getTokens()
|
|
||||||
authenticator.tokenService.getTokens()
|
authenticator.tokenService.getTokens()
|
||||||
}
|
}
|
||||||
refreshTokens {
|
refreshTokens {
|
||||||
logger.debug { "HTTPClient: refreshTokens" }
|
logger.debug { "HTTPClient: refreshTokens" }
|
||||||
// val tokens = authenticator.tokenService.getTokens()
|
|
||||||
if (oldTokens != null) {
|
if (oldTokens != null) {
|
||||||
val access = JWT.decode(oldTokens?.accessToken)
|
logger.debug { "HTTPClient: refresh token present - requestRefreshToken()" }
|
||||||
val refresh = JWT.decode(oldTokens?.refreshToken)
|
authenticator.requestRefreshToken(oldTokens)
|
||||||
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 {
|
} else {
|
||||||
logger.debug { "HTTPClient: no tokens - reAuthenticate()" }
|
logger.debug { "HTTPClient: no tokens - reAuthenticate()" }
|
||||||
authenticator.reAuthenticate()
|
authenticator.authenticate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
@@ -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")
|
||||||
|
}
|
||||||
Reference in new issue
Block a user