Skip to content

Commit

Permalink
feat: support http basic auth
Browse files Browse the repository at this point in the history
  • Loading branch information
jimirocks committed Sep 27, 2024
1 parent efbdaa9 commit 3b03af3
Show file tree
Hide file tree
Showing 6 changed files with 78 additions and 26 deletions.
11 changes: 2 additions & 9 deletions examples/kotlin/src/main/kotlin/LoxoneClientExample.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package cz.smarteon.loxone.example

import cz.smarteon.loxone.LoxoneAuth
import cz.smarteon.loxone.LoxoneClient
import cz.smarteon.loxone.LoxoneCredentials
import cz.smarteon.loxone.LoxoneEndpoint
import cz.smarteon.loxone.LoxoneProfile
import cz.smarteon.loxone.LoxoneTokenAuthenticator
import cz.smarteon.loxone.callForMsg
import cz.smarteon.loxone.ktor.KtorHttpLoxoneClient
import cz.smarteon.loxone.message.ApiInfo
Expand All @@ -14,12 +12,7 @@ suspend fun main(args: Array<String>) {
val endpoint = LoxoneEndpoint.fromUrl(args[0])
val loxoneClient: LoxoneClient = KtorHttpLoxoneClient(
endpoint,
LoxoneTokenAuthenticator(
LoxoneProfile(
endpoint,
LoxoneCredentials(args[1], args[2])
)
)
LoxoneAuth.Basic(args[1], args[2])
)

println(loxoneClient.callRaw("jdev/cfg/api"))
Expand Down
38 changes: 38 additions & 0 deletions src/commonMain/kotlin/LoxoneAuth.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package cz.smarteon.loxone

/**
* Represents Loxone authentication method.
*/
sealed interface LoxoneAuth {
/**
* No authentication.
*/
data object None : LoxoneAuth

/**
* Token based authentication. Can be used both for HTTP and WebSocket.
* @param[authenticator] Token authenticator.
*/
class Token(val authenticator: LoxoneTokenAuthenticator) : LoxoneAuth

/**
* Basic authentication. Only suitable for HTTP.
* @param[username] Loxone username.
* @param[password] Loxone password.
*/
class Basic(val username: String, val password: String) : LoxoneAuth {
constructor(credentials: LoxoneCredentials) : this(credentials.username, credentials.password)
constructor(profile: LoxoneProfile) : this(
requireNotNull(profile.credentials) { "Credentials are required for Basic authentication" }
)
}

/**
* Returns token authenticator if this authentication is token based.
*/
val tokenAuthenticator: LoxoneTokenAuthenticator?
get() = when (this) {
is Token -> authenticator
else -> null
}
}
File renamed without changes.
20 changes: 12 additions & 8 deletions src/commonMain/kotlin/ktor/KtorHttpLoxoneClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package cz.smarteon.loxone.ktor
import cz.smarteon.loxone.Codec
import cz.smarteon.loxone.Command
import cz.smarteon.loxone.HttpLoxoneClient
import cz.smarteon.loxone.LoxoneAuth
import cz.smarteon.loxone.LoxoneEndpoint
import cz.smarteon.loxone.LoxoneResponse
import cz.smarteon.loxone.LoxoneTokenAuthenticator
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.client.*
import io.ktor.client.call.*
Expand All @@ -19,14 +19,14 @@ import kotlin.jvm.JvmOverloads

class KtorHttpLoxoneClient internal constructor(
private val endpoint: LoxoneEndpoint,
private val authenticator: LoxoneTokenAuthenticator? = null,
private val authentication: LoxoneAuth = LoxoneAuth.None,
httpClientEngine: HttpClientEngine? = null
) : HttpLoxoneClient {

@JvmOverloads constructor(
endpoint: LoxoneEndpoint,
authenticator: LoxoneTokenAuthenticator? = null
) : this(endpoint, authenticator, null)
authentication: LoxoneAuth = LoxoneAuth.None,
) : this(endpoint, authentication, null)

Check warning on line 29 in src/commonMain/kotlin/ktor/KtorHttpLoxoneClient.kt

View check run for this annotation

Codecov / codecov/patch

src/commonMain/kotlin/ktor/KtorHttpLoxoneClient.kt#L28-L29

Added lines #L28 - L29 were not covered by tests

private val logger = KotlinLogging.logger {}

Expand All @@ -49,7 +49,7 @@ class KtorHttpLoxoneClient internal constructor(

override suspend fun <RESPONSE : LoxoneResponse> call(command: Command<RESPONSE>): RESPONSE {
if (command.authenticated) {
authenticator?.ensureAuthenticated(this)
authentication.tokenAuthenticator?.ensureAuthenticated(this)
}

return httpClient.get {
Expand All @@ -60,7 +60,7 @@ class KtorHttpLoxoneClient internal constructor(
}

override suspend fun callRaw(command: String): String {
authenticator?.ensureAuthenticated(this)
authentication.tokenAuthenticator?.ensureAuthenticated(this)
return httpClient.get {
commandRequest {
appendEncodedPathSegments(command)
Expand All @@ -72,7 +72,7 @@ class KtorHttpLoxoneClient internal constructor(
override suspend fun close() {
try {
logger.debug { "Closing authenticator" }
authenticator?.close(this@KtorHttpLoxoneClient)
authentication.tokenAuthenticator?.close(this@KtorHttpLoxoneClient)
} catch (e: Exception) {
logger.error(e) { "Error closing authenticator" }
}
Expand All @@ -81,12 +81,16 @@ class KtorHttpLoxoneClient internal constructor(
}

private fun HttpRequestBuilder.commandRequest(addAuth: Boolean = true, pathBuilder: URLBuilder.() -> Unit) {
if (addAuth && authentication is LoxoneAuth.Basic) {
basicAuth(authentication.username, authentication.password)
}
url {
protocol = if (endpoint.useSsl) URLProtocol.HTTPS else URLProtocol.HTTP
host = endpoint.host
appendEncodedPathSegments(endpoint.path)
pathBuilder()
if (addAuth && authenticator != null) {
if (addAuth && authentication is LoxoneAuth.Token) {
val authenticator = authentication.authenticator
parameters.append("autht", authenticator.tokenHash("http-autht"))
parameters.append("user", authenticator.user)
}
Expand Down
33 changes: 25 additions & 8 deletions src/commonTest/kotlin/ktor/HttpLoxoneClientTest.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package cz.smarteon.loxone.ktor

import cz.smarteon.loxone.LoxoneAuth
import cz.smarteon.loxone.LoxoneCredentials
import cz.smarteon.loxone.LoxoneEndpoint.Companion.local
import cz.smarteon.loxone.LoxoneProfile
Expand All @@ -22,7 +23,7 @@ import io.ktor.http.*

class HttpLoxoneClientTest : WordSpec({

val mockEngine = MockEngine {request ->
val mockEngine = MockEngine { request ->
fun respondJson(body: String) = respond(body, headers = headersOf("Content-Type", "application/json"))
fun respondHtmlError(status: HttpStatusCode) =
respondError(status, htmlError(status), headersOf("Content-Type", "text/html"))
Expand All @@ -31,9 +32,17 @@ class HttpLoxoneClientTest : WordSpec({
when {
path == "/jdev/cfg/api" -> respondJson(okMsg("dev/cfg/api", API_INFO_MSG_VAL))

path == "/jdev/sys/authTest" -> {
path == "/jdev/sys/tokenAuthTest" -> {
if (request.url.encodedQuery.contains("autht")) {
respondJson(okMsg("dev/sys/authTest", "authenticated"))
respondJson(okMsg("dev/sys/tokenAuthTest", "authenticated"))
} else {
respondHtmlError(HttpStatusCode.Unauthorized)
}
}

path == "/jdev/sys/basicAuthTest" -> {
if (request.headers.contains("Authorization")) {
respondJson(okMsg("dev/sys/basicAuthTest", "authenticated"))
} else {
respondHtmlError(HttpStatusCode.Unauthorized)
}
Expand All @@ -51,7 +60,7 @@ class HttpLoxoneClientTest : WordSpec({
}

"not authenticated client" should {
val client = KtorHttpLoxoneClient(local("10.0.1.77"), null, mockEngine)
val client = KtorHttpLoxoneClient(local("10.0.1.77"), LoxoneAuth.None, mockEngine)

"call raw" {
client.callRaw("jdev/cfg/api") shouldBe okMsg("dev/cfg/api", API_INFO_MSG_VAL)
Expand All @@ -76,14 +85,22 @@ class HttpLoxoneClientTest : WordSpec({
"authenticated client" should {
val endpoint = local("10.0.1.77")
val profile = LoxoneProfile(endpoint, LoxoneCredentials("user", "pass"))
val client = KtorHttpLoxoneClient(endpoint, LoxoneTokenAuthenticator(profile), mockEngine)

"call authenticated" {
val response = client.call(sysCommand<LoxoneMsgVal>("authTest"))
"call authenticated by token" {
val client =
KtorHttpLoxoneClient(endpoint, LoxoneAuth.Token(LoxoneTokenAuthenticator(profile)), mockEngine)
val response = client.call(sysCommand<LoxoneMsgVal>("tokenAuthTest"))
response.code shouldBe "200"
client.close()
}

client.close()
"call authenticated by basic auth" {
val client =
KtorHttpLoxoneClient(endpoint, LoxoneAuth.Basic(profile), mockEngine)
val response = client.call(sysCommand<LoxoneMsgVal>("basicAuthTest"))
response.code shouldBe "200"
client.close()
}
}

})
2 changes: 1 addition & 1 deletion src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class LoxoneClientAT : WordSpec() {
val endpoint = LoxoneEndpoint.fromUrl(address)
val authenticator = LoxoneTokenAuthenticator(LoxoneProfile(endpoint, LoxoneCredentials(user, password)))

httpClient = KtorHttpLoxoneClient(endpoint, authenticator)
httpClient = KtorHttpLoxoneClient(endpoint, LoxoneAuth.Token(authenticator))
websocketClient = KtorWebsocketLoxoneClient(endpoint, authenticator)

include(commonAT(httpClient))
Expand Down

0 comments on commit 3b03af3

Please sign in to comment.