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(websocket): introduce authwithtoken #22

Merged
merged 5 commits into from
Apr 18, 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: 8 additions & 3 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ on:
type: string
required: false
default: '.'
secrets:
GRADLE_ENCRYPTION_KEY:
required: true
CODECOV_TOKEN:
required: false

jobs:
gradle-check:
Expand Down Expand Up @@ -36,7 +41,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 }}
1 change: 1 addition & 0 deletions .github/workflows/java-example-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ jobs:
uses: ./.github/workflows/check.yml
with:
projectDir: 'examples/java'
secrets: inherit
1 change: 1 addition & 0 deletions .github/workflows/kotlin-example-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ jobs:
uses: ./.github/workflows/check.yml
with:
projectDir: 'examples/kotlin'
secrets: inherit
1 change: 1 addition & 0 deletions .github/workflows/loxone-client-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ on:
jobs:
call-check:
uses: ./.github/workflows/check.yml
secrets: inherit
4 changes: 2 additions & 2 deletions examples/kotlin/src/main/kotlin/LoxoneClientExample.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
val endpoint = LoxoneEndpoint.fromUrl(args[0])
val loxoneClient: LoxoneClient = HttpLoxoneClient(
val loxoneClient: LoxoneClient = KtorHttpLoxoneClient(
endpoint,
LoxoneTokenAuthenticator(
LoxoneProfile(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
val endpoint = LoxoneEndpoint.fromUrl(args[0])
val loxoneClient: LoxoneClient = WebsocketLoxoneClient(
val loxoneClient: LoxoneClient = KtorWebsocketLoxoneClient(
endpoint,
LoxoneTokenAuthenticator(
LoxoneProfile(
Expand Down
3 changes: 3 additions & 0 deletions src/commonMain/kotlin/LoxoneClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ interface LoxoneClient {
suspend fun close()
}

interface HttpLoxoneClient : LoxoneClient
interface WebsocketLoxoneClient : LoxoneClient

suspend inline fun <reified VAL : LoxoneMsgVal> LoxoneClient.callForMsg(command: LoxoneMsgCommand<VAL>): VAL {
val msg = call(command)
return if (msg.code == command.expectedCode) {
Expand Down
43 changes: 42 additions & 1 deletion src/commonMain/kotlin/LoxoneCommands.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ 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
import kotlin.jvm.JvmStatic

Expand All @@ -16,7 +19,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") {}
Expand All @@ -26,6 +29,44 @@ 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<Token>(
"getjwt",
credentialsHash,
user,
permission.id.toString(),
clientId,
clientInfo,
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: 41 additions & 26 deletions src/commonMain/kotlin/LoxoneTokenAuthenticator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
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
Expand All @@ -31,49 +30,65 @@

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(
commandGetToken(
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)

Check warning on line 67 in src/commonMain/kotlin/LoxoneTokenAuthenticator.kt

View check run for this annotation

Codecov / codecov/patch

src/commonMain/kotlin/LoxoneTokenAuthenticator.kt#L67

Added line #L67 was not covered by tests
}
}

state.needsRefresh -> {
TODO("refresh and merge token")

Check warning on line 72 in src/commonMain/kotlin/LoxoneTokenAuthenticator.kt

View check run for this annotation

Codecov / codecov/patch

src/commonMain/kotlin/LoxoneTokenAuthenticator.kt#L72

Added line #L72 was not covered by tests
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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" }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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" }
}
Expand Down
2 changes: 1 addition & 1 deletion src/commonMain/kotlin/message/MessageHeader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading