Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support http basic auth #29

Merged
merged 1 commit into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}
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 @@
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 @@

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 @@

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 @@
}

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 @@
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 @@
}

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