From 6b950f7b2812788cf6b172b5db97bcfb47b54989 Mon Sep 17 00:00:00 2001 From: Yahor Berdnikau Date: Wed, 22 Nov 2017 21:56:16 +0100 Subject: [PATCH] Extract password param providing in separate interceptors. Added interceptor for api version before 1.12.0 and after. Signed-off-by: Yahor Berdnikau --- .../api/subsonic/GetStreamUrlTest.kt | 3 +- .../api/subsonic/SubsonicApiPasswordTest.kt | 16 +---- .../PasswordHexInterceptorTest.kt | 33 ++++++++++ .../PasswordMD5InterceptorTest.kt | 37 +++++++++++ .../api/subsonic/SubsonicAPIClient.kt | 64 +++---------------- .../api/subsonic/interceptors/PasswordExt.kt | 22 +++++++ .../interceptors/PasswordHexInterceptor.kt | 25 ++++++++ .../interceptors/PasswordMD5Interceptor.kt | 41 ++++++++++++ 8 files changed, 172 insertions(+), 69 deletions(-) create mode 100644 subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordHexInterceptorTest.kt create mode 100644 subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordMD5InterceptorTest.kt create mode 100644 subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordExt.kt create mode 100644 subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordHexInterceptor.kt create mode 100644 subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordMD5Interceptor.kt diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/GetStreamUrlTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/GetStreamUrlTest.kt index 5fc17035..828e7af5 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/GetStreamUrlTest.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/GetStreamUrlTest.kt @@ -6,6 +6,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions.V1_6_0 +import org.moire.ultrasonic.api.subsonic.interceptors.toHexBytes import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule /** @@ -24,7 +25,7 @@ class GetStreamUrlTest { USERNAME, PASSWORD, V1_6_0, CLIENT_ID) val baseExpectedUrl = mockWebServerRule.mockWebServer.url("").toString() expectedUrl = "$baseExpectedUrl/rest/stream.view?id=$id&u=$USERNAME" + - "&p=${client.passwordHex}&v=${V1_6_0.restApiVersion}&c=$CLIENT_ID&f=json" + "&v=${V1_6_0.restApiVersion}&c=$CLIENT_ID&f=json&p=enc:${PASSWORD.toHexBytes()}" } @Test diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiPasswordTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiPasswordTest.kt index e09f5746..0b6f23e3 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiPasswordTest.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicApiPasswordTest.kt @@ -1,18 +1,15 @@ package org.moire.ultrasonic.api.subsonic import org.amshove.kluent.`should contain` -import org.amshove.kluent.`should equal` import org.amshove.kluent.`should not contain` -import org.apache.commons.codec.binary.Hex import org.junit.Test -import java.security.MessageDigest /** * Integration test for [SubsonicAPIClient] that checks proper user password handling. */ class SubsonicApiPasswordTest : SubsonicAPIClientTest() { @Test - fun `Should pass password hash and salt in query params for api version 1 13 0`() { + fun `Should pass PasswordMD5Interceptor in query params for api version 1 13 0`() { val clientV12 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID) mockWebServerRule.enqueueResponse("ping_ok.json") @@ -23,17 +20,11 @@ class SubsonicApiPasswordTest : SubsonicAPIClientTest() { requestLine `should contain` "&s=" requestLine `should contain` "&t=" requestLine `should not contain` "&p=enc:" - - val salt = requestLine.split('&').find { it.startsWith("s=") }?.substringAfter('=') - val token = requestLine.split('&').find { it.startsWith("t=") }?.substringAfter('=') - val expectedToken = String(Hex.encodeHex(MessageDigest.getInstance("MD5") - .digest("$PASSWORD$salt".toByteArray()), false)) - token!! `should equal` expectedToken } } @Test - fun `Should pass hex encoded password in query params for api version 1 12 0`() { + fun `Should pass PasswordHexInterceptor in query params for api version 1 12 0`() { val clientV11 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID) mockWebServerRule.enqueueResponse("ping_ok.json") @@ -44,9 +35,6 @@ class SubsonicApiPasswordTest : SubsonicAPIClientTest() { requestLine `should not contain` "&s=" requestLine `should not contain` "&t=" requestLine `should contain` "&p=enc:" - val passParam = requestLine.split('&').find { it.startsWith("p=enc:") } - val encodedPassword = String(Hex.encodeHex(PASSWORD.toByteArray(), false)) - passParam `should equal` "p=enc:$encodedPassword" } } } diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordHexInterceptorTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordHexInterceptorTest.kt new file mode 100644 index 00000000..50b4d5bf --- /dev/null +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordHexInterceptorTest.kt @@ -0,0 +1,33 @@ +package org.moire.ultrasonic.api.subsonic.interceptors + +import okhttp3.Interceptor +import okhttp3.mockwebserver.MockResponse +import org.amshove.kluent.`should contain` +import org.amshove.kluent.`should not contain` +import org.apache.commons.codec.binary.Hex +import org.junit.Test +import org.moire.ultrasonic.api.subsonic.PASSWORD + +/** + * Integration test for [PasswordHexInterceptor]. + */ +class PasswordHexInterceptorTest : BaseInterceptorTest() { + private val password = "some-password" + + override val interceptor: Interceptor get() = PasswordHexInterceptor(password) + + @Test + fun `Should pass hex encoded password in query params`() { + mockWebServerRule.mockWebServer.enqueue(MockResponse()) + val request = createRequest { } + + client.newCall(request).execute() + + with(mockWebServerRule.mockWebServer.takeRequest()) { + requestLine `should not contain` "s=" + requestLine `should not contain` "t=" + val encodedPassword = String(Hex.encodeHex(PASSWORD.toByteArray(), false)) + requestLine `should contain` "p=enc:$encodedPassword" + } + } +} diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordMD5InterceptorTest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordMD5InterceptorTest.kt new file mode 100644 index 00000000..0be5b889 --- /dev/null +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordMD5InterceptorTest.kt @@ -0,0 +1,37 @@ +package org.moire.ultrasonic.api.subsonic.interceptors + +import okhttp3.Interceptor +import okhttp3.mockwebserver.MockResponse +import org.amshove.kluent.`should contain` +import org.amshove.kluent.`should not contain` +import org.apache.commons.codec.binary.Hex +import org.junit.Test +import java.security.MessageDigest + +/** + * Integration test for [PasswordMD5Interceptor]. + */ +class PasswordMD5InterceptorTest : BaseInterceptorTest() { + private val password = "some-password" + override val interceptor: Interceptor get() = PasswordMD5Interceptor(password) + + @Test + fun `Should pass password hash and salt in query params`() { + mockWebServerRule.mockWebServer.enqueue(MockResponse()) + val request = createRequest { } + + client.newCall(request).execute() + + with(mockWebServerRule.mockWebServer.takeRequest()) { + requestLine `should contain` "s=" + requestLine `should contain` "t=" + requestLine `should not contain` "p=enc:" + + val salt = requestLine.split('&').find { it.startsWith("s=") } + ?.substringAfter('=')?.substringBefore(" ") + val expectedToken = String(Hex.encodeHex(MessageDigest.getInstance("MD5") + .digest("$password$salt".toByteArray()), false)) + requestLine `should contain` "t=$expectedToken" + } + } +} diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt index 8e088d9a..19eb6415 100644 --- a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt @@ -4,21 +4,17 @@ import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.readValue -import okhttp3.HttpUrl import okhttp3.OkHttpClient import okhttp3.ResponseBody import okhttp3.logging.HttpLoggingInterceptor +import org.moire.ultrasonic.api.subsonic.interceptors.PasswordHexInterceptor +import org.moire.ultrasonic.api.subsonic.interceptors.PasswordMD5Interceptor import org.moire.ultrasonic.api.subsonic.interceptors.RangeHeaderInterceptor import org.moire.ultrasonic.api.subsonic.response.StreamResponse import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.jackson.JacksonConverterFactory -import java.lang.IllegalStateException -import java.math.BigInteger -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException -import java.security.SecureRandom import java.util.concurrent.TimeUnit.MILLISECONDS private const val READ_TIMEOUT = 60_000L @@ -36,10 +32,6 @@ class SubsonicAPIClient(baseUrl: String, clientProtocolVersion: SubsonicAPIVersions, clientID: String, debug: Boolean = false) { - companion object { - internal val HEX_ARRAY = "0123456789ABCDEF".toCharArray() - } - private val okHttpClient = OkHttpClient.Builder() .readTimeout(READ_TIMEOUT, MILLISECONDS) .addInterceptor { chain -> @@ -47,9 +39,6 @@ class SubsonicAPIClient(baseUrl: String, val originalRequest = chain.request() val newUrl = originalRequest.url().newBuilder() .addQueryParameter("u", username) - .also { - it.addPasswordQueryParam(clientProtocolVersion) - } .addQueryParameter("v", clientProtocolVersion.restApiVersion) .addQueryParameter("c", clientID) .addQueryParameter("f", "json") @@ -57,11 +46,9 @@ class SubsonicAPIClient(baseUrl: String, chain.proceed(originalRequest.newBuilder().url(newUrl).build()) } .addInterceptor(RangeHeaderInterceptor()) - .also { - if (debug) { - it.addLogging() - } - }.build() + .apply { if (debug) addLogging() } + .addPasswordQueryParam(clientProtocolVersion) + .build() private val jacksonMapper = ObjectMapper() .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true) @@ -147,50 +134,19 @@ class SubsonicAPIClient(baseUrl: String, return url } - private val salt: String by lazy { - val secureRandom = SecureRandom() - BigInteger(130, secureRandom).toString(32) - } - - private val passwordMD5Hash: String by lazy { - try { - val md5Digest = MessageDigest.getInstance("MD5") - md5Digest.digest("$password$salt".toByteArray()).toHexBytes() - } catch (e: NoSuchAlgorithmException) { - throw IllegalStateException(e) - } - } - - internal val passwordHex: String by lazy { - "enc:${password.toHexBytes()}" - } - - private fun String.toHexBytes(): String { - return this.toByteArray().toHexBytes() - } - - private fun ByteArray.toHexBytes(): String { - val hexChars = CharArray(this.size * 2) - for (j in 0..this.lastIndex) { - val v = this[j].toInt().and(0xFF) - hexChars[j * 2] = HEX_ARRAY[v.ushr(4)] - hexChars[j * 2 + 1] = HEX_ARRAY[v.and(0x0F)] - } - return String(hexChars) - } - private fun OkHttpClient.Builder.addLogging() { val loggingInterceptor = HttpLoggingInterceptor() loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY this.addInterceptor(loggingInterceptor) } - private fun HttpUrl.Builder.addPasswordQueryParam(clientProtocolVersion: SubsonicAPIVersions) { + private fun OkHttpClient.Builder.addPasswordQueryParam( + clientProtocolVersion: SubsonicAPIVersions): OkHttpClient.Builder { if (clientProtocolVersion < SubsonicAPIVersions.V1_13_0) { - this.addQueryParameter("p", passwordHex) + this.addInterceptor(PasswordHexInterceptor(password)) } else { - this.addQueryParameter("t", passwordMD5Hash) - this.addQueryParameter("s", salt) + this.addInterceptor(PasswordMD5Interceptor(password)) } + return this } } diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordExt.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordExt.kt new file mode 100644 index 00000000..307fc49e --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordExt.kt @@ -0,0 +1,22 @@ +// Contains common extension functions for password interceptors +package org.moire.ultrasonic.api.subsonic.interceptors + +private val hexCharsArray = "0123456789ABCDEF".toCharArray() + +/** + * Converts string to hex representation. + */ +fun String.toHexBytes(): String = this.toByteArray().toHexBytes() + +/** + * Converts given [ByteArray] to corresponding hex chars representation. + */ +fun ByteArray.toHexBytes(): String { + val hexChars = CharArray(this.size * 2) + for (j in 0..this.lastIndex) { + val v = this[j].toInt().and(0xFF) + hexChars[j * 2] = hexCharsArray[v.ushr(4)] + hexChars[j * 2 + 1] = hexCharsArray[v.and(0x0F)] + } + return String(hexChars) +} diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordHexInterceptor.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordHexInterceptor.kt new file mode 100644 index 00000000..bec49519 --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordHexInterceptor.kt @@ -0,0 +1,25 @@ +package org.moire.ultrasonic.api.subsonic.interceptors + +import okhttp3.Interceptor +import okhttp3.Interceptor.Chain +import okhttp3.Response +import kotlin.LazyThreadSafetyMode.NONE + +/** + * Adds password param converted to hex string in request url. + * + * Should enabled for request that runs again [org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions.V1_12_0] + * or lower. + */ +class PasswordHexInterceptor(private val password: String) : Interceptor { + private val passwordHex: String by lazy(NONE) { + "enc:${password.toHexBytes()}" + } + + override fun intercept(chain: Chain): Response { + val originalRequest = chain.request() + val updatedUrl = originalRequest.url().newBuilder() + .addQueryParameter("p", passwordHex).build() + return chain.proceed(originalRequest.newBuilder().url(updatedUrl).build()) + } +} diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordMD5Interceptor.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordMD5Interceptor.kt new file mode 100644 index 00000000..0e6312e8 --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/interceptors/PasswordMD5Interceptor.kt @@ -0,0 +1,41 @@ +package org.moire.ultrasonic.api.subsonic.interceptors + +import okhttp3.Interceptor +import okhttp3.Interceptor.Chain +import okhttp3.Response +import java.math.BigInteger +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom + +/** + * Adds password param as MD5 hash with random salt. Salt is also added as a param. + * + * Should be enabled for requests against [org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions.V1_13_0] + * and above. + */ +class PasswordMD5Interceptor(private val password: String) : Interceptor { + private val salt: String by lazy { + val secureRandom = SecureRandom() + BigInteger(130, secureRandom).toString(32) + } + + private val passwordMD5Hash: String by lazy { + try { + val md5Digest = MessageDigest.getInstance("MD5") + md5Digest.digest("$password$salt".toByteArray()).toHexBytes() + } catch (e: NoSuchAlgorithmException) { + throw IllegalStateException(e) + } + } + + override fun intercept(chain: Chain): Response { + val originalRequest = chain.request() + val updatedUrl = originalRequest.url().newBuilder() + .addQueryParameter("t", passwordMD5Hash) + .addQueryParameter("s", salt) + .build() + + return chain.proceed(originalRequest.newBuilder().url(updatedUrl).build()) + } +}