From 4e7ce4e2d4b2f758d969d6d6da28a18ac61a8b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Mikul=C3=A1=C5=A1ek?= Date: Sun, 7 Apr 2024 11:26:42 +0200 Subject: [PATCH 1/5] refactor: move the gettoken command to LoxoneCommands --- src/commonMain/kotlin/LoxoneCommands.kt | 28 +++++++++++++++++++ .../kotlin/LoxoneTokenAuthenticator.kt | 3 +- src/commonMain/kotlin/message/Token.kt | 22 --------------- src/commonTest/kotlin/LoxoneCommandsTest.kt | 20 +++++++++++++ 4 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/commonMain/kotlin/LoxoneCommands.kt b/src/commonMain/kotlin/LoxoneCommands.kt index 1dca268..2157419 100644 --- a/src/commonMain/kotlin/LoxoneCommands.kt +++ b/src/commonMain/kotlin/LoxoneCommands.kt @@ -2,6 +2,8 @@ package cz.smarteon.loxone import cz.smarteon.loxone.message.EmptyLoxoneMsgVal import cz.smarteon.loxone.message.LoxoneMsg +import cz.smarteon.loxone.message.Token +import cz.smarteon.loxone.message.TokenPermission import cz.smarteon.loxone.message.sysCommand import kotlin.jvm.JvmStatic @@ -26,6 +28,32 @@ object LoxoneCommands { */ object Tokens { + /** + * Command to get new token. + * @param credentialsHash The credentials hash obtained by [LoxoneCrypto.loxoneHashing] + * from [LoxoneCredentials]. + * @param user The user to get the token for. + * @param permission The permission of the token. + * @param clientId Unique identifier of this client. + * @param clientInfo Information about this client. + */ + @JvmStatic + fun get( + credentialsHash: String, + user: String, + permission: TokenPermission, + clientId: String = LoxoneClientSettings.DEFAULT_CLIENT_ID, + clientInfo: String = LoxoneClientSettings.DEFAULT_CLIENT_INFO + ) = sysCommand( + "getjwt", + credentialsHash, + user, + permission.id.toString(), + clientId, + clientInfo, + authenticated = false + ) + /** * Command to kill token. * @param tokenHash The token hash to kill. diff --git a/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt b/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt index d088392..d23b041 100644 --- a/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt +++ b/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt @@ -5,7 +5,6 @@ import cz.smarteon.loxone.LoxoneCrypto.loxoneHashing import cz.smarteon.loxone.message.Hashing import cz.smarteon.loxone.message.Hashing.Companion.commandForUser import cz.smarteon.loxone.message.Token -import cz.smarteon.loxone.message.Token.Companion.commandGetToken import cz.smarteon.loxone.message.TokenState import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.sync.Mutex @@ -48,7 +47,7 @@ class LoxoneTokenAuthenticator @JvmOverloads constructor( state.isExpired -> { logger.debug { "Token expired, requesting new one" } token = client.callForMsg( - commandGetToken( + Tokens.get( loxoneHashing(profile.credentials!!.password, checkNotNull(hashing), "getttoken", user), user, settings.tokenPermission, diff --git a/src/commonMain/kotlin/message/Token.kt b/src/commonMain/kotlin/message/Token.kt index 55714b5..c1242e3 100644 --- a/src/commonMain/kotlin/message/Token.kt +++ b/src/commonMain/kotlin/message/Token.kt @@ -4,7 +4,6 @@ import cz.smarteon.loxone.LoxoneTime import kotlinx.datetime.Clock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlin.jvm.JvmStatic /** * Represents Loxone authentication token. @@ -61,25 +60,4 @@ data class Token( result = 31 * result + filled.hashCode() return result } - - companion object { - - @JvmStatic - fun commandGetToken( - tokenHash: String, - user: String, - permission: TokenPermission, - clientId: String, - clientInfo: String - ) = - sysCommand( - "getjwt", - tokenHash, - user, - permission.id.toString(), - clientId, - clientInfo, - authenticated = false - ) - } } diff --git a/src/commonTest/kotlin/LoxoneCommandsTest.kt b/src/commonTest/kotlin/LoxoneCommandsTest.kt index a14075c..76a79d6 100644 --- a/src/commonTest/kotlin/LoxoneCommandsTest.kt +++ b/src/commonTest/kotlin/LoxoneCommandsTest.kt @@ -1,6 +1,8 @@ package cz.smarteon.loxone import cz.smarteon.loxone.message.EmptyLoxoneMsgVal +import cz.smarteon.loxone.message.Token +import cz.smarteon.loxone.message.TokenPermission import io.kotest.assertions.asClue import io.kotest.core.spec.style.ShouldSpec import io.kotest.matchers.shouldBe @@ -14,4 +16,22 @@ class LoxoneCommandsTest : ShouldSpec({ it.expectedCode shouldBe "401" } } + + should("create gettoken command") { + LoxoneCommands.Tokens.get("hash", "user", TokenPermission.WEB).asClue { + it.pathSegments shouldBe listOf( + "jdev", + "sys", + "getjwt", + "hash", + "user", + "2", + "df184362-73fc-5d3e-ab0ec7c1c3e5bb2e", + "loxoneKotlin" + ) + it.valueType shouldBe Token::class + it.authenticated shouldBe false + it.expectedCode shouldBe "200" + } + } }) From 934fa02f2b199a1f153982c89bf74b30f1244180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Mikul=C3=A1=C5=A1ek?= Date: Thu, 11 Apr 2024 20:02:35 +0200 Subject: [PATCH 2/5] refactor: declare interfaces for both Http and Websocket clients --- .../kotlin/src/main/kotlin/LoxoneClientExample.kt | 4 ++-- .../src/main/kotlin/LoxoneWebsocketClientExample.kt | 4 ++-- src/commonMain/kotlin/LoxoneClient.kt | 3 +++ src/commonMain/kotlin/LoxoneCommands.kt | 2 +- .../{HttpLoxoneClient.kt => KtorHttpLoxoneClient.kt} | 10 +++++----- ...tLoxoneClient.kt => KtorWebsocketLoxoneClient.kt} | 8 ++++---- src/commonMain/kotlin/message/MessageHeader.kt | 2 +- src/commonTest/kotlin/ktor/HttpLoxoneClientTest.kt | 4 ++-- src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt | 12 ++++++------ src/jvmTest/kotlin/ktor/WebsocketLoxoneClientIT.kt | 2 +- 10 files changed, 27 insertions(+), 24 deletions(-) rename src/commonMain/kotlin/ktor/{HttpLoxoneClient.kt => KtorHttpLoxoneClient.kt} (92%) rename src/commonMain/kotlin/ktor/{WebsocketLoxoneClient.kt => KtorWebsocketLoxoneClient.kt} (97%) diff --git a/examples/kotlin/src/main/kotlin/LoxoneClientExample.kt b/examples/kotlin/src/main/kotlin/LoxoneClientExample.kt index 8fd4044..4c5449d 100644 --- a/examples/kotlin/src/main/kotlin/LoxoneClientExample.kt +++ b/examples/kotlin/src/main/kotlin/LoxoneClientExample.kt @@ -6,13 +6,13 @@ 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.HttpLoxoneClient +import cz.smarteon.loxone.ktor.KtorHttpLoxoneClient import cz.smarteon.loxone.message.ApiInfo suspend fun main(args: Array) { val endpoint = LoxoneEndpoint.fromUrl(args[0]) - val loxoneClient: LoxoneClient = HttpLoxoneClient( + val loxoneClient: LoxoneClient = KtorHttpLoxoneClient( endpoint, LoxoneTokenAuthenticator( LoxoneProfile( diff --git a/examples/kotlin/src/main/kotlin/LoxoneWebsocketClientExample.kt b/examples/kotlin/src/main/kotlin/LoxoneWebsocketClientExample.kt index 2bab2d0..037ae2c 100644 --- a/examples/kotlin/src/main/kotlin/LoxoneWebsocketClientExample.kt +++ b/examples/kotlin/src/main/kotlin/LoxoneWebsocketClientExample.kt @@ -6,13 +6,13 @@ 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.WebsocketLoxoneClient +import cz.smarteon.loxone.ktor.KtorWebsocketLoxoneClient import cz.smarteon.loxone.message.ApiInfo suspend fun main(args: Array) { val endpoint = LoxoneEndpoint.fromUrl(args[0]) - val loxoneClient: LoxoneClient = WebsocketLoxoneClient( + val loxoneClient: LoxoneClient = KtorWebsocketLoxoneClient( endpoint, LoxoneTokenAuthenticator( LoxoneProfile( diff --git a/src/commonMain/kotlin/LoxoneClient.kt b/src/commonMain/kotlin/LoxoneClient.kt index 408f2a5..3cc2b40 100644 --- a/src/commonMain/kotlin/LoxoneClient.kt +++ b/src/commonMain/kotlin/LoxoneClient.kt @@ -13,6 +13,9 @@ interface LoxoneClient { suspend fun close() } +interface HttpLoxoneClient : LoxoneClient +interface WebsocketLoxoneClient : LoxoneClient + suspend inline fun LoxoneClient.callForMsg(command: LoxoneMsgCommand): VAL { val msg = call(command) return if (msg.code == command.expectedCode) { diff --git a/src/commonMain/kotlin/LoxoneCommands.kt b/src/commonMain/kotlin/LoxoneCommands.kt index 2157419..2da2287 100644 --- a/src/commonMain/kotlin/LoxoneCommands.kt +++ b/src/commonMain/kotlin/LoxoneCommands.kt @@ -18,7 +18,7 @@ import kotlin.jvm.JvmStatic object LoxoneCommands { /** - * Keep alive command used solely in [cz.smarteon.loxone.ktor.WebsocketLoxoneClient] to ensure connection alive + * Keep alive command used solely in [cz.smarteon.loxone.WebsocketLoxoneClient] to ensure connection alive * functionality. */ val KEEP_ALIVE = object : NoResponseCommand("keepalive") {} diff --git a/src/commonMain/kotlin/ktor/HttpLoxoneClient.kt b/src/commonMain/kotlin/ktor/KtorHttpLoxoneClient.kt similarity index 92% rename from src/commonMain/kotlin/ktor/HttpLoxoneClient.kt rename to src/commonMain/kotlin/ktor/KtorHttpLoxoneClient.kt index cbc3302..0888cb5 100644 --- a/src/commonMain/kotlin/ktor/HttpLoxoneClient.kt +++ b/src/commonMain/kotlin/ktor/KtorHttpLoxoneClient.kt @@ -2,7 +2,7 @@ package cz.smarteon.loxone.ktor import cz.smarteon.loxone.Codec import cz.smarteon.loxone.Command -import cz.smarteon.loxone.LoxoneClient +import cz.smarteon.loxone.HttpLoxoneClient import cz.smarteon.loxone.LoxoneEndpoint import cz.smarteon.loxone.LoxoneResponse import cz.smarteon.loxone.LoxoneTokenAuthenticator @@ -17,11 +17,11 @@ import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlin.jvm.JvmOverloads -class HttpLoxoneClient internal constructor( +class KtorHttpLoxoneClient internal constructor( private val endpoint: LoxoneEndpoint, private val authenticator: LoxoneTokenAuthenticator? = null, httpClientEngine: HttpClientEngine? = null -) : LoxoneClient { +) : HttpLoxoneClient { @JvmOverloads constructor( endpoint: LoxoneEndpoint, @@ -40,7 +40,7 @@ class HttpLoxoneClient internal constructor( install(Logging) { logger = object : Logger { override fun log(message: String) { - this@HttpLoxoneClient.logger.debug { message } + this@KtorHttpLoxoneClient.logger.debug { message } } } level = LogLevel.ALL @@ -72,7 +72,7 @@ class HttpLoxoneClient internal constructor( override suspend fun close() { try { logger.debug { "Closing authenticator" } - authenticator?.close(this@HttpLoxoneClient) + authenticator?.close(this@KtorHttpLoxoneClient) } catch (e: Exception) { logger.error(e) { "Error closing authenticator" } } diff --git a/src/commonMain/kotlin/ktor/WebsocketLoxoneClient.kt b/src/commonMain/kotlin/ktor/KtorWebsocketLoxoneClient.kt similarity index 97% rename from src/commonMain/kotlin/ktor/WebsocketLoxoneClient.kt rename to src/commonMain/kotlin/ktor/KtorWebsocketLoxoneClient.kt index 5be5d7a..2f917e5 100644 --- a/src/commonMain/kotlin/ktor/WebsocketLoxoneClient.kt +++ b/src/commonMain/kotlin/ktor/KtorWebsocketLoxoneClient.kt @@ -3,11 +3,11 @@ package cz.smarteon.loxone.ktor import cz.smarteon.loxone.Codec import cz.smarteon.loxone.Codec.loxJson import cz.smarteon.loxone.Command -import cz.smarteon.loxone.LoxoneClient import cz.smarteon.loxone.LoxoneCommands import cz.smarteon.loxone.LoxoneEndpoint import cz.smarteon.loxone.LoxoneResponse import cz.smarteon.loxone.LoxoneTokenAuthenticator +import cz.smarteon.loxone.WebsocketLoxoneClient import cz.smarteon.loxone.message.MessageHeader import cz.smarteon.loxone.message.MessageKind import io.github.oshai.kotlinlogging.KotlinLogging @@ -34,12 +34,12 @@ import kotlin.jvm.JvmOverloads import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds -class WebsocketLoxoneClient internal constructor( +class KtorWebsocketLoxoneClient internal constructor( private val client: HttpClient, private val endpoint: LoxoneEndpoint? = null, private val authenticator: LoxoneTokenAuthenticator? = null, dispatcher: CoroutineDispatcher = Dispatchers.Default -) : LoxoneClient { +) : WebsocketLoxoneClient { @JvmOverloads constructor( endpoint: LoxoneEndpoint, @@ -85,7 +85,7 @@ class WebsocketLoxoneClient internal constructor( override suspend fun close() { try { logger.debug { "Closing authenticator" } - authenticator?.close(this@WebsocketLoxoneClient) + authenticator?.close(this@KtorWebsocketLoxoneClient) } catch (e: Exception) { logger.error(e) { "Error closing authenticator" } } diff --git a/src/commonMain/kotlin/message/MessageHeader.kt b/src/commonMain/kotlin/message/MessageHeader.kt index 55d8012..8d0d524 100644 --- a/src/commonMain/kotlin/message/MessageHeader.kt +++ b/src/commonMain/kotlin/message/MessageHeader.kt @@ -6,7 +6,7 @@ package cz.smarteon.loxone.message * @param kind message kind * @param sizeEstimated whether message size is estimated or calculated, `false` if message size is exact * @param messageSize number of bytes in message payload - * @see[cz.smarteon.loxone.ktor.WebsocketLoxoneClient] + * @see[cz.smarteon.loxone.WebsocketLoxoneClient] */ internal data class MessageHeader( val kind: MessageKind, diff --git a/src/commonTest/kotlin/ktor/HttpLoxoneClientTest.kt b/src/commonTest/kotlin/ktor/HttpLoxoneClientTest.kt index 6c7e270..552d778 100644 --- a/src/commonTest/kotlin/ktor/HttpLoxoneClientTest.kt +++ b/src/commonTest/kotlin/ktor/HttpLoxoneClientTest.kt @@ -51,7 +51,7 @@ class HttpLoxoneClientTest : WordSpec({ } "not authenticated client" should { - val client = HttpLoxoneClient(local("10.0.1.77"), null, mockEngine) + val client = KtorHttpLoxoneClient(local("10.0.1.77"), null, mockEngine) "call raw" { client.callRaw("jdev/cfg/api") shouldBe okMsg("dev/cfg/api", API_INFO_MSG_VAL) @@ -76,7 +76,7 @@ class HttpLoxoneClientTest : WordSpec({ "authenticated client" should { val endpoint = local("10.0.1.77") val profile = LoxoneProfile(endpoint, LoxoneCredentials("user", "pass")) - val client = HttpLoxoneClient(endpoint, LoxoneTokenAuthenticator(profile), mockEngine) + val client = KtorHttpLoxoneClient(endpoint, LoxoneTokenAuthenticator(profile), mockEngine) "call authenticated" { val response = client.call(sysCommand("authTest")) diff --git a/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt b/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt index 629ed53..ac305c5 100644 --- a/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt +++ b/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt @@ -1,7 +1,7 @@ package cz.smarteon.loxone -import cz.smarteon.loxone.ktor.HttpLoxoneClient -import cz.smarteon.loxone.ktor.WebsocketLoxoneClient +import cz.smarteon.loxone.ktor.KtorHttpLoxoneClient +import cz.smarteon.loxone.ktor.KtorWebsocketLoxoneClient import cz.smarteon.loxone.message.ApiInfo import io.kotest.core.spec.Spec import io.kotest.core.spec.style.WordSpec @@ -29,8 +29,8 @@ fun commonAT(loxoneClient: LoxoneClient) = wordSpec { } class LoxoneClientAT : WordSpec() { - private val httpClient: HttpLoxoneClient - private val websocketClient: WebsocketLoxoneClient + private val httpClient: KtorHttpLoxoneClient + private val websocketClient: KtorWebsocketLoxoneClient override suspend fun afterSpec(spec: Spec) { super.afterSpec(spec) @@ -46,8 +46,8 @@ class LoxoneClientAT : WordSpec() { val endpoint = LoxoneEndpoint.fromUrl(address) val authenticator = LoxoneTokenAuthenticator(LoxoneProfile(endpoint, LoxoneCredentials(user, password))) - httpClient = HttpLoxoneClient(endpoint, authenticator) - websocketClient = WebsocketLoxoneClient(endpoint, authenticator) + httpClient = KtorHttpLoxoneClient(endpoint, authenticator) + websocketClient = KtorWebsocketLoxoneClient(endpoint, authenticator) // TODO websocket client is failing when both clients are tested in the reverse order than below // it's probably because of the missing authWithToken implementation in the websocket client diff --git a/src/jvmTest/kotlin/ktor/WebsocketLoxoneClientIT.kt b/src/jvmTest/kotlin/ktor/WebsocketLoxoneClientIT.kt index 0c54345..3d69fb5 100644 --- a/src/jvmTest/kotlin/ktor/WebsocketLoxoneClientIT.kt +++ b/src/jvmTest/kotlin/ktor/WebsocketLoxoneClientIT.kt @@ -27,7 +27,7 @@ class WebsocketLoxoneClientIT : StringSpec({ "should call simple command" { val ctx = startTestWebsocketServer() val bgDispatcher = UnconfinedTestDispatcher() - val client = WebsocketLoxoneClient(ctx.testedClient, dispatcher = bgDispatcher) + val client = KtorWebsocketLoxoneClient(ctx.testedClient, dispatcher = bgDispatcher) client.callRaw("jdev/cfg/api") shouldBe okMsg("dev/cfg/api", API_INFO_MSG_VAL) ctx.received.receive() shouldBe "jdev/cfg/api" From c6581945b080f18bc2b1fd0c88beb8ac4b41bd99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Mikul=C3=A1=C5=A1ek?= Date: Sun, 7 Apr 2024 12:38:57 +0200 Subject: [PATCH 3/5] feat(websocket): introduce authwithtoken --- src/commonMain/kotlin/LoxoneCommands.kt | 13 ++++ .../kotlin/LoxoneTokenAuthenticator.kt | 66 ++++++++++++------- src/commonMain/kotlin/message/Token.kt | 25 ++++++- src/commonTest/kotlin/LoxoneCommandsTest.kt | 9 +++ .../kotlin/LoxoneTokenAuthenticatorTest.kt | 52 ++++++++++++--- src/commonTest/kotlin/MockMpSupport.kt | 14 ++++ .../kotlin/message/TestingMessages.kt | 10 +++ src/commonTest/kotlin/message/TokenTest.kt | 22 +++++++ .../kotlin/LoxoneClientAT.kt | 4 +- 9 files changed, 176 insertions(+), 39 deletions(-) create mode 100644 src/commonTest/kotlin/MockMpSupport.kt diff --git a/src/commonMain/kotlin/LoxoneCommands.kt b/src/commonMain/kotlin/LoxoneCommands.kt index 2da2287..d1b2563 100644 --- a/src/commonMain/kotlin/LoxoneCommands.kt +++ b/src/commonMain/kotlin/LoxoneCommands.kt @@ -2,6 +2,7 @@ package cz.smarteon.loxone import cz.smarteon.loxone.message.EmptyLoxoneMsgVal import cz.smarteon.loxone.message.LoxoneMsg +import cz.smarteon.loxone.message.SimpleLoxoneMsgCommand import cz.smarteon.loxone.message.Token import cz.smarteon.loxone.message.TokenPermission import cz.smarteon.loxone.message.sysCommand @@ -54,6 +55,18 @@ object LoxoneCommands { authenticated = false ) + /** + * Command to authenticate with token. + * @param tokenHash The token hash to authenticate with. + * @param user The user to authenticate as. + */ + @JvmStatic + fun auth(tokenHash: String, user: String) = SimpleLoxoneMsgCommand( + listOf("authwithtoken", tokenHash, user), + Token::class, + authenticated = false + ) + /** * Command to kill token. * @param tokenHash The token hash to kill. diff --git a/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt b/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt index d23b041..46b9ce2 100644 --- a/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt +++ b/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt @@ -30,49 +30,65 @@ class LoxoneTokenAuthenticator @JvmOverloads constructor( private var token: Token? by Delegates.observable(repository.getToken(profile)) { _, _, newValue -> if (newValue != null) { + logger.info { + "Got loxone token, valid until: ${newValue.validUntil}, " + + "seconds to expire: ${newValue.secondsToExpireFromNow()}" + } repository.putToken(profile, newValue) } else { repository.removeToken(profile) } } - suspend fun ensureAuthenticated(client: LoxoneClient) = execConditionalWithMutex({ !TokenState(token).isUsable }) { - if (hashing == null) { - hashing = client.callForMsg(commandForUser(user)) - } + private val authWebsockets = mutableSetOf() - val state = TokenState(token) - - when { - state.isExpired -> { - logger.debug { "Token expired, requesting new one" } - token = client.callForMsg( - Tokens.get( - loxoneHashing(profile.credentials!!.password, checkNotNull(hashing), "getttoken", user), - user, - settings.tokenPermission, - settings.clientId, - settings.clientInfo - ) - ) - logger.debug { "Received token: $token" } + suspend fun ensureAuthenticated(client: LoxoneClient) = + execConditionalWithMutex({ !TokenState(token).isUsable || !authWebsockets.contains(client) }) { + if (hashing == null) { + hashing = client.callForMsg(commandForUser(user)) } - state.needsRefresh -> { - TODO("refresh and merge token") - } + val state = TokenState(token) + + when { + state.isExpired -> { + logger.debug { "Token expired, requesting new one" } + token = client.callForMsg( + Tokens.get( + loxoneHashing(profile.credentials!!.password, checkNotNull(hashing), "getttoken", user), + user, + settings.tokenPermission, + settings.clientId, + settings.clientInfo + ) + ) + logger.debug { "Received token: $token" } + if (client is WebsocketLoxoneClient) { + authWebsockets.add(client) + } + } + + state.needsRefresh -> { + TODO("refresh and merge token") + } - else -> { - // TODO("send authwithtoken if websockets") + else -> { + if (client is WebsocketLoxoneClient) { + logger.debug { "Authenticating websocket with token $token" } + val authResponse = client.callForMsg(Tokens.auth(tokenHash("authenticate"), user)) + token = token!!.merge(authResponse) + authWebsockets.add(client) + } + } } } - } suspend fun killToken(client: LoxoneClient) = execConditionalWithMutex({ TokenState(token).isUsable }) { logger.debug { "Going to kill token $token" } client.callForMsg(Tokens.kill(tokenHash("killtoken"), user)) logger.info { "Token killed" } token = null + authWebsockets.remove(client) } suspend fun close(client: LoxoneClient) { diff --git a/src/commonMain/kotlin/message/Token.kt b/src/commonMain/kotlin/message/Token.kt index c1242e3..f6c34e1 100644 --- a/src/commonMain/kotlin/message/Token.kt +++ b/src/commonMain/kotlin/message/Token.kt @@ -16,8 +16,8 @@ import kotlinx.serialization.Serializable */ @Serializable data class Token( - val token: String?, - @Serializable(HexSerializer::class) val key: ByteArray?, + val token: String? = null, + @Serializable(HexSerializer::class) val key: ByteArray? = null, val validUntil: Long, @SerialName("tokenRights") val rights: Int, @SerialName("unsecurePass") val unsecurePassword: Boolean @@ -36,6 +36,27 @@ data class Token( return block(token!!, key!!) } + /** + * Merges the given token to this one and returns the merged token. The [Token.token] and [Token.key] are taken + * from given token only if they are not null, otherwise the values from this token are used. Other properties are + * always taken from given token. + * + * @param other token to merge + * @return new merged token + */ + fun merge(other: Token): Token = + if (this == other) { + this + } else { + Token( + token = other.token ?: token, + key = other.key ?: key, + validUntil = other.validUntil, + rights = other.rights, + unsecurePassword = other.unsecurePassword + ) + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other == null || this::class != other::class) return false diff --git a/src/commonTest/kotlin/LoxoneCommandsTest.kt b/src/commonTest/kotlin/LoxoneCommandsTest.kt index 76a79d6..fe0be38 100644 --- a/src/commonTest/kotlin/LoxoneCommandsTest.kt +++ b/src/commonTest/kotlin/LoxoneCommandsTest.kt @@ -34,4 +34,13 @@ class LoxoneCommandsTest : ShouldSpec({ it.expectedCode shouldBe "200" } } + + should("create authwithtoken command") { + LoxoneCommands.Tokens.auth("hash", "user").asClue { + it.pathSegments shouldBe listOf("authwithtoken", "hash", "user") + it.valueType shouldBe Token::class + it.authenticated shouldBe false + it.expectedCode shouldBe "200" + } + } }) diff --git a/src/commonTest/kotlin/LoxoneTokenAuthenticatorTest.kt b/src/commonTest/kotlin/LoxoneTokenAuthenticatorTest.kt index 54a1fae..0ad6626 100644 --- a/src/commonTest/kotlin/LoxoneTokenAuthenticatorTest.kt +++ b/src/commonTest/kotlin/LoxoneTokenAuthenticatorTest.kt @@ -2,30 +2,64 @@ package cz.smarteon.loxone import cz.smarteon.loxone.LoxoneEndpoint.Companion.local import cz.smarteon.loxone.message.EmptyLoxoneMsgVal +import cz.smarteon.loxone.message.Hashing.Companion.commandForUser import cz.smarteon.loxone.message.LoxoneMsg +import cz.smarteon.loxone.message.TestingLoxValues.HASHING +import cz.smarteon.loxone.message.TestingLoxValues.tokenAuthResponse import cz.smarteon.loxone.message.Token +import io.kotest.assertions.asClue import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.kodein.mock.Mocker +import org.kodein.mock.UsesMocks +@UsesMocks(WebsocketLoxoneClient::class) class LoxoneTokenAuthenticatorTest : ShouldSpec({ - should("kill token on close") { - val profile = LoxoneProfile(local("192.168.7.77"), LoxoneCredentials("user", "pass")) + context("with valid token") { + val profile = LoxoneProfile(local("192.168.7.77"), LoxoneCredentials(USER, "pass")) val token = Token("token", byteArrayOf(1), TimeUtils.currentLoxoneSeconds().plus(310), 0, false) val repository = InMemoryTokenRepository().apply { putToken(profile, token) } val authenticator = LoxoneTokenAuthenticator(profile, repository) val mocker = Mocker() - val client: LoxoneClient = MockLoxoneClient(mocker) + val client: WebsocketLoxoneClient = MockWebsocketLoxoneClient(mocker) mocker.everySuspending { - client.call(isNotNull>()) - } returns LoxoneMsg("killtoken", "401", "") + client.call(commandForUser(USER)) + } returns LoxoneMsg("jdev/sys/getkey2/$USER", "200", HASHING) - authenticator.close(client) - repository.getToken(profile) shouldBe null - mocker.verifyWithSuspend { client.call(isNotNull>()) } + should("authenticate with token") { + mocker.everySuspending { + client.call(isLoxMsgCmdContaining("authwithtoken")) + } returns LoxoneMsg("authwithtoken", "200", tokenAuthResponse(TimeUtils.currentLoxoneSeconds().plus(320))) + + authenticator.ensureAuthenticated(client) + + repository.getToken(profile).shouldNotBeNull().asClue { + it.token shouldBe token.token + it.key shouldBe token.key + it.validUntil shouldBeGreaterThan token.validUntil + } + mocker.verifyWithSuspend(exhaustive = false) { client.call(isNotNull>()) } + } + + should("kill token on close") { + mocker.everySuspending { + client.call(isLoxMsgCmdContaining("killtoken")) + } returns LoxoneMsg("killtoken", "401", "") + + authenticator.close(client) + + repository.getToken(profile) shouldBe null + mocker.verifyWithSuspend(exhaustive = false) { client.call(isNotNull>()) } + } + } +}) { + companion object { + const val USER = "someUserName" } -}) +} diff --git a/src/commonTest/kotlin/MockMpSupport.kt b/src/commonTest/kotlin/MockMpSupport.kt new file mode 100644 index 0000000..2d7ba79 --- /dev/null +++ b/src/commonTest/kotlin/MockMpSupport.kt @@ -0,0 +1,14 @@ +package cz.smarteon.loxone + +import cz.smarteon.loxone.message.LoxoneMsgVal +import org.kodein.mock.ArgConstraint +import org.kodein.mock.ArgConstraintsBuilder + +internal fun ArgConstraintsBuilder.isLoxMsgCmdContaining(vararg segments: String, capture: MutableList>? = null): LoxoneMsgCommand = + isValid(ArgConstraint(capture, { "isLoxMsgCmdContaining $segments" }) { + if (it.pathSegments.containsAll(segments.toList())) { + ArgConstraint.Result.Success + } else { + ArgConstraint.Result.Failure { "Expected command containing $segments, but was ${it.pathSegments}" } + } + }) diff --git a/src/commonTest/kotlin/message/TestingMessages.kt b/src/commonTest/kotlin/message/TestingMessages.kt index 2cadd2c..e2c0927 100644 --- a/src/commonTest/kotlin/message/TestingMessages.kt +++ b/src/commonTest/kotlin/message/TestingMessages.kt @@ -39,4 +39,14 @@ internal object TestingLoxValues { } """.trimIndent() + fun tokenAuthResponse(validUntil: Long) = + // language=JSON + """ + { + "validUntil": $validUntil, + "tokenRights": 1666, + "unsecurePass": false + } + """.trimIndent() + } diff --git a/src/commonTest/kotlin/message/TokenTest.kt b/src/commonTest/kotlin/message/TokenTest.kt index 958db2e..ea634b5 100644 --- a/src/commonTest/kotlin/message/TokenTest.kt +++ b/src/commonTest/kotlin/message/TokenTest.kt @@ -2,6 +2,7 @@ package cz.smarteon.loxone.message import cz.smarteon.loxone.Codec.loxJson import cz.smarteon.loxone.message.TestingLoxValues.token +import cz.smarteon.loxone.message.TestingLoxValues.tokenAuthResponse import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe @@ -16,4 +17,25 @@ class TokenTest : StringSpec({ false ) } + + "should deserialize auth response" { + loxJson.decodeFromString(tokenAuthResponse(342151839)) shouldBe Token( + null, + null, + 342151839, + 1666, + false + ) + } + + "should merge token" { + val original = loxJson.decodeFromString(token(342151839)) + original.merge(Token(validUntil = 342151900, rights = 1667, unsecurePassword = true)) shouldBe Token( + "8E2AA590E996B321C0E17C3FA9F7A3C17BD376CC", + byteArrayOf(68, 68, 50), + 342151900, + 1667, + true + ) + } }) diff --git a/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt b/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt index ac305c5..1595161 100644 --- a/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt +++ b/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt @@ -49,10 +49,8 @@ class LoxoneClientAT : WordSpec() { httpClient = KtorHttpLoxoneClient(endpoint, authenticator) websocketClient = KtorWebsocketLoxoneClient(endpoint, authenticator) - // TODO websocket client is failing when both clients are tested in the reverse order than below - // it's probably because of the missing authWithToken implementation in the websocket client - include(commonAT(websocketClient)) include(commonAT(httpClient)) + include(commonAT(websocketClient)) } private fun getLoxEnv(name: String) = "LOX_$name".let { requireNotNull(System.getenv(it)) { "Please set $it env" } } From 4649684963b695565f1cf8f46fbec23c2dfe9a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Mikul=C3=A1=C5=A1ek?= Date: Mon, 8 Apr 2024 13:26:51 +0200 Subject: [PATCH 4/5] ci: use latest codecov action --- .github/workflows/check.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 04e3dea..313389d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -36,7 +36,7 @@ jobs: run: ./gradlew -p ${{ inputs.projectDir }} koverXmlReport - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 if: ${{ inputs.projectDir == '.' }} - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + token: ${{ secrets.CODECOV_TOKEN }} From 3392f62e8a40f59e97a5f8ef8400f29a24f74ac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Mikul=C3=A1=C5=A1ek?= Date: Mon, 8 Apr 2024 14:19:20 +0200 Subject: [PATCH 5/5] fix(ci): pass the secrets to check properly --- .github/workflows/check.yml | 5 +++++ .github/workflows/java-example-check.yml | 1 + .github/workflows/kotlin-example-check.yml | 1 + .github/workflows/loxone-client-check.yml | 1 + 4 files changed, 8 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 313389d..1118f31 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -9,6 +9,11 @@ on: type: string required: false default: '.' + secrets: + GRADLE_ENCRYPTION_KEY: + required: true + CODECOV_TOKEN: + required: false jobs: gradle-check: diff --git a/.github/workflows/java-example-check.yml b/.github/workflows/java-example-check.yml index e367e3e..4a5eace 100644 --- a/.github/workflows/java-example-check.yml +++ b/.github/workflows/java-example-check.yml @@ -11,3 +11,4 @@ jobs: uses: ./.github/workflows/check.yml with: projectDir: 'examples/java' + secrets: inherit diff --git a/.github/workflows/kotlin-example-check.yml b/.github/workflows/kotlin-example-check.yml index d57d42e..ee10124 100644 --- a/.github/workflows/kotlin-example-check.yml +++ b/.github/workflows/kotlin-example-check.yml @@ -11,3 +11,4 @@ jobs: uses: ./.github/workflows/check.yml with: projectDir: 'examples/kotlin' + secrets: inherit diff --git a/.github/workflows/loxone-client-check.yml b/.github/workflows/loxone-client-check.yml index 762cfa5..1a808c7 100644 --- a/.github/workflows/loxone-client-check.yml +++ b/.github/workflows/loxone-client-check.yml @@ -14,3 +14,4 @@ on: jobs: call-check: uses: ./.github/workflows/check.yml + secrets: inherit