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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
+92
-192
@@ -15,16 +15,15 @@ import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.utils.io.core.Closeable
|
||||
import kotlinx.coroutines.delay
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.keinekekse.app.service.TokenService
|
||||
import org.koin.core.annotation.Single
|
||||
import java.awt.Desktop
|
||||
import java.net.Socket
|
||||
import kotlin.use
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@@ -46,87 +45,22 @@ class Authenticator(
|
||||
}
|
||||
}
|
||||
|
||||
private val _logonState = MutableStateFlow<AuthResult>(AuthResult.None())
|
||||
val logonState = _logonState.asStateFlow()
|
||||
|
||||
override fun close() = httpClient.close()
|
||||
|
||||
// suspend fun authenticate(): AuthResult {
|
||||
// // check if necessary
|
||||
// logger.debug { "Authenticator: authenticate()" }
|
||||
// val tokens = tokenService.getTokens()
|
||||
// if (tokens != null) {
|
||||
// val access = JWT.decode(tokens.accessToken)
|
||||
// val refresh = JWT.decode(tokens.refreshToken)
|
||||
// val now = Date()
|
||||
// logger.debug { "Now: $now" }
|
||||
// logger.debug { "Token expires at: ${access.expiresAt}" }
|
||||
// logger.debug { "Token is expired: ${access.expiresAt.before(Date())}" }
|
||||
// logger.debug { "Refresh token expires at: ${refresh.expiresAt}" }
|
||||
// logger.debug { "Refresh token is expired: ${refresh.expiresAt.before(Date())}" }
|
||||
// if (access.expiresAt.before(now)) {
|
||||
// // access_token ungültig
|
||||
// if (refresh.expiresAt.before(now)) {
|
||||
// // refresh_token ungültig
|
||||
// return doAuthentication()
|
||||
// } else {
|
||||
// val result = requestRefreshToken(tokens)
|
||||
// return if (result != null) {
|
||||
// AuthResult.Success()
|
||||
// } else {
|
||||
// doAuthentication()
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// logger.debug { "Authenticator: Token is still valid." }
|
||||
// return AuthResult.Success()
|
||||
// }
|
||||
// } else {
|
||||
// return doAuthentication()
|
||||
// }
|
||||
// }
|
||||
|
||||
private suspend fun authenticate(): AuthResult {
|
||||
logger.debug { "Authenticator: doAuthentication()" }
|
||||
val config = AuthRun(keycloakConfig)
|
||||
if (!testServerConnection(config)) {
|
||||
return AuthResult.Error("ERR001", "Der Anmeldeserver ist nicht erreichbar.", "")
|
||||
}
|
||||
var server: EmbeddedServer<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) {
|
||||
suspend fun authenticate(): BearerTokens? {
|
||||
logger.debug { "Authenticator: authenticate()" }
|
||||
_logonState.update { doAuthentication() }
|
||||
if (logonState.value is AuthResult.Success) {
|
||||
return tokenService.getTokens()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
|
||||
logger.debug { "Authenticator: requestRefreshToken()" }
|
||||
val config = AuthRun(keycloakConfig)
|
||||
|
||||
val response = httpClient.submitForm(
|
||||
@@ -142,71 +76,80 @@ class Authenticator(
|
||||
}
|
||||
)
|
||||
val result = validateRequestTokenResponse(response)
|
||||
when (result.first) {
|
||||
is AuthResult.Success -> {
|
||||
tokenService.setTokens(result.second)
|
||||
return result.second
|
||||
}
|
||||
_logonState.update { result.first }
|
||||
|
||||
else -> {
|
||||
tokenService.setTokens(null)
|
||||
logger.debug { "Error refreshing tokens: " }
|
||||
var error = ""
|
||||
var errorDetails = ""
|
||||
when (val err = result.first) {
|
||||
is AuthResult.Error -> {
|
||||
logger.debug { err.code }
|
||||
error = err.message ?: ""
|
||||
errorDetails = err.details ?: ""
|
||||
}
|
||||
|
||||
is AuthResult.Success -> println("Success ???")
|
||||
}
|
||||
logger.debug { "Status: ${response.status}" }
|
||||
logger.debug { "Error: #$error#" }
|
||||
logger.debug { "Description: $errorDetails" }
|
||||
return if (error == "invalid_grant") {
|
||||
// session expired || token is invalid
|
||||
logger.debug { "Session expired or token invalid. Re-authenticating..." }
|
||||
reAuthenticate()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return if (result.first is AuthResult.Success) {
|
||||
logger.debug { "Authenticator: Refresh token successful." }
|
||||
tokenService.setTokens(result.second)
|
||||
result.second
|
||||
} else {
|
||||
tokenService.setTokens(null)
|
||||
// session expired || token is invalid
|
||||
if (result.first is AuthResult.Error
|
||||
&& (result.first as AuthResult.Error).message == "invalid_grant"
|
||||
) {
|
||||
logger.debug { "Authenticator: Session expired or token invalid. Re-authenticating..." }
|
||||
authenticate()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPortOpen(port: Int): Boolean =
|
||||
private suspend fun doAuthentication(): AuthResult {
|
||||
logger.debug { "Authenticator: doAuthentication()" }
|
||||
val config = AuthRun(keycloakConfig)
|
||||
if (!testServerConnection(config)) {
|
||||
return AuthErrors.noLoginServerError()
|
||||
}
|
||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
||||
try {
|
||||
// TODO Socket ist nicht portabel (JVM only)
|
||||
Socket(KeycloakConfig.LOCALHOST, port).use { true }
|
||||
} catch (e: Exception) {
|
||||
// Port is closed or unreachable
|
||||
logger.debug { "${e.javaClass.simpleName}: ${e.message}" }
|
||||
false
|
||||
}
|
||||
server = createCallbackServer(config).start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
config.buildRedirectUri(port)
|
||||
|
||||
private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
|
||||
repeat(timeoutSeconds * 10) {
|
||||
if (isPortOpen(port)) return true
|
||||
delay(100)
|
||||
openAuthorizationInBrowser(config)
|
||||
|
||||
return config.resultDeferred.await()
|
||||
|
||||
} catch (e: Exception) {
|
||||
return AuthErrors.loginError(e.message)
|
||||
} finally {
|
||||
server?.stop(1000, 2000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// private fun isPortOpen(port: Int): Boolean =
|
||||
// try {
|
||||
// // TODO Socket ist nicht portabel (JVM only)
|
||||
// Socket(KeycloakConfig.LOCALHOST, port).use { true }
|
||||
// } catch (e: Exception) {
|
||||
// // Port is closed or unreachable
|
||||
// logger.debug { "${e.javaClass.simpleName}: ${e.message}" }
|
||||
// false
|
||||
// }
|
||||
//
|
||||
// private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
|
||||
// repeat(timeoutSeconds * 10) {
|
||||
// if (isPortOpen(port)) return true
|
||||
// delay(100)
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
private fun openAuthorizationInBrowser(config: AuthRun) {
|
||||
val authorizationUrl = buildAuthorizationUrl(config)
|
||||
|
||||
// TODO momentan nur für JVM, Rest ist noch zu eruieren
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
||||
}
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||
throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
||||
}
|
||||
|
||||
desktop.browse(authorizationUrl.toURI())
|
||||
openInBrowser(authorizationUrl)
|
||||
// // TODO momentan nur für JVM, Rest ist noch zu eruieren
|
||||
// if (!Desktop.isDesktopSupported()) {
|
||||
// throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
||||
// }
|
||||
// val desktop = Desktop.getDesktop()
|
||||
// if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||
// throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
||||
// }
|
||||
//
|
||||
// desktop.browse(authorizationUrl.toURI())
|
||||
}
|
||||
|
||||
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
|
||||
@@ -252,8 +195,8 @@ class Authenticator(
|
||||
.replace("'", "'")
|
||||
|
||||
private fun createGoodByeMessage(authResult: AuthResult): Pair<String, HttpStatusCode> {
|
||||
var text = ""
|
||||
var status = HttpStatusCode.BadRequest
|
||||
var text: String
|
||||
var status: HttpStatusCode
|
||||
when (authResult) {
|
||||
is AuthResult.Success -> {
|
||||
text =
|
||||
@@ -268,6 +211,11 @@ class Authenticator(
|
||||
status = HttpStatusCode.BadRequest
|
||||
}
|
||||
|
||||
is AuthResult.None -> {
|
||||
text =
|
||||
"<html><body><h1>Fehler: Anmeldeversuch liefert keinen Status, das hätte nicht passieren sollen.</h1></body></html>"
|
||||
status = HttpStatusCode.InternalServerError
|
||||
}
|
||||
}
|
||||
return Pair(text, status)
|
||||
}
|
||||
@@ -287,18 +235,13 @@ class Authenticator(
|
||||
rc NULL -> app
|
||||
*/
|
||||
if (request.queryParameters["error"] != null) {
|
||||
return AuthResult.Error(
|
||||
"ERR004",
|
||||
"Fehler bei der Anmeldung",
|
||||
"${request.queryParameters["error"]}: ${request.queryParameters["error_description"] ?: ""}"
|
||||
return AuthErrors.loginCommunicationError(
|
||||
request.queryParameters["error"],
|
||||
request.queryParameters["error_description"]
|
||||
)
|
||||
}
|
||||
if (request.queryParameters["state"] != csrfProtectionState) {
|
||||
return AuthResult.Error(
|
||||
"ERR005",
|
||||
"Schwerer Fehler",
|
||||
"CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
return AuthErrors.loginCompromisedError()
|
||||
}
|
||||
return AuthResult.Success()
|
||||
}
|
||||
@@ -338,11 +281,7 @@ class Authenticator(
|
||||
|
||||
private suspend fun requestTokens(config: AuthRun, authCode: String?): AuthResult {
|
||||
if (authCode == null) {
|
||||
return AuthResult.Error(
|
||||
"ERR003",
|
||||
"Schwerer Fehler",
|
||||
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
return AuthErrors.requestTokenCompromiseError()
|
||||
}
|
||||
val response: HttpResponse = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
@@ -376,35 +315,21 @@ class Authenticator(
|
||||
try {
|
||||
val errorResponse = response.body<TokenErrorResponse>()
|
||||
return Pair(
|
||||
AuthResult.Error(
|
||||
"ERR010",
|
||||
errorResponse.error,
|
||||
"${errorResponse.errorDescription} (${response.status})"
|
||||
AuthErrors.requestTokenError(
|
||||
errorResponse.error, errorResponse.errorDescription, response.status
|
||||
), null
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
val errorResponse = response.bodyAsText()
|
||||
return Pair(
|
||||
AuthResult.Error(
|
||||
"ERR007",
|
||||
"HTTP-Fehler ${response.status.value} beim Abholen der Zugriffstoken",
|
||||
errorResponse
|
||||
), null
|
||||
)
|
||||
return Pair(AuthErrors.requestTokenError(response.status, errorResponse), null)
|
||||
}
|
||||
}
|
||||
val tokenResponse: TokenResponse = try {
|
||||
response.body<TokenResponse>()
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
return Pair(
|
||||
AuthResult.Error(
|
||||
"ERR008",
|
||||
"Schwerer Fehler",
|
||||
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
), null
|
||||
)
|
||||
return Pair(AuthErrors.requestTokenUnknownError(), null)
|
||||
}
|
||||
|
||||
return Pair(
|
||||
@@ -416,31 +341,6 @@ class Authenticator(
|
||||
)
|
||||
}
|
||||
|
||||
// suspend fun requestUserInfo(
|
||||
// accessToken: String,
|
||||
// ) {
|
||||
// val config = AuthenticationConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
//
|
||||
// val response = httpClient.get(
|
||||
// url = buildUrl {
|
||||
// protocol = URLProtocol.HTTPS
|
||||
// host = config.keycloakServerHostname
|
||||
// path(config.userinfoEndpoint)
|
||||
// },
|
||||
//// formParameters = parameters {
|
||||
//// append("grant_type", "refresh_token")
|
||||
//// append("client_id", config.keycloakClient)
|
||||
//// append("refresh_token", refreshToken)
|
||||
//// }
|
||||
// ) {
|
||||
// headers { append("Authorization", "Bearer $accessToken") }
|
||||
// }
|
||||
//
|
||||
// println("Status: ${response.status}")
|
||||
// println("Body: ${response.bodyAsText()}")
|
||||
//
|
||||
// }
|
||||
|
||||
private suspend fun testServerConnection(config: AuthRun): Boolean {
|
||||
val response: HttpResponse = httpClient.request(
|
||||
url = buildUrl {
|
||||
@@ -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
|
||||
|
||||
import com.auth0.jwt.JWT
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.*
|
||||
@@ -12,7 +11,6 @@ import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.keinekekse.app.auth.Authenticator
|
||||
import org.koin.core.annotation.Single
|
||||
import java.util.*
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@@ -24,32 +22,16 @@ fun createHttpClient(authenticator: Authenticator): HttpClient {
|
||||
bearer {
|
||||
loadTokens {
|
||||
logger.debug { "HTTPClient: loadTokens" }
|
||||
// tokenService.getTokens()
|
||||
authenticator.tokenService.getTokens()
|
||||
}
|
||||
refreshTokens {
|
||||
logger.debug { "HTTPClient: refreshTokens" }
|
||||
// val tokens = authenticator.tokenService.getTokens()
|
||||
if (oldTokens != null) {
|
||||
val access = JWT.decode(oldTokens?.accessToken)
|
||||
val refresh = JWT.decode(oldTokens?.refreshToken)
|
||||
val now = Date()
|
||||
logger.debug { "Now: $now" }
|
||||
logger.debug { "Token expires at: ${access.expiresAt}" }
|
||||
logger.debug { "Token is expired: ${access.expiresAt.before(Date())}" }
|
||||
logger.debug { "Refresh token expires at: ${refresh.expiresAt}" }
|
||||
logger.debug { "Refresh token is expired: ${refresh.expiresAt.before(Date())}" }
|
||||
|
||||
if (refresh.expiresAt.before(now)) {
|
||||
logger.debug { "HTTPClient: refresh token expired - reAuthenticate()" }
|
||||
authenticator.reAuthenticate()
|
||||
} else {
|
||||
logger.debug { "HTTPClient: refresh token valid - requestRefreshToken()" }
|
||||
authenticator.requestRefreshToken(oldTokens)
|
||||
}
|
||||
logger.debug { "HTTPClient: refresh token present - requestRefreshToken()" }
|
||||
authenticator.requestRefreshToken(oldTokens)
|
||||
} else {
|
||||
logger.debug { "HTTPClient: no tokens - reAuthenticate()" }
|
||||
authenticator.reAuthenticate()
|
||||
authenticator.authenticate()
|
||||
}
|
||||
}
|
||||
}
|
||||
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