mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
fall back to password auth when the server rejects the token scheme
Navidrome refuses the Subsonic token scheme for LDAP users with error code 41. Catch it once in SubsonicClient: flip that client to the password scheme, tell SourceRepository to persist the change for the source and show a toast, then retry the request. Only 41 is retried, the flip is idempotent, and a client never retries more than once.
This commit is contained in:
@@ -38,6 +38,9 @@ interface SourcesDao {
|
|||||||
@Upsert
|
@Upsert
|
||||||
suspend fun upsertSubsonicSource(subsonic: SubsonicSource)
|
suspend fun upsertSubsonicSource(subsonic: SubsonicSource)
|
||||||
|
|
||||||
|
@Query("UPDATE subsonic_sources SET useTokenAuth = 0 WHERE sourceId = :sourceId")
|
||||||
|
suspend fun disableTokenAuth(sourceId: Long)
|
||||||
|
|
||||||
@Query("DELETE FROM sources WHERE id = :id")
|
@Query("DELETE FROM sources WHERE id = :id")
|
||||||
suspend fun deleteSourceRow(id: Long)
|
suspend fun deleteSourceRow(id: Long)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class SourceRepository(
|
|||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val http: OkHttpClient,
|
private val http: OkHttpClient,
|
||||||
private val prefs: UserPreferences,
|
private val prefs: UserPreferences,
|
||||||
|
private val showMessage: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@@ -126,22 +127,38 @@ class SourceRepository(
|
|||||||
): SubsonicMusicSource =
|
): SubsonicMusicSource =
|
||||||
SubsonicMusicSource(
|
SubsonicMusicSource(
|
||||||
id = id,
|
id = id,
|
||||||
client = client(address, username, password, useTokenAuth),
|
client =
|
||||||
|
client(
|
||||||
|
address = address,
|
||||||
|
username = username,
|
||||||
|
password = password,
|
||||||
|
useTokenAuth = useTokenAuth,
|
||||||
|
onTokenAuthUnsupported = { disableTokenAuth(id) },
|
||||||
|
),
|
||||||
maxBitrate = maxBitrate,
|
maxBitrate = maxBitrate,
|
||||||
streamFormat = streamFormat,
|
streamFormat = streamFormat,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun disableTokenAuth(sourceId: Long) {
|
||||||
|
scope.launch {
|
||||||
|
db.sourcesDao().disableTokenAuth(sourceId)
|
||||||
|
showMessage("Server does not support token auth; using the password instead")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun client(
|
private fun client(
|
||||||
address: String,
|
address: String,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
|
onTokenAuthUnsupported: (() -> Unit)? = null,
|
||||||
) = SubsonicClient(
|
) = SubsonicClient(
|
||||||
baseUrl = normalizeAddress(address).toHttpUrl(),
|
baseUrl = normalizeAddress(address).toHttpUrl(),
|
||||||
username = username,
|
username = username,
|
||||||
password = password,
|
password = password,
|
||||||
useTokenAuth = useTokenAuth,
|
useTokenAuth = useTokenAuth,
|
||||||
http = http,
|
http = http,
|
||||||
|
onTokenAuthUnsupported = onTokenAuthUnsupported,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun normalizeAddress(address: String): String {
|
private fun normalizeAddress(address: String): String {
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ class SubsonicClient(
|
|||||||
private val baseUrl: HttpUrl,
|
private val baseUrl: HttpUrl,
|
||||||
private val username: String,
|
private val username: String,
|
||||||
private val password: String,
|
private val password: String,
|
||||||
private val useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
private val http: OkHttpClient,
|
private val http: OkHttpClient,
|
||||||
private val userAgent: String = "subtracks/android",
|
private val userAgent: String = "subtracks/android",
|
||||||
|
private val onTokenAuthUnsupported: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
@Volatile
|
||||||
|
private var useTokenAuth: Boolean = useTokenAuth
|
||||||
|
|
||||||
fun uri(
|
fun uri(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
@@ -49,6 +53,19 @@ class SubsonicClient(
|
|||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
body: (InputStream) -> T,
|
body: (InputStream) -> T,
|
||||||
|
): T =
|
||||||
|
try {
|
||||||
|
execute(method, params, body)
|
||||||
|
} catch (failure: SubsonicException) {
|
||||||
|
if (failure.code != TOKEN_AUTH_UNSUPPORTED || !useTokenAuth) throw failure
|
||||||
|
disableTokenAuth()
|
||||||
|
execute(method, params, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> execute(
|
||||||
|
method: String,
|
||||||
|
params: Map<String, String>,
|
||||||
|
body: (InputStream) -> T,
|
||||||
): T {
|
): T {
|
||||||
val request =
|
val request =
|
||||||
Request
|
Request
|
||||||
@@ -64,9 +81,17 @@ class SubsonicClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun disableTokenAuth() {
|
||||||
|
if (!useTokenAuth) return
|
||||||
|
useTokenAuth = false
|
||||||
|
onTokenAuthUnsupported?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val API_VERSION = "1.13.0"
|
const val API_VERSION = "1.13.0"
|
||||||
const val CLIENT = "subtracks"
|
const val CLIENT = "subtracks"
|
||||||
|
const val TOKEN_AUTH_UNSUPPORTED = 41
|
||||||
|
|
||||||
private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("")
|
private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.subtracks.di
|
package com.subtracks.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.widget.Toast
|
||||||
import com.subtracks.data.db.createAndroidDatabase
|
import com.subtracks.data.db.createAndroidDatabase
|
||||||
import com.subtracks.data.prefs.createUserPreferences
|
import com.subtracks.data.prefs.createUserPreferences
|
||||||
import com.subtracks.data.repo.ArtworkSeedRepository
|
import com.subtracks.data.repo.ArtworkSeedRepository
|
||||||
@@ -28,10 +31,20 @@ fun appModule(
|
|||||||
context: Context,
|
context: Context,
|
||||||
http: OkHttpClient,
|
http: OkHttpClient,
|
||||||
) = module {
|
) = module {
|
||||||
|
val mainHandler = Handler(Looper.getMainLooper())
|
||||||
single { createAndroidDatabase(context) }
|
single { createAndroidDatabase(context) }
|
||||||
single { http }
|
single { http }
|
||||||
single { createUserPreferences(context) }
|
single { createUserPreferences(context) }
|
||||||
single { SourceRepository(get(), get(), get()) }
|
single {
|
||||||
|
SourceRepository(
|
||||||
|
get(),
|
||||||
|
get(),
|
||||||
|
get(),
|
||||||
|
showMessage = { message ->
|
||||||
|
mainHandler.post { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
single { LibraryRepository(get(), get()) }
|
single { LibraryRepository(get(), get()) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get()) }
|
single { SyncManager(get(), get(), get()) }
|
||||||
|
|||||||
@@ -25,15 +25,23 @@ class SubsonicClientTest {
|
|||||||
server.shutdown()
|
server.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun client(tokenAuth: Boolean) =
|
private fun client(
|
||||||
SubsonicClient(
|
tokenAuth: Boolean,
|
||||||
|
onTokenAuthUnsupported: (() -> Unit)? = null,
|
||||||
|
) = SubsonicClient(
|
||||||
baseUrl = server.url("/"),
|
baseUrl = server.url("/"),
|
||||||
username = "guest",
|
username = "guest",
|
||||||
password = "secret",
|
password = "secret",
|
||||||
useTokenAuth = tokenAuth,
|
useTokenAuth = tokenAuth,
|
||||||
http = OkHttpClient(),
|
http = OkHttpClient(),
|
||||||
|
onTokenAuthUnsupported = onTokenAuthUnsupported,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun failed(
|
||||||
|
code: Int,
|
||||||
|
message: String,
|
||||||
|
) = "<subsonic-response status=\"failed\"><error code=\"$code\" message=\"$message\"/></subsonic-response>"
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun tokenAuthSendsSaltAndHash() {
|
fun tokenAuthSendsSaltAndHash() {
|
||||||
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
|
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
|
||||||
@@ -90,4 +98,51 @@ class SubsonicClientTest {
|
|||||||
|
|
||||||
assertThrows(SubsonicException::class.java) { client(tokenAuth = true).check("ping") }
|
assertThrows(SubsonicException::class.java) { client(tokenAuth = true).check("ping") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tokenAuthUnsupportedRetriesWithPassword() {
|
||||||
|
server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users.")))
|
||||||
|
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
|
||||||
|
var notified = 0
|
||||||
|
|
||||||
|
client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }).check("ping")
|
||||||
|
|
||||||
|
val first = server.takeRequest().requestUrl!!
|
||||||
|
assertNotNull(first.queryParameter("t"))
|
||||||
|
assertNull(first.queryParameter("p"))
|
||||||
|
val second = server.takeRequest().requestUrl!!
|
||||||
|
assertNull(second.queryParameter("t"))
|
||||||
|
assertEquals("secret", second.queryParameter("p"))
|
||||||
|
assertEquals(1, notified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tokenAuthUnsupportedIsNotifiedOnceAndNotRetriedAgain() {
|
||||||
|
server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users.")))
|
||||||
|
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
|
||||||
|
server.enqueue(MockResponse().setBody(failed(41, "Token authentication not supported for LDAP users.")))
|
||||||
|
var notified = 0
|
||||||
|
val client = client(tokenAuth = true, onTokenAuthUnsupported = { notified++ })
|
||||||
|
|
||||||
|
client.check("ping")
|
||||||
|
assertThrows(SubsonicException::class.java) { client.check("ping") }
|
||||||
|
|
||||||
|
assertEquals(1, notified)
|
||||||
|
assertEquals(3, server.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun otherErrorsAreNotRetried() {
|
||||||
|
server.enqueue(MockResponse().setBody(failed(40, "Wrong username")))
|
||||||
|
var notified = 0
|
||||||
|
|
||||||
|
val error =
|
||||||
|
assertThrows(SubsonicException::class.java) {
|
||||||
|
client(tokenAuth = true, onTokenAuthUnsupported = { notified++ }).check("ping")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(40, error.code)
|
||||||
|
assertEquals(0, notified)
|
||||||
|
assertEquals(1, server.requestCount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user