diff --git a/.gitignore b/.gitignore index c4791770..b60d1902 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ build/ .gradle/ +.idea +/local.properties .shade-config.properties diff --git a/auth/src/main/kotlin/inkapplications/shade/auth/HueAuthApi.kt b/auth/src/main/kotlin/inkapplications/shade/auth/HueAuthApi.kt index 38a9b23e..84940541 100644 --- a/auth/src/main/kotlin/inkapplications/shade/auth/HueAuthApi.kt +++ b/auth/src/main/kotlin/inkapplications/shade/auth/HueAuthApi.kt @@ -2,9 +2,13 @@ package inkapplications.shade.auth import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import inkapplications.shade.constructs.HueError +import inkapplications.shade.constructs.HueResponse import inkapplications.shade.serialization.converter.FirstInCollection import retrofit2.http.Body +import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Path /** * Hue Bridge Authentication endpoints. @@ -27,6 +31,16 @@ internal interface HueAuthApi { @POST("api/") @FirstInCollection suspend fun createToken(@Body devicetype: DeviceType): AuthToken + + /** + * Validate token. + * + * Send request to a non-existing endpoint to validate token based on error type: + * Invalid token returns error type 1 (unauthorized user) + * Valid token returns error type 4 (method not available) + */ + @GET("api/{token}/connected") + suspend fun validateToken(@Path("token") token: String): HueResponse } /** diff --git a/auth/src/main/kotlin/inkapplications/shade/auth/ShadeAuth.kt b/auth/src/main/kotlin/inkapplications/shade/auth/ShadeAuth.kt index b0c1731d..c2f1e85f 100644 --- a/auth/src/main/kotlin/inkapplications/shade/auth/ShadeAuth.kt +++ b/auth/src/main/kotlin/inkapplications/shade/auth/ShadeAuth.kt @@ -3,10 +3,16 @@ package inkapplications.shade.auth import inkapplications.shade.constructs.ErrorCodes import inkapplications.shade.constructs.ShadeApiError import inkapplications.shade.constructs.ShadeException +import inkapplications.shade.constructs.throwOnFailure import inkapplications.shade.serialization.parse import kotlinx.coroutines.delay import retrofit2.HttpException +/** + * Error type returned by Hue Bridge for a non-existing endpoint. + */ +const val METHOD_NOT_AVAILABLE = 4 + /** * Authentication for the Phillips Hue bridge. */ @@ -21,6 +27,11 @@ interface ShadeAuth { * These do not appear to expire. Store it safely. */ suspend fun awaitToken(retries: Int = 50, timeout: Long = 5000) + + /** + * Validate token. + */ + suspend fun validateToken(token: String): Boolean } /** @@ -30,7 +41,7 @@ internal class ApiAuth( private val authApi: HueAuthApi, private val appId: String, private val storage: TokenStorage -): ShadeAuth { +) : ShadeAuth { override suspend fun awaitToken(retries: Int, timeout: Long) { repeat(retries) { try { @@ -48,4 +59,24 @@ internal class ApiAuth( } throw ShadeException("Auth timed out") } + + /** + * Validate token. + * + * Send request to a non-existing endpoint to validate token based on error type: + * Invalid token returns error type 1 (unauthorized user) + * Valid token returns error type 4 (method not available) + */ + override suspend fun validateToken(token: String): Boolean { + if (token.isNotBlank()) { + try { + authApi.validateToken(token).throwOnFailure() + } catch (error: ShadeApiError) { + return error.hueError.type == METHOD_NOT_AVAILABLE + } catch (error: HttpException) { + throw error.parse() + } + } + return false + } } diff --git a/cli/src/main/kotlin/inkapplications/shade/cli/auth/AuthModule.kt b/cli/src/main/kotlin/inkapplications/shade/cli/auth/AuthModule.kt index 50cf05aa..b4d7d236 100644 --- a/cli/src/main/kotlin/inkapplications/shade/cli/auth/AuthModule.kt +++ b/cli/src/main/kotlin/inkapplications/shade/cli/auth/AuthModule.kt @@ -16,6 +16,10 @@ abstract class AuthModule { @IntoSet abstract fun discover(command: Discover): CliktCommand + @Binds + @IntoSet + abstract fun validate(command: Validate): CliktCommand + @Binds abstract fun storage(fileStorage: FileStorage): TokenStorage } diff --git a/cli/src/main/kotlin/inkapplications/shade/cli/auth/Validate.kt b/cli/src/main/kotlin/inkapplications/shade/cli/auth/Validate.kt new file mode 100644 index 00000000..0b7e7f24 --- /dev/null +++ b/cli/src/main/kotlin/inkapplications/shade/cli/auth/Validate.kt @@ -0,0 +1,28 @@ +package inkapplications.shade.cli.auth + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.option +import dagger.Reusable +import inkapplications.shade.Shade +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +@Reusable +class Validate @Inject constructor( + private val shade: Shade +): CliktCommand( + name = "auth:validate", + help = "Check validity of stored token" +) { + private val token by option( + "--token", + help = "Token value to validate, ex: yOXvTj16z5qx1TWazPXBgZa8vAlgebBmpl5wbxXb" + ) + + override fun run() { + runBlocking { + val valid = shade.auth.validateToken(token.orEmpty()) + echo("Valid token: $valid") + } + } +} diff --git a/http/src/main/kotlin/shade/http/RateLimitInterceptor.kt b/http/src/main/kotlin/shade/http/RateLimitInterceptor.kt index 3017c4ff..0ad08fb9 100644 --- a/http/src/main/kotlin/shade/http/RateLimitInterceptor.kt +++ b/http/src/main/kotlin/shade/http/RateLimitInterceptor.kt @@ -20,7 +20,8 @@ object RateLimitInterceptor: Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() - val limit = request.header(RATE_LIMIT)?.toLong() ?: 0 + val limit = request.header(RATE_LIMIT)?.toLong() + ?: return chain.proceed(request) semaphore.acquire() val response = request.newBuilder() diff --git a/shade/src/main/kotlin/inkapplications/shade/delegates/AuthDelegate.kt b/shade/src/main/kotlin/inkapplications/shade/delegates/AuthDelegate.kt index 71c80ea2..a1f57ea0 100644 --- a/shade/src/main/kotlin/inkapplications/shade/delegates/AuthDelegate.kt +++ b/shade/src/main/kotlin/inkapplications/shade/delegates/AuthDelegate.kt @@ -16,4 +16,5 @@ internal class AuthDelegate( } override suspend fun awaitToken(retries: Int, timeout: Long) = delegate.awaitToken(retries, timeout) + override suspend fun validateToken(token: String) = delegate.validateToken(token) }