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
@@ -27,8 +27,23 @@ kotlin {
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
|
||||
implementation(project.dependencies.platform(libs.koin.bom))
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.annotations)
|
||||
|
||||
implementation(libs.ktor.client)
|
||||
implementation(libs.ktor.clientCio)
|
||||
implementation(libs.ktor.clientAuth)
|
||||
implementation(libs.ktor.serializationJson)
|
||||
implementation(libs.ktor.clientLogging)
|
||||
implementation(libs.ktor.clientContentNegotiation)
|
||||
implementation(libs.ktor.serializationJson)
|
||||
implementation(libs.ktor.serverCore)
|
||||
implementation(libs.ktor.serverCio)
|
||||
|
||||
// implementation(libs.ktor.serverAuthJwt) // nicht f. js, wasmJs
|
||||
|
||||
implementation(libs.kotlin.logging)
|
||||
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import net.keinekekse.app.config.ConfigProperties
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
expect fun openInBrowser(url: Url)
|
||||
expect fun generateRandomString(): String
|
||||
expect fun generateCodeChallenge(codeVerifier: String): String
|
||||
|
||||
sealed interface AuthResult {
|
||||
class None : AuthResult
|
||||
class Success : AuthResult
|
||||
|
||||
data class Error(val code: String, val message: String, val details: String? = null) :
|
||||
AuthResult
|
||||
}
|
||||
|
||||
object AuthErrors {
|
||||
fun noLoginServerError() =
|
||||
AuthResult.Error("AUTH01", "Der Anmeldeserver ist nicht erreichbar.", "")
|
||||
|
||||
fun loginError(message: String?) = AuthResult.Error(
|
||||
"AUTH02", "Bei der Anmeldung ist ein Fehler aufgetreten", message ?: "Unbekannter Fehler"
|
||||
)
|
||||
|
||||
fun loginCommunicationError(error: String?, description: String?) = AuthResult.Error(
|
||||
"AUTH03", "Fehler bei der Anmeldung", "${error ?: ""}: ${description ?: ""}"
|
||||
)
|
||||
|
||||
fun loginCompromisedError() = AuthResult.Error(
|
||||
"AUTH04", "Schwerer Fehler",
|
||||
"CSRF-Fehler in der Kommunikation mit dem Anmeldeserver. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
|
||||
fun webserverNotRunningError() = AuthResult.Error(
|
||||
"AUTH05", "Schwerer Fehler",
|
||||
"Der interne Webserver konnte nicht gestartet werden. Keine Anmeldung möglich."
|
||||
)
|
||||
|
||||
fun requestTokenError(error: String, description: String?, status: HttpStatusCode) =
|
||||
AuthResult.Error(
|
||||
"AUTH06", error, "$description ($status)"
|
||||
)
|
||||
|
||||
fun requestTokenError(status: HttpStatusCode, description: String) = AuthResult.Error(
|
||||
"AUTH07", "HTTP-Fehler $status beim Abholen der Zugriffstoken", description
|
||||
)
|
||||
|
||||
fun requestTokenCompromiseError() = AuthResult.Error(
|
||||
"AUTH08", "Schwerer Fehler",
|
||||
"Der Anmeldeserver lieferte ungültige Daten. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
|
||||
fun requestTokenUnknownError() = AuthResult.Error(
|
||||
"AUTH09", "Schwerer Fehler",
|
||||
"Ungültige Daten beim Abholen der Zugriffstoken. Mögliche Ursachen: Netzwerkfehler, Serverfehler oder Malware."
|
||||
)
|
||||
}
|
||||
|
||||
@Single
|
||||
fun createKeycloakConfig(config: ConfigProperties): KeycloakConfig {
|
||||
|
||||
// TODO muss aus config geholt werden (<--> DI??)
|
||||
|
||||
val keycloakServerHostname = config["keycloakServer"] ?: ""
|
||||
val keycloakRealm = config["keycloakRealm"] ?: ""
|
||||
val keycloakClient = config["keycloakClient"] ?: ""
|
||||
|
||||
return KeycloakConfig(keycloakServerHostname, keycloakRealm, keycloakClient)
|
||||
}
|
||||
|
||||
class KeycloakConfig(
|
||||
val keycloakServerHostname: String,
|
||||
keycloakRealm: String,
|
||||
val keycloakClient: String,
|
||||
) {
|
||||
companion object {
|
||||
const val LOCALHOST = "127.0.0.1"
|
||||
const val CALLBACK_PATH = "/callback"
|
||||
const val SCOPE = "openid"
|
||||
const val PKCE_METHOD = "S256"
|
||||
|
||||
// TODO die Hash-Berechnung ist nicht portabel, die beiden fn müssen je Platform implementiert werden
|
||||
// fun generateRandomString(): String {
|
||||
// val bytes = ByteArray(64)
|
||||
// SecureRandom().nextBytes(bytes)
|
||||
// return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
// }
|
||||
|
||||
// fun generateCodeChallenge(codeVerifier: String): String {
|
||||
// val bytes = codeVerifier.toByteArray(Charsets.US_ASCII)
|
||||
// val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
// return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
|
||||
// }
|
||||
}
|
||||
|
||||
private val keycloakBaseUrl = "/realms/$keycloakRealm/protocol/openid-connect"
|
||||
|
||||
val authorizationEndpoint = "$keycloakBaseUrl/auth"
|
||||
val tokenEndpoint = "$keycloakBaseUrl/token"
|
||||
val certificatesEndpoint = "$keycloakBaseUrl/certs"
|
||||
val userinfoEndpoint = "$keycloakBaseUrl/userinfo"
|
||||
|
||||
}
|
||||
|
||||
internal class AuthRun(val kc: KeycloakConfig) {
|
||||
val codeVerifier = generateRandomString()
|
||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
val csrfProtectionState = generateRandomString()
|
||||
|
||||
val resultDeferred = CompletableDeferred<AuthResult>()
|
||||
private var redirectUri_: String? = null
|
||||
val redirectUri: String
|
||||
get() = redirectUri_
|
||||
?: throw IllegalStateException("redirectUri not set, can only be when embedded server is running")
|
||||
|
||||
fun buildRedirectUri(port: Int) {
|
||||
redirectUri_ = "http://${KeycloakConfig.LOCALHOST}:$port${KeycloakConfig.CALLBACK_PATH}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.keinekekse.app.service.TokenService
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Single
|
||||
class Authenticator(
|
||||
val tokenService: TokenService,
|
||||
private val keycloakConfig: KeycloakConfig,
|
||||
|
||||
) : Closeable {
|
||||
private val httpClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json { ignoreUnknownKeys = true }
|
||||
)
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10000
|
||||
connectTimeoutMillis = 5000
|
||||
}
|
||||
}
|
||||
|
||||
private val _logonState = MutableStateFlow<AuthResult>(AuthResult.None())
|
||||
val logonState = _logonState.asStateFlow()
|
||||
|
||||
override fun close() = httpClient.close()
|
||||
|
||||
suspend fun authenticate(): BearerTokens? {
|
||||
logger.debug { "Authenticator: authenticate()" }
|
||||
_logonState.update { doAuthentication() }
|
||||
if (logonState.value is AuthResult.Success) {
|
||||
return tokenService.getTokens()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun requestRefreshToken(tokens: BearerTokens?): BearerTokens? {
|
||||
logger.debug { "Authenticator: requestRefreshToken()" }
|
||||
val config = AuthRun(keycloakConfig)
|
||||
|
||||
val response = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.tokenEndpoint
|
||||
}.toString(),
|
||||
formParameters = parameters {
|
||||
append("grant_type", "refresh_token")
|
||||
append("client_id", config.kc.keycloakClient)
|
||||
append("refresh_token", tokens?.refreshToken ?: "")
|
||||
}
|
||||
)
|
||||
val result = validateRequestTokenResponse(response)
|
||||
_logonState.update { result.first }
|
||||
|
||||
return if (result.first is AuthResult.Success) {
|
||||
logger.debug { "Authenticator: Refresh token successful." }
|
||||
tokenService.setTokens(result.second)
|
||||
result.second
|
||||
} else {
|
||||
tokenService.setTokens(null)
|
||||
// session expired || token is invalid
|
||||
if (result.first is AuthResult.Error
|
||||
&& (result.first as AuthResult.Error).message == "invalid_grant"
|
||||
) {
|
||||
logger.debug { "Authenticator: Session expired or token invalid. Re-authenticating..." }
|
||||
authenticate()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun doAuthentication(): AuthResult {
|
||||
logger.debug { "Authenticator: doAuthentication()" }
|
||||
val config = AuthRun(keycloakConfig)
|
||||
if (!testServerConnection(config)) {
|
||||
return AuthErrors.noLoginServerError()
|
||||
}
|
||||
var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
||||
try {
|
||||
server = createCallbackServer(config).start(wait = false)
|
||||
val port = server.engine.resolvedConnectors().first().port
|
||||
config.buildRedirectUri(port)
|
||||
|
||||
openAuthorizationInBrowser(config)
|
||||
|
||||
return config.resultDeferred.await()
|
||||
|
||||
} catch (e: Exception) {
|
||||
return AuthErrors.loginError(e.message)
|
||||
} finally {
|
||||
server?.stop(1000, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
// private fun isPortOpen(port: Int): Boolean =
|
||||
// try {
|
||||
// // TODO Socket ist nicht portabel (JVM only)
|
||||
// Socket(KeycloakConfig.LOCALHOST, port).use { true }
|
||||
// } catch (e: Exception) {
|
||||
// // Port is closed or unreachable
|
||||
// logger.debug { "${e.javaClass.simpleName}: ${e.message}" }
|
||||
// false
|
||||
// }
|
||||
//
|
||||
// private suspend fun waitForServer(port: Int, timeoutSeconds: Int = 3): Boolean {
|
||||
// repeat(timeoutSeconds * 10) {
|
||||
// if (isPortOpen(port)) return true
|
||||
// delay(100)
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
private fun openAuthorizationInBrowser(config: AuthRun) {
|
||||
val authorizationUrl = buildAuthorizationUrl(config)
|
||||
openInBrowser(authorizationUrl)
|
||||
// // TODO momentan nur für JVM, Rest ist noch zu eruieren
|
||||
// if (!Desktop.isDesktopSupported()) {
|
||||
// throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
||||
// }
|
||||
// val desktop = Desktop.getDesktop()
|
||||
// if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||
// throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
||||
// }
|
||||
//
|
||||
// desktop.browse(authorizationUrl.toURI())
|
||||
}
|
||||
|
||||
private fun buildAuthorizationUrl(config: AuthRun): Url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.authorizationEndpoint
|
||||
parameters.append("response_type", "code")
|
||||
parameters.append("client_id", config.kc.keycloakClient)
|
||||
parameters.append("redirect_uri", config.redirectUri)
|
||||
parameters.append("scope", KeycloakConfig.SCOPE)
|
||||
parameters.append("state", config.csrfProtectionState)
|
||||
parameters.append("code_challenge", config.codeChallenge)
|
||||
parameters.append("code_challenge_method", KeycloakConfig.PKCE_METHOD)
|
||||
}
|
||||
|
||||
private fun createCallbackServer(config: AuthRun) =
|
||||
embeddedServer(CIO, port = 0, host = KeycloakConfig.LOCALHOST) {
|
||||
routing {
|
||||
get(KeycloakConfig.CALLBACK_PATH) {
|
||||
val code = call.request.queryParameters["code"]
|
||||
|
||||
var authResult =
|
||||
validateAuthorizationRequest(call.request, config.csrfProtectionState)
|
||||
|
||||
if (authResult is AuthResult.Success) {
|
||||
authResult = requestTokens(config, code)
|
||||
}
|
||||
|
||||
val (text, status) = createGoodByeMessage(authResult)
|
||||
call.respondText(text, contentType = ContentType.Text.Html, status = status)
|
||||
|
||||
if (!config.resultDeferred.isCompleted)
|
||||
config.resultDeferred.complete(authResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun htmlEscape(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
|
||||
private fun createGoodByeMessage(authResult: AuthResult): Pair<String, HttpStatusCode> {
|
||||
var text: String
|
||||
var status: HttpStatusCode
|
||||
when (authResult) {
|
||||
is AuthResult.Success -> {
|
||||
text =
|
||||
"<html><body><h1>Login erfolgreich! Du kannst das Fenster schließen.</h1></body></html>"
|
||||
status = HttpStatusCode.OK
|
||||
}
|
||||
|
||||
is AuthResult.Error -> {
|
||||
text = "<html><body><h1>Fehler bei der Anmeldung.</h1><h2>${
|
||||
htmlEscape(authResult.message)
|
||||
}</h2><h3>${htmlEscape(authResult.details ?: "")}</h3></body></html>"
|
||||
status = HttpStatusCode.BadRequest
|
||||
}
|
||||
|
||||
is AuthResult.None -> {
|
||||
text =
|
||||
"<html><body><h1>Fehler: Anmeldeversuch liefert keinen Status, das hätte nicht passieren sollen.</h1></body></html>"
|
||||
status = HttpStatusCode.InternalServerError
|
||||
}
|
||||
}
|
||||
return Pair(text, status)
|
||||
}
|
||||
|
||||
private fun validateAuthorizationRequest(
|
||||
request: RoutingRequest,
|
||||
csrfProtectionState: String,
|
||||
): AuthResult {
|
||||
/*
|
||||
- state != csrfProt -> Fehler: Kommunikation mit dem Anmeldeserver hat nicht geklappt,
|
||||
könnte auf Malware hindeuten, Abbruch
|
||||
throw Fatal error authenticating user -> exit app
|
||||
- wenn error =! null -> error + error_description anzeigen
|
||||
rc NULL == keine auth -> app muss entscheiden
|
||||
- code == null -> Fehler: Server liefert ungültige Daten,
|
||||
könnte auf Probleme bei der Netzwerkverbindung oder beim Anmeldeserver hindeuten
|
||||
rc NULL -> app
|
||||
*/
|
||||
if (request.queryParameters["error"] != null) {
|
||||
return AuthErrors.loginCommunicationError(
|
||||
request.queryParameters["error"],
|
||||
request.queryParameters["error_description"]
|
||||
)
|
||||
}
|
||||
if (request.queryParameters["state"] != csrfProtectionState) {
|
||||
return AuthErrors.loginCompromisedError()
|
||||
}
|
||||
return AuthResult.Success()
|
||||
}
|
||||
|
||||
/* Antwort von Keycloak:
|
||||
{
|
||||
"access_token": "eyJhbG ...
|
||||
"expires_in": 300,
|
||||
"refresh_expires_in": 0,
|
||||
"refresh_token": "eyJhb ...
|
||||
"token_type": "Bearer",
|
||||
"id_token": "eyJ ...
|
||||
"not-before-policy": 1769817270,
|
||||
"session_state": "YhlNjIuW-lk1aQwwD9xm4edJ",
|
||||
"scope": "openid profile email"
|
||||
}
|
||||
*/
|
||||
|
||||
@Serializable
|
||||
data class TokenResponse(
|
||||
@SerialName("access_token") val accessToken: String,
|
||||
@SerialName("expires_in") val expiresIn: Int,
|
||||
@SerialName("refresh_expires_in") val refreshExpiresIn: Int,
|
||||
@SerialName("refresh_token") val refreshToken: String,
|
||||
@SerialName("token_type") val tokenType: String,
|
||||
@SerialName("id_token") val idToken: String?,
|
||||
@SerialName("not-before-policy") val notBeforePolicy: Int?,
|
||||
@SerialName("session_state") val sessionState: String?,
|
||||
val scope: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TokenErrorResponse(
|
||||
@SerialName("error") val error: String,
|
||||
@SerialName("error_description") val errorDescription: String?,
|
||||
)
|
||||
|
||||
private suspend fun requestTokens(config: AuthRun, authCode: String?): AuthResult {
|
||||
if (authCode == null) {
|
||||
return AuthErrors.requestTokenCompromiseError()
|
||||
}
|
||||
val response: HttpResponse = httpClient.submitForm(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.tokenEndpoint
|
||||
}.toString(),
|
||||
formParameters = parameters {
|
||||
append("grant_type", "authorization_code")
|
||||
append("client_id", config.kc.keycloakClient)
|
||||
append("code", authCode)
|
||||
append("redirect_uri", config.redirectUri)
|
||||
append("code_verifier", config.codeVerifier)
|
||||
}
|
||||
)
|
||||
val result = validateRequestTokenResponse(response)
|
||||
when (result.first) {
|
||||
is AuthResult.Success -> {
|
||||
tokenService.setTokens(result.second)
|
||||
}
|
||||
|
||||
else -> {
|
||||
tokenService.setTokens(null)
|
||||
}
|
||||
}
|
||||
return result.first
|
||||
}
|
||||
|
||||
suspend fun validateRequestTokenResponse(response: HttpResponse): Pair<AuthResult, BearerTokens?> {
|
||||
if (response.status != HttpStatusCode.OK) {
|
||||
try {
|
||||
val errorResponse = response.body<TokenErrorResponse>()
|
||||
return Pair(
|
||||
AuthErrors.requestTokenError(
|
||||
errorResponse.error, errorResponse.errorDescription, response.status
|
||||
), null
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
val errorResponse = response.bodyAsText()
|
||||
return Pair(AuthErrors.requestTokenError(response.status, errorResponse), null)
|
||||
}
|
||||
}
|
||||
val tokenResponse: TokenResponse = try {
|
||||
response.body<TokenResponse>()
|
||||
} catch (_: Exception) {
|
||||
// Fehler beim JSON-Konvertieren
|
||||
return Pair(AuthErrors.requestTokenUnknownError(), null)
|
||||
}
|
||||
|
||||
return Pair(
|
||||
AuthResult.Success(),
|
||||
BearerTokens(
|
||||
tokenResponse.accessToken,
|
||||
tokenResponse.refreshToken
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun testServerConnection(config: AuthRun): Boolean {
|
||||
val response: HttpResponse = httpClient.request(
|
||||
url = buildUrl {
|
||||
protocol = URLProtocol.HTTPS
|
||||
host = config.kc.keycloakServerHostname
|
||||
encodedPath = config.kc.certificatesEndpoint
|
||||
}
|
||||
)
|
||||
return response.status.value < 400
|
||||
}
|
||||
|
||||
/*
|
||||
access token:
|
||||
|
||||
{
|
||||
"exp": 1769839803,
|
||||
"iat": 1769839503,
|
||||
"auth_time": 1769839502,
|
||||
"jti": "onrtac:f0ea9436-470d-7d1d-fd9d-b43d2f075c46",
|
||||
"iss": "https://kec.keinekekse.net/realms/app-server-realm",
|
||||
"aud": "account",
|
||||
"sub": "b5b40fa3-468f-443c-8eb0-e220bdd353bc",
|
||||
"typ": "Bearer",
|
||||
"azp": "pkce-book-app",
|
||||
"sid": "n_TeXWIUSV66OkYwoc0D1og9",
|
||||
"acr": "1",
|
||||
"allowed-origins": [
|
||||
"http://127.0.0.1",
|
||||
"https://kas.keinekekse.net"
|
||||
],
|
||||
"realm_access": {
|
||||
"roles": [
|
||||
"offline_access",
|
||||
"default-roles-app-server-realm",
|
||||
"uma_authorization",
|
||||
"book-admin"
|
||||
]
|
||||
},
|
||||
"resource_access": {
|
||||
"account": {
|
||||
"roles": [
|
||||
"manage-account",
|
||||
"manage-account-links",
|
||||
"view-profile"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scope": "openid profile email",
|
||||
"email_verified": true,
|
||||
"name": "Hartmut Bauer",
|
||||
"preferred_username": "hb42",
|
||||
"given_name": "Hartmut",
|
||||
"family_name": "Bauer",
|
||||
"email": "hb242@pm.me"
|
||||
}
|
||||
|
||||
-> realm_access:
|
||||
|
||||
implementation(libs.ktor.serverAuthJwt)
|
||||
|
||||
val decod = JWT().decodeJwt(tokenResponse.accessToken!!)
|
||||
// claims, die nur String enthalten: getClaim("claim-name")
|
||||
// für andere: "{" -> asMap<String, Any> => cast!
|
||||
// "[" -> asList(Class<T>)
|
||||
val roles: List<String> = decod.getClaim("realm_access").asMap()["roles"] as List<String>
|
||||
roles.forEach { println(it) }
|
||||
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package net.keinekekse.app.di
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Configuration
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
@Module
|
||||
@Configuration
|
||||
@ComponentScan("net.keinekekse.app")
|
||||
class KoinModulesShared
|
||||
@@ -0,0 +1,69 @@
|
||||
package net.keinekekse.app.service
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.auth.*
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.keinekekse.app.auth.Authenticator
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Single
|
||||
fun createHttpClient(authenticator: Authenticator): HttpClient {
|
||||
val client = HttpClient {
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens {
|
||||
logger.debug { "HTTPClient: loadTokens" }
|
||||
authenticator.tokenService.getTokens()
|
||||
}
|
||||
refreshTokens {
|
||||
logger.debug { "HTTPClient: refreshTokens" }
|
||||
if (oldTokens != null) {
|
||||
logger.debug { "HTTPClient: refresh token present - requestRefreshToken()" }
|
||||
authenticator.requestRefreshToken(oldTokens)
|
||||
} else {
|
||||
logger.debug { "HTTPClient: no tokens - reAuthenticate()" }
|
||||
authenticator.authenticate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
prettyPrint = true
|
||||
allowTrailingComma = true
|
||||
ignoreUnknownKeys = true
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 10000
|
||||
connectTimeoutMillis = 5000
|
||||
}
|
||||
}
|
||||
|
||||
client.plugin(HttpSend).intercept { request ->
|
||||
logger.debug { "HTTPCli: intercept ${request.url}" }
|
||||
val originalCall = execute(request)
|
||||
logger.debug { "HTTPCli: response status ${originalCall.response.status}" }
|
||||
originalCall
|
||||
// val originalCall = execute(request)
|
||||
// if (originalCall.response.status.value !in 100..399) {
|
||||
// execute(request)
|
||||
// } else {
|
||||
// originalCall
|
||||
// }
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package net.keinekekse.app.service
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import io.ktor.client.plugins.auth.providers.*
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
@Single
|
||||
class TokenService {
|
||||
private var tokens: BearerTokens? = null
|
||||
|
||||
fun getTokens(): BearerTokens? = tokens
|
||||
|
||||
fun setTokens(tokens: BearerTokens? = null) {
|
||||
this.tokens = tokens
|
||||
logger.debug { "set tokens" }
|
||||
logger.debug { "Access Token: ${tokens?.accessToken}" }
|
||||
logger.debug { "Refresh Token: ${tokens?.refreshToken}" }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import io.ktor.http.*
|
||||
import java.awt.Desktop
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
|
||||
actual fun openInBrowser(url: Url) {
|
||||
if (!Desktop.isDesktopSupported()) {
|
||||
throw IllegalStateException("Desktop API not supported (cannot open browser automatically).")
|
||||
}
|
||||
val desktop = Desktop.getDesktop()
|
||||
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
|
||||
throw IllegalStateException("BROWSE action not supported (cannot open browser automatically).")
|
||||
}
|
||||
|
||||
desktop.browse(url.toURI())
|
||||
|
||||
}
|
||||
|
||||
actual fun generateRandomString(): String {
|
||||
val bytes = ByteArray(64)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
|
||||
}
|
||||
|
||||
actual fun generateCodeChallenge(codeVerifier: String): String {
|
||||
val bytes = codeVerifier.toByteArray(Charsets.US_ASCII)
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import io.ktor.http.*
|
||||
|
||||
actual fun openInBrowser(url: Url) {
|
||||
TODO("Implement openInBrowser for macOS")
|
||||
}
|
||||
|
||||
actual fun generateRandomString(): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun generateCodeChallenge(codeVerifier: String): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package net.keinekekse.app.auth
|
||||
|
||||
import io.ktor.http.*
|
||||
|
||||
actual fun openInBrowser(url: Url) {
|
||||
TODO("Implement openInBrowser for WASM/JS")
|
||||
}
|
||||
|
||||
actual fun generateRandomString(): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
actual fun generateCodeChallenge(codeVerifier: String): String {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
Reference in new issue
Block a user