mirror of
https://github.com/ultrasonic/ultrasonic
synced 2025-02-18 04:30:48 +01:00
Extract password param providing in separate interceptors.
Added interceptor for api version before 1.12.0 and after. Signed-off-by: Yahor Berdnikau <egorr.berd@gmail.com>
This commit is contained in:
parent
b9cfc0ed6d
commit
6b950f7b28
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user