Merge pull request #82 from ultrasonic/introduce-auth-interceptors
Extract password param providing in separate interceptors.
This commit is contained in:
commit
0deed54a9e
|
@ -6,6 +6,7 @@ import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.moire.ultrasonic.api.subsonic.SubsonicAPIVersions.V1_6_0
|
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
|
import org.moire.ultrasonic.api.subsonic.rules.MockWebServerRule
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -24,7 +25,7 @@ class GetStreamUrlTest {
|
||||||
USERNAME, PASSWORD, V1_6_0, CLIENT_ID)
|
USERNAME, PASSWORD, V1_6_0, CLIENT_ID)
|
||||||
val baseExpectedUrl = mockWebServerRule.mockWebServer.url("").toString()
|
val baseExpectedUrl = mockWebServerRule.mockWebServer.url("").toString()
|
||||||
expectedUrl = "$baseExpectedUrl/rest/stream.view?id=$id&u=$USERNAME" +
|
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
|
@Test
|
||||||
|
|
|
@ -1,18 +1,15 @@
|
||||||
package org.moire.ultrasonic.api.subsonic
|
package org.moire.ultrasonic.api.subsonic
|
||||||
|
|
||||||
import org.amshove.kluent.`should contain`
|
import org.amshove.kluent.`should contain`
|
||||||
import org.amshove.kluent.`should equal`
|
|
||||||
import org.amshove.kluent.`should not contain`
|
import org.amshove.kluent.`should not contain`
|
||||||
import org.apache.commons.codec.binary.Hex
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Integration test for [SubsonicAPIClient] that checks proper user password handling.
|
* Integration test for [SubsonicAPIClient] that checks proper user password handling.
|
||||||
*/
|
*/
|
||||||
class SubsonicApiPasswordTest : SubsonicAPIClientTest() {
|
class SubsonicApiPasswordTest : SubsonicAPIClientTest() {
|
||||||
@Test
|
@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,
|
val clientV12 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME,
|
||||||
PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID)
|
PASSWORD, SubsonicAPIVersions.V1_14_0, CLIENT_ID)
|
||||||
mockWebServerRule.enqueueResponse("ping_ok.json")
|
mockWebServerRule.enqueueResponse("ping_ok.json")
|
||||||
|
@ -23,17 +20,11 @@ class SubsonicApiPasswordTest : SubsonicAPIClientTest() {
|
||||||
requestLine `should contain` "&s="
|
requestLine `should contain` "&s="
|
||||||
requestLine `should contain` "&t="
|
requestLine `should contain` "&t="
|
||||||
requestLine `should not contain` "&p=enc:"
|
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
|
@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,
|
val clientV11 = SubsonicAPIClient(mockWebServerRule.mockWebServer.url("/").toString(), USERNAME,
|
||||||
PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID)
|
PASSWORD, SubsonicAPIVersions.V1_12_0, CLIENT_ID)
|
||||||
mockWebServerRule.enqueueResponse("ping_ok.json")
|
mockWebServerRule.enqueueResponse("ping_ok.json")
|
||||||
|
@ -44,9 +35,6 @@ class SubsonicApiPasswordTest : SubsonicAPIClientTest() {
|
||||||
requestLine `should not contain` "&s="
|
requestLine `should not contain` "&s="
|
||||||
requestLine `should not contain` "&t="
|
requestLine `should not contain` "&t="
|
||||||
requestLine `should contain` "&p=enc:"
|
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.databind.ObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
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.interceptors.RangeHeaderInterceptor
|
||||||
import org.moire.ultrasonic.api.subsonic.response.StreamResponse
|
import org.moire.ultrasonic.api.subsonic.response.StreamResponse
|
||||||
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
|
import org.moire.ultrasonic.api.subsonic.response.SubsonicResponse
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.jackson.JacksonConverterFactory
|
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
|
import java.util.concurrent.TimeUnit.MILLISECONDS
|
||||||
|
|
||||||
private const val READ_TIMEOUT = 60_000L
|
private const val READ_TIMEOUT = 60_000L
|
||||||
|
@ -36,10 +32,6 @@ class SubsonicAPIClient(baseUrl: String,
|
||||||
clientProtocolVersion: SubsonicAPIVersions,
|
clientProtocolVersion: SubsonicAPIVersions,
|
||||||
clientID: String,
|
clientID: String,
|
||||||
debug: Boolean = false) {
|
debug: Boolean = false) {
|
||||||
companion object {
|
|
||||||
internal val HEX_ARRAY = "0123456789ABCDEF".toCharArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val okHttpClient = OkHttpClient.Builder()
|
private val okHttpClient = OkHttpClient.Builder()
|
||||||
.readTimeout(READ_TIMEOUT, MILLISECONDS)
|
.readTimeout(READ_TIMEOUT, MILLISECONDS)
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
|
@ -47,9 +39,6 @@ class SubsonicAPIClient(baseUrl: String,
|
||||||
val originalRequest = chain.request()
|
val originalRequest = chain.request()
|
||||||
val newUrl = originalRequest.url().newBuilder()
|
val newUrl = originalRequest.url().newBuilder()
|
||||||
.addQueryParameter("u", username)
|
.addQueryParameter("u", username)
|
||||||
.also {
|
|
||||||
it.addPasswordQueryParam(clientProtocolVersion)
|
|
||||||
}
|
|
||||||
.addQueryParameter("v", clientProtocolVersion.restApiVersion)
|
.addQueryParameter("v", clientProtocolVersion.restApiVersion)
|
||||||
.addQueryParameter("c", clientID)
|
.addQueryParameter("c", clientID)
|
||||||
.addQueryParameter("f", "json")
|
.addQueryParameter("f", "json")
|
||||||
|
@ -57,11 +46,9 @@ class SubsonicAPIClient(baseUrl: String,
|
||||||
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
chain.proceed(originalRequest.newBuilder().url(newUrl).build())
|
||||||
}
|
}
|
||||||
.addInterceptor(RangeHeaderInterceptor())
|
.addInterceptor(RangeHeaderInterceptor())
|
||||||
.also {
|
.apply { if (debug) addLogging() }
|
||||||
if (debug) {
|
.addPasswordQueryParam(clientProtocolVersion)
|
||||||
it.addLogging()
|
.build()
|
||||||
}
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
private val jacksonMapper = ObjectMapper()
|
private val jacksonMapper = ObjectMapper()
|
||||||
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
|
.configure(DeserializationFeature.UNWRAP_ROOT_VALUE, true)
|
||||||
|
@ -147,50 +134,19 @@ class SubsonicAPIClient(baseUrl: String,
|
||||||
return url
|
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() {
|
private fun OkHttpClient.Builder.addLogging() {
|
||||||
val loggingInterceptor = HttpLoggingInterceptor()
|
val loggingInterceptor = HttpLoggingInterceptor()
|
||||||
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
|
||||||
this.addInterceptor(loggingInterceptor)
|
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) {
|
if (clientProtocolVersion < SubsonicAPIVersions.V1_13_0) {
|
||||||
this.addQueryParameter("p", passwordHex)
|
this.addInterceptor(PasswordHexInterceptor(password))
|
||||||
} else {
|
} else {
|
||||||
this.addQueryParameter("t", passwordMD5Hash)
|
this.addInterceptor(PasswordMD5Interceptor(password))
|
||||||
this.addQueryParameter("s", salt)
|
|
||||||
}
|
}
|
||||||
|
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…
Reference in New Issue