From f1ab0a3e0c96c118bd78b702b3598d18fac385e6 Mon Sep 17 00:00:00 2001 From: Yahor Berdnikau Date: Sun, 23 Jul 2017 19:46:06 +0200 Subject: [PATCH] Add using new authentication method since 1.13.0. Signed-off-by: Yahor Berdnikau --- dependencies.gradle | 13 +- subsonic-api/build.gradle | 2 + ...nicAPITest.kt => SubsonicAPIClientTest.kt} | 84 ++++++++++--- .../ultrasonic/api/subsonic/SubsonicAPI.kt | 68 ----------- .../api/subsonic/SubsonicAPIClient.kt | 113 ++++++++++++++++++ ultrasonic/build.gradle | 2 + 6 files changed, 190 insertions(+), 92 deletions(-) rename subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/{SubsonicAPITest.kt => SubsonicAPIClientTest.kt} (67%) delete mode 100644 subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPI.kt create mode 100644 subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt diff --git a/dependencies.gradle b/dependencies.gradle index de849abe..a177610f 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -12,11 +12,12 @@ ext.versions = [ retrofit : "2.1.0", jackson : "2.8.7", + okhttp : "3.6.0", junit : "4.12", - mockitoKotlin : "1.3.0", - kluent : "1.15", - okhttp : "3.6.0", + mockitoKotlin : "1.5.0", + kluent : "1.26", + apacheCodecs : "1.10", ] ext.gradlePlugins = [ @@ -30,7 +31,7 @@ ext.androidSupport = [ ] ext.other = [ - kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib-common:$versions.kotlin", + kotlinStdlib : "org.jetbrains.kotlin:kotlin-stdlib:$versions.kotlin", retrofit : "com.squareup.retrofit2:retrofit:$versions.retrofit", gsonConverter : "com.squareup.retrofit2:converter-gson:$versions.retrofit", jacksonConverter : "com.squareup.retrofit2:converter-jackson:$versions.retrofit", @@ -41,7 +42,9 @@ ext.other = [ ext.testing = [ junit : "junit:junit:$versions.junit", kotlinJunit : "org.jetbrains.kotlin:kotlin-test-junit:$versions.kotlin", + kotlinReflect : "org.jetbrains.kotlin:kotlin-reflect:$versions.kotlin", mockitoKotlin : "com.nhaarman:mockito-kotlin:$versions.mockitoKotlin", kluent : "org.amshove.kluent:kluent:$versions.kluent", - mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp" + mockWebServer : "com.squareup.okhttp3:mockwebserver:$versions.okhttp", + apacheCodecs : "commons-codec:commons-codec:$versions.apacheCodecs", ] diff --git a/subsonic-api/build.gradle b/subsonic-api/build.gradle index 1bdb172b..1091d2f5 100644 --- a/subsonic-api/build.gradle +++ b/subsonic-api/build.gradle @@ -16,7 +16,9 @@ dependencies { testCompile testing.junit testCompile testing.kotlinJunit + testCompile testing.kotlinReflect testCompile testing.mockitoKotlin testCompile testing.kluent testCompile testing.mockWebServer + testCompile testing.apacheCodecs } \ No newline at end of file diff --git a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPITest.kt b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClientTest.kt similarity index 67% rename from subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPITest.kt rename to subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClientTest.kt index 49bc6ab4..a8e314c9 100644 --- a/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPITest.kt +++ b/subsonic-api/src/integrationTest/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClientTest.kt @@ -6,21 +6,28 @@ import org.amshove.kluent.`should be` import org.amshove.kluent.`should contain` import org.amshove.kluent.`should equal` import org.amshove.kluent.`should not be` +import org.amshove.kluent.`should not contain` +import org.apache.commons.codec.binary.Hex import org.junit.Before import org.junit.Rule import org.junit.Test -import org.moire.ultrasonic.api.subsonic.models.* +import org.moire.ultrasonic.api.subsonic.models.Artist +import org.moire.ultrasonic.api.subsonic.models.Index +import org.moire.ultrasonic.api.subsonic.models.License +import org.moire.ultrasonic.api.subsonic.models.MusicDirectoryChild +import org.moire.ultrasonic.api.subsonic.models.MusicFolder import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule import retrofit2.Response import java.nio.charset.Charset +import java.security.MessageDigest import java.text.SimpleDateFormat import java.util.* /** - * Integration test for [SubsonicAPI] class. + * Integration test for [SubsonicAPIClient] class. */ -class SubsonicAPITest { +class SubsonicAPIClientTest { companion object { const val USERNAME = "some-user" const val PASSWORD = "some-password" @@ -30,19 +37,58 @@ class SubsonicAPITest { @JvmField @Rule val mockWebServerRule = MockWebServerRule() - private lateinit var api: SubsonicAPI + private lateinit var client: SubsonicAPIClient @Before fun setUp() { - api = SubsonicAPI(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD, + client = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, PASSWORD, CLIENT_VERSION, CLIENT_ID) } + @Test + fun `Should pass password hash and salt in query params for api version 1_13_0`() { + val clientV12 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, + PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID) + enqueueResponse("ping_ok.json") + + clientV12.api.ping().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('=') + 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`() { + val clientV11 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME, + PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID) + enqueueResponse("ping_ok.json") + + clientV11.api.ping().execute() + + with(mockWebServerRule.mockWebServer.takeRequest()) { + 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" + } + } + @Test fun `Should parse ping ok response`() { enqueueResponse("ping_ok.json") - val response = api.getApi().ping().execute() + val response = client.api.ping().execute() assertResponseSuccessful(response) with(response.body()) { @@ -52,14 +98,14 @@ class SubsonicAPITest { @Test fun `Should parse ping error response`() { - checkErrorCallParsed { api.getApi().ping().execute() } + checkErrorCallParsed { client.api.ping().execute() } } @Test fun `Should parse get license ok response`() { enqueueResponse("license_ok.json") - val response = api.getApi().getLicense().execute() + val response = client.api.getLicense().execute() assertResponseSuccessful(response) with(response.body()) { @@ -70,7 +116,7 @@ class SubsonicAPITest { @Test fun `Should parse get license error response`() { - val response = checkErrorCallParsed { api.getApi().getLicense().execute() } + val response = checkErrorCallParsed { client.api.getLicense().execute() } response.license `should be` null } @@ -79,7 +125,7 @@ class SubsonicAPITest { fun `Should parse get music folders ok response`() { enqueueResponse("get_music_folders_ok.json") - val response = api.getApi().getMusicFolders().execute() + val response = client.api.getMusicFolders().execute() assertResponseSuccessful(response) with(response.body()) { @@ -90,7 +136,7 @@ class SubsonicAPITest { @Test fun `Should parse get music folders error response`() { - val response = checkErrorCallParsed { api.getApi().getMusicFolders().execute() } + val response = checkErrorCallParsed { client.api.getMusicFolders().execute() } response.musicFolders `should be` null } @@ -100,7 +146,7 @@ class SubsonicAPITest { // TODO: check for shortcut parsing enqueueResponse("get_indexes_ok.json") - val response = api.getApi().getIndexes(null, null).execute() + val response = client.api.getIndexes(null, null).execute() assertResponseSuccessful(response) response.body().indexes `should not be` null @@ -126,7 +172,7 @@ class SubsonicAPITest { enqueueResponse("get_indexes_ok.json") val musicFolderId = 9L - api.getApi().getIndexes(musicFolderId, null).execute() + client.api.getIndexes(musicFolderId, null).execute() with(mockWebServerRule.mockWebServer.takeRequest()) { requestLine `should contain` "musicFolderId=$musicFolderId" @@ -138,7 +184,7 @@ class SubsonicAPITest { enqueueResponse("get_indexes_ok.json") val ifModifiedSince = System.currentTimeMillis() - api.getApi().getIndexes(null, ifModifiedSince).execute() + client.api.getIndexes(null, ifModifiedSince).execute() with(mockWebServerRule.mockWebServer.takeRequest()) { requestLine `should contain` "ifModifiedSince=$ifModifiedSince" @@ -151,7 +197,7 @@ class SubsonicAPITest { val musicFolderId = 110L val ifModifiedSince = System.currentTimeMillis() - api.getApi().getIndexes(musicFolderId, ifModifiedSince).execute() + client.api.getIndexes(musicFolderId, ifModifiedSince).execute() with(mockWebServerRule.mockWebServer.takeRequest()) { requestLine `should contain` "musicFolderId=$musicFolderId" @@ -161,14 +207,14 @@ class SubsonicAPITest { @Test fun `Should parse get indexes error response`() { - val response = checkErrorCallParsed { api.getApi().getIndexes(null, null).execute() } + val response = checkErrorCallParsed { client.api.getIndexes(null, null).execute() } response.indexes `should be` null } @Test fun `Should parse getMusicDirectory error response`() { - val response = checkErrorCallParsed { api.getApi().getMusicDirectory(1).execute() } + val response = checkErrorCallParsed { client.api.getMusicDirectory(1).execute() } response.musicDirectory `should be` null } @@ -178,7 +224,7 @@ class SubsonicAPITest { enqueueResponse("get_music_directory_ok.json") val directoryId = 124L - api.getApi().getMusicDirectory(directoryId).execute() + client.api.getMusicDirectory(directoryId).execute() mockWebServerRule.mockWebServer.takeRequest().requestLine `should contain` "id=$directoryId" } @@ -187,7 +233,7 @@ class SubsonicAPITest { fun `Should parse get music directory ok response`() { enqueueResponse("get_music_directory_ok.json") - val response = api.getApi().getMusicDirectory(1).execute() + val response = client.api.getMusicDirectory(1).execute() assertResponseSuccessful(response) diff --git a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPI.kt b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPI.kt deleted file mode 100644 index 94a0fece..00000000 --- a/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPI.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.moire.ultrasonic.api.subsonic - -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.KotlinModule -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.jackson.JacksonConverterFactory -import java.math.BigInteger - -/** - * Main entry for Subsonic API calls. - * - * @see SubsonicAPI http://www.subsonic.org/pages/api.jsp - */ -class SubsonicAPI(baseUrl: String, - username: String, - private val password: String, - clientProtocolVersion: SubsonicAPIVersions, - clientID: String, - debug: Boolean = false) { - private val okHttpClient = OkHttpClient.Builder() - .addInterceptor { chain -> - // Adds default request params - val originalRequest = chain.request() - val newUrl = originalRequest.url().newBuilder() - .addQueryParameter("u", username) - .addQueryParameter("p", passwordHex()) - .addQueryParameter("v", clientProtocolVersion.restApiVersion) - .addQueryParameter("c", clientID) - .addQueryParameter("f", "json") - .build() - chain.proceed(originalRequest.newBuilder().url(newUrl).build()) - } - .apply { - if (debug) { - val loggingInterceptor = HttpLoggingInterceptor() - loggingInterceptor.level = HttpLoggingInterceptor.Level.BASIC - this.addInterceptor(loggingInterceptor) - } - }.build() - - private val jacksonMapper = ObjectMapper() - .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true) - .registerModule(KotlinModule()) - - private val retrofit = Retrofit.Builder() - .baseUrl(baseUrl) - .client(okHttpClient) - .addConverterFactory(JacksonConverterFactory.create(jacksonMapper)) - .build() - - private val subsonicAPI = retrofit.create(SubsonicAPIDefinition::class.java) - - /** - * Get API instance. - * - * @return initialized API instance - */ - fun getApi(): SubsonicAPIDefinition = subsonicAPI - - private fun passwordHex() = "enc:${password.toHexBytes()}" - - private fun String.toHexBytes(): String { - return String.format("%040x", BigInteger(1, this.toByteArray())) - } -} \ No newline at end of file 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 new file mode 100644 index 00000000..e1ab6a2a --- /dev/null +++ b/subsonic-api/src/main/kotlin/org/moire/ultrasonic/api/subsonic/SubsonicAPIClient.kt @@ -0,0 +1,113 @@ +package org.moire.ultrasonic.api.subsonic + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +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 + +/** + * Subsonic API client that provides api access. + * + * For supported API calls see [SubsonicAPIDefinition]. + * + * @author Yahor Berdnikau + */ +class SubsonicAPIClient(baseUrl: String, + username: String, + private val password: String, + clientProtocolVersion: SubsonicAPIVersions, + clientID: String, + debug: Boolean = false) { + companion object { + internal val HEX_ARRAY = "0123456789ABCDEF".toCharArray() + } + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + // Adds default request params + 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") + .build() + chain.proceed(originalRequest.newBuilder().url(newUrl).build()) + } + .also { + if (debug) { + it.addLogging() + } + }.build() + + private val jacksonMapper = ObjectMapper() + .configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true) + .registerModule(KotlinModule()) + + private val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(JacksonConverterFactory.create(jacksonMapper)) + .build() + + val api: SubsonicAPIDefinition = retrofit.create(SubsonicAPIDefinition::class.java) + + 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) + } + } + + private 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.BASIC + this.addInterceptor(loggingInterceptor) + } + + private fun HttpUrl.Builder.addPasswordQueryParam(clientProtocolVersion: SubsonicAPIVersions) { + if (clientProtocolVersion < SubsonicAPIVersions.V1_13_0) { + this.addQueryParameter("p", passwordHex) + } else { + this.addQueryParameter("t", passwordMD5Hash) + this.addQueryParameter("s", salt) + } + } +} \ No newline at end of file diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 506d0370..2f494fee 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -40,8 +40,10 @@ dependencies { } testCompile(testing.mockitoKotlin) { exclude module: "kotlin-stdlib" + exclude module: "kotlin-reflect" } testCompile(testing.kluent) { exclude module: "kotlin-stdlib" + exclude module: "kotlin-reflect" } }