From f5097c0b01afa01a6cb9d8291f1e2403f8aac78b 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] feat(websocket): introduce authwithtoken --- src/commonMain/kotlin/LoxoneCommands.kt | 13 ++++ .../kotlin/LoxoneTokenAuthenticator.kt | 67 ++++++++++++------- src/commonMain/kotlin/message/Token.kt | 25 ++++++- src/commonTest/kotlin/LoxoneCommandsTest.kt | 9 +++ .../kotlin/message/TestingMessages.kt | 10 +++ src/commonTest/kotlin/message/TokenTest.kt | 22 ++++++ .../kotlin/LoxoneClientAT.kt | 4 +- 7 files changed, 120 insertions(+), 30 deletions(-) diff --git a/src/commonMain/kotlin/LoxoneCommands.kt b/src/commonMain/kotlin/LoxoneCommands.kt index 2157419..106ef99 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..8b9161d 100644 --- a/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt +++ b/src/commonMain/kotlin/LoxoneTokenAuthenticator.kt @@ -2,6 +2,7 @@ package cz.smarteon.loxone import cz.smarteon.loxone.LoxoneCommands.Tokens import cz.smarteon.loxone.LoxoneCrypto.loxoneHashing +import cz.smarteon.loxone.ktor.WebsocketLoxoneClient import cz.smarteon.loxone.message.Hashing import cz.smarteon.loxone.message.Hashing.Companion.commandForUser import cz.smarteon.loxone.message.Token @@ -30,49 +31,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/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 629ed53..957c587 100644 --- a/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt +++ b/src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt @@ -49,10 +49,8 @@ class LoxoneClientAT : WordSpec() { httpClient = HttpLoxoneClient(endpoint, authenticator) websocketClient = WebsocketLoxoneClient(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" } }