Merge pull request #82 from ultrasonic/introduce-auth-interceptors

Extract password param providing in separate interceptors.
This commit is contained in:
Yahor Berdnikau 2017-11-22 22:01:52 +01:00 committed by GitHub
commit 0deed54a9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 172 additions and 69 deletions

View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -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())
}
}

View File

@ -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())
}
}