From 92b53529f61660834a0cc44d3888930e83692952 Mon Sep 17 00:00:00 2001 From: Timo Bryant Date: Wed, 10 Jan 2024 14:30:06 +0100 Subject: [PATCH] auth groundwork for xs support --- ...cthor.kotlin-common-conventions.gradle.kts | 4 ++ libraries/httpClient/build.gradle.kts | 2 + .../itkl/httpClient/SmartCloudAuthPlugin.kt | 12 +++-- .../de/itkl/httpClient/auth/AuthStrategy.kt | 11 +++++ .../httpClient/auth/AuthenticationToken.kt | 21 ++++++++ .../de/itkl/httpClient/auth/Authenticator.kt | 17 +++++++ .../de/itkl/httpClient/auth/Credentials.kt | 8 +++ .../httpClient/auth/CredentialsProvider.kt | 5 ++ .../de/itkl/httpClient/clients/XsClient.kt | 48 ++++++++++++++++++ .../de/itkl/httpClient/httpClientModule.kt | 10 ++++ .../implementation/SmartCloudAuthStrategy.kt | 38 ++++++++++++++ .../StaticCredentialsProvider.kt | 49 +++++++++++++++++++ .../StaticCredentialsProviderTest.kt | 25 ++++++++++ 13 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/AuthStrategy.kt create mode 100644 libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/AuthenticationToken.kt create mode 100644 libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/Authenticator.kt create mode 100644 libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/Credentials.kt create mode 100644 libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/CredentialsProvider.kt create mode 100644 libraries/httpClient/src/main/kotlin/de/itkl/httpClient/implementation/SmartCloudAuthStrategy.kt create mode 100644 libraries/httpClient/src/main/kotlin/de/itkl/httpClient/implementation/StaticCredentialsProvider.kt create mode 100644 libraries/httpClient/src/test/kotlin/de/itkl/httpClient/implementation/StaticCredentialsProviderTest.kt diff --git a/buildSrc/src/main/kotlin/docthor.kotlin-common-conventions.gradle.kts b/buildSrc/src/main/kotlin/docthor.kotlin-common-conventions.gradle.kts index 1d5113f..b43af62 100644 --- a/buildSrc/src/main/kotlin/docthor.kotlin-common-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/docthor.kotlin-common-conventions.gradle.kts @@ -1,4 +1,6 @@ +import gradle.kotlin.dsl.accessors._6ebf0b5d05ec1eff67605a516c5db18b.implementation import gradle.kotlin.dsl.accessors._d9dcfd1a467b0b6fe90c5571a57aa558.api +import gradle.kotlin.dsl.accessors._d9dcfd1a467b0b6fe90c5571a57aa558.testImplementation import org.gradle.api.plugins.jvm.JvmTestSuite import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -20,6 +22,8 @@ dependencies { api("io.github.oshai:kotlin-logging-jvm:5.1.0") testImplementation("io.insert-koin:koin-test:$koin_version") + testImplementation("ch.qos.logback:logback-classic:1.4.14") + testImplementation("com.willowtreeapps.assertk:assertk:0.28.0") } java { diff --git a/libraries/httpClient/build.gradle.kts b/libraries/httpClient/build.gradle.kts index 5ee7f02..86ef97e 100644 --- a/libraries/httpClient/build.gradle.kts +++ b/libraries/httpClient/build.gradle.kts @@ -12,4 +12,6 @@ dependencies { implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + implementation("com.akuleshov7:ktoml-core:0.5.1") + implementation("com.akuleshov7:ktoml-file:0.5.1") } \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/SmartCloudAuthPlugin.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/SmartCloudAuthPlugin.kt index 479e6ae..b804f84 100644 --- a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/SmartCloudAuthPlugin.kt +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/SmartCloudAuthPlugin.kt @@ -1,17 +1,21 @@ package de.itkl.httpClient +import de.itkl.httpClient.implementation.SmartCloudAuthStrategy import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.plugins.api.* +import io.ktor.client.request.* +import io.ktor.http.* import io.ktor.util.* import org.koin.core.component.KoinComponent private val Log = KotlinLogging.logger { } val smartCloudAuthPlugin = createClientPlugin("SmartCloudAuth") { + val smartCloudAuthStrategy = SmartCloudAuthStrategy() on(Send) { request -> - request.attributes.getOrNull(AttributeKey("user"))?.let { username -> - Log.info { "Provide bearer token for $username" } - TODO("retrieve user token ") + val token = smartCloudAuthStrategy.login(client, request) ?: error("login failed") + request.headers { + set(HttpHeaders.Authorization, "Bearer ${token.toBearer()}") } - TODO() + proceed(request) } } \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/AuthStrategy.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/AuthStrategy.kt new file mode 100644 index 0000000..551c7dd --- /dev/null +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/AuthStrategy.kt @@ -0,0 +1,11 @@ +package de.itkl.httpClient.auth + +import io.ktor.client.* +import io.ktor.client.request.* + +interface AuthStrategy { + suspend fun login( + httpClient: HttpClient, + request: HttpRequestBuilder + ): AuthenticationToken? +} \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/AuthenticationToken.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/AuthenticationToken.kt new file mode 100644 index 0000000..130be6d --- /dev/null +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/AuthenticationToken.kt @@ -0,0 +1,21 @@ +package de.itkl.httpClient.auth + +import kotlinx.datetime.Instant + +sealed class AuthenticationToken { + abstract val expires: Expires + abstract fun toBearer(): String +} + +data class BearerToken(val token: String, val validUntil: Instant) : AuthenticationToken() { + override val expires: Expires + get() = Expires.ExpiresAt(validUntil) + + override fun toBearer(): String { + return "Bearer $token" + } +} +sealed class Expires { + data object Never : Expires() + data class ExpiresAt(val instant: Instant) : Expires() +} \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/Authenticator.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/Authenticator.kt new file mode 100644 index 0000000..37a3063 --- /dev/null +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/Authenticator.kt @@ -0,0 +1,17 @@ +package de.itkl.httpClient.auth + +import io.ktor.http.* +import org.koin.core.component.KoinComponent + +typealias UrlMatcher = (Url) -> Boolean +class Authenticator : KoinComponent { + fun addStrategy(strategy: AuthStrategy, urlMatcher: UrlMatcher) {} + + fun requiresAuthentication(url: Url): Boolean { + TODO() + } + suspend fun authenticate(url: Url, username: String): AuthenticationToken? { + TODO() + } +} + diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/Credentials.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/Credentials.kt new file mode 100644 index 0000000..513ecc8 --- /dev/null +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/Credentials.kt @@ -0,0 +1,8 @@ +package de.itkl.httpClient.auth + +import kotlinx.serialization.Serializable + +sealed class Credentials { + @Serializable + data class LoginAndPassword(val login: String, val password: String) : Credentials() +} \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/CredentialsProvider.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/CredentialsProvider.kt new file mode 100644 index 0000000..824581e --- /dev/null +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/auth/CredentialsProvider.kt @@ -0,0 +1,5 @@ +package de.itkl.httpClient.auth + +interface CredentialsProvider { + suspend fun lookupByUsername(username: String): Credentials? +} \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/clients/XsClient.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/clients/XsClient.kt index e2cd3d3..076de34 100644 --- a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/clients/XsClient.kt +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/clients/XsClient.kt @@ -1,9 +1,57 @@ package de.itkl.httpClient.clients +import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.client.utils.EmptyContent.contentType +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.util.cio.* +import io.ktor.utils.io.* +import io.ktor.utils.io.streams.* import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.isRegularFile +import kotlin.io.path.name + +private val Log = KotlinLogging.logger { } class XsClient : KoinComponent { private val httpClient by inject() + + suspend fun analyse(image: Path) { + Log.info { "Starting analysis for image at path: $image" } + + check(image.isRegularFile()) { + "The provided path $image is not a file" + } + + Log.info { "Submitting form with binary data to http://localhost:8080/api/v1/analyse-async" } + val response = httpClient.submitFormWithBinaryData( + url = "http://localhost:8080/api/v1/analyse-async", + formData { + append( + "image", + image.toChannelProvider(), + Headers.build { + append(HttpHeaders.ContentType, ContentType.defaultForFile(image)) + append(HttpHeaders.ContentDisposition, """filename="${image.name}"""") + } + ) + } + ) + + val responseText = response.bodyAsText() + Log.info { "Form submitted successfully: ${response.status}: $responseText" } + } +} + +fun Path.toChannelProvider(): ChannelProvider { + val file = toFile() + return ChannelProvider(file.length()) { file.readChannel() } } \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/httpClientModule.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/httpClientModule.kt index 1778158..9a60c7f 100644 --- a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/httpClientModule.kt +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/httpClientModule.kt @@ -1,10 +1,20 @@ package de.itkl.httpClient +import de.itkl.httpClient.auth.CredentialsProvider import de.itkl.httpClient.clients.MsOcr +import de.itkl.httpClient.clients.XsClient +import de.itkl.httpClient.implementation.StaticCredentialsProvider import io.ktor.client.* import org.koin.dsl.module +import java.nio.file.Paths val httpClientModule = module { single { createHttpClient() } single { MsOcr() } + single { + val homeDirectory = System.getProperty("user.home") + val credentialsFilePath = Paths.get(homeDirectory, ".auth.toml") + StaticCredentialsProvider.load(credentialsFilePath) + } + single { XsClient() } } \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/implementation/SmartCloudAuthStrategy.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/implementation/SmartCloudAuthStrategy.kt new file mode 100644 index 0000000..dffd38a --- /dev/null +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/implementation/SmartCloudAuthStrategy.kt @@ -0,0 +1,38 @@ +package de.itkl.httpClient.implementation + +import de.itkl.httpClient.auth.* +import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.* +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +private val Log = KotlinLogging.logger { } + +class SmartCloudAuthStrategy : AuthStrategy, KoinComponent { + private val credentialsProvider: CredentialsProvider by inject() + override suspend fun login(httpClient: HttpClient, request: HttpRequestBuilder): AuthenticationToken? { + Log.debug { "Attempting login..." } + val user = request.attributes.getOrNull(AttributeKey("username")) ?: error("No username is specified for this request: $request") + val credentials: Credentials = credentialsProvider.lookupByUsername(user) ?: error("No credentials found for user: $user") + val loginAndPassword: Credentials.LoginAndPassword = credentials as? Credentials.LoginAndPassword ?: error("Only username and password is supported by smartcloud auth login") + + Log.debug { "User: $user, using smartcloud auth login" } + + val response = httpClient.post { + url("https://api.internal.insiders.cloud/1/rest/accounts/authentication/requesttoken") + contentType(ContentType.Application.Json) + setBody(loginAndPassword) + } + check(response.status.isSuccess()) { + "could not login into smart cloud: ${response.bodyAsText()}" + } + val token = response.body() + Log.info { "Login successful. Valid until: ${token.validUntil}" } + return token + } +} \ No newline at end of file diff --git a/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/implementation/StaticCredentialsProvider.kt b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/implementation/StaticCredentialsProvider.kt new file mode 100644 index 0000000..17aed8f --- /dev/null +++ b/libraries/httpClient/src/main/kotlin/de/itkl/httpClient/implementation/StaticCredentialsProvider.kt @@ -0,0 +1,49 @@ +package de.itkl.httpClient.implementation + +import com.akuleshov7.ktoml.Toml +import de.itkl.httpClient.auth.Credentials +import de.itkl.httpClient.auth.CredentialsProvider +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import java.nio.file.Files +import java.nio.file.Path + +private val Log = KotlinLogging.logger { } + +class StaticCredentialsProvider(private val credentials: Map) : CredentialsProvider { + + companion object { + fun load(path: Path): StaticCredentialsProvider { + Log.info { "Loading credentials from path: $path" } + val content = Files.readString(path) + val credentials = Toml.decodeFromString(content).toCredentialsMap() + Log.info { "Credentials loaded successfully" } + return StaticCredentialsProvider(credentials) + } + } + + override suspend fun lookupByUsername(username: String): Credentials? { + Log.info { "Looking up credentials by username: $username" } + val credentialsEntry = credentials[username] + if (credentialsEntry != null) { + Log.info { "Credentials found for username: $username" } + return credentialsEntry + } + Log.info { "No credentials found for username: $username" } + return null + } +} + +@Serializable +data class CredentialsTable( + val credentials: Map> +) { + fun toCredentialsMap(): Map { + return credentials.mapValues { + val login = it.value["Login"] ?: throw IllegalArgumentException("Missing login") + val password = it.value["Password"] ?: throw IllegalArgumentException("Missing password") + Credentials.LoginAndPassword(login, password) + } + } +} \ No newline at end of file diff --git a/libraries/httpClient/src/test/kotlin/de/itkl/httpClient/implementation/StaticCredentialsProviderTest.kt b/libraries/httpClient/src/test/kotlin/de/itkl/httpClient/implementation/StaticCredentialsProviderTest.kt new file mode 100644 index 0000000..05f0be8 --- /dev/null +++ b/libraries/httpClient/src/test/kotlin/de/itkl/httpClient/implementation/StaticCredentialsProviderTest.kt @@ -0,0 +1,25 @@ +package de.itkl.httpClient.implementation + +import assertk.assertThat +import assertk.assertions.isDataClassEqualTo +import assertk.assertions.isNotNull +import de.itkl.httpClient.auth.Credentials +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import java.nio.file.Paths + + +class StaticCredentialsProviderTest { + + @Test + fun `can load credentials file`() = runBlocking { + val target = StaticCredentialsProvider.load(Paths.get("../../assets/credentials.toml")) + val credentials = target.lookupByUsername("test user") + assertThat(credentials) + .isNotNull() + .isDataClassEqualTo(Credentials.LoginAndPassword("TestiTest", "Secret")) + Unit + } + + +} \ No newline at end of file