Skip to content

Commit

Permalink
feat(websocket): introduce authwithtoken
Browse files Browse the repository at this point in the history
  • Loading branch information
jimirocks committed Apr 7, 2024
1 parent 32a31c9 commit 419ddb9
Show file tree
Hide file tree
Showing 7 changed files with 120 additions and 30 deletions.
13 changes: 13 additions & 0 deletions src/commonMain/kotlin/LoxoneCommands.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
67 changes: 42 additions & 25 deletions src/commonMain/kotlin/LoxoneTokenAuthenticator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<WebsocketLoxoneClient>()

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) {
Expand Down
25 changes: 23 additions & 2 deletions src/commonMain/kotlin/message/Token.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/commonTest/kotlin/LoxoneCommandsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
})
10 changes: 10 additions & 0 deletions src/commonTest/kotlin/message/TestingMessages.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,14 @@ internal object TestingLoxValues {
}
""".trimIndent()

fun tokenAuthResponse(validUntil: Long) =
// language=JSON
"""
{
"validUntil": $validUntil,
"tokenRights": 1666,
"unsecurePass": false
}
""".trimIndent()

}
22 changes: 22 additions & 0 deletions src/commonTest/kotlin/message/TokenTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -16,4 +17,25 @@ class TokenTest : StringSpec({
false
)
}

"should deserialize auth response" {
loxJson.decodeFromString<Token>(tokenAuthResponse(342151839)) shouldBe Token(
null,
null,
342151839,
1666,
false
)
}

"should merge token" {
val original = loxJson.decodeFromString<Token>(token(342151839))
original.merge(Token(validUntil = 342151900, rights = 1667, unsecurePassword = true)) shouldBe Token(
"8E2AA590E996B321C0E17C3FA9F7A3C17BD376CC",
byteArrayOf(68, 68, 50),
342151900,
1667,
true
)
}
})
4 changes: 1 addition & 3 deletions src/jvmAcceptanceTest/kotlin/LoxoneClientAT.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
Expand Down

0 comments on commit 419ddb9

Please sign in to comment.