diff --git a/sublime_music/adapters/subsonic/adapter.py b/sublime_music/adapters/subsonic/adapter.py
index bc5cbd4..c5e22c4 100644
--- a/sublime_music/adapters/subsonic/adapter.py
+++ b/sublime_music/adapters/subsonic/adapter.py
@@ -1,3 +1,4 @@
+import hashlib
import json
import logging
import math
@@ -5,6 +6,7 @@ import multiprocessing
import os
import pickle
import random
+import string
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
@@ -111,6 +113,15 @@ class SubsonicAdapter(Adapter):
helptext="If toggled, Sublime Music will periodically save the play "
"queue state so that you can resume on other devices.",
),
+ "salt_auth": ConfigParamDescriptor(
+ bool,
+ "Use Salt Authentication",
+ default=True,
+ advanced=True,
+ helptext="If toggled, Sublime Music will use salted hash tokens "
+ "instead of the plain password in the request urls (only supported on "
+ "Subsonic API 1.13.0+)",
+ ),
}
if networkmanager_imported:
@@ -160,10 +171,38 @@ class SubsonicAdapter(Adapter):
"Double check the server address."
)
except ServerError as e:
- errors["__ping__"] = (
- "Error connecting to the server.\n"
- f"Error {e.status_code}: {str(e)}"
- )
+ if e.status_code in [10, 41] and config_store["salt_auth"]:
+ # status code 10: if salt auth is not enabled, server will
+ # return error server error with status_code 10 since it'll
+ # interpret it as a missing (password) parameter
+ # status code 41: as per subsonic api docs, description of
+ # status_code 41 is "Token authentication not supported for LDAP
+ # users." so fall back to password auth
+ try:
+ config_store["salt_auth"] = False
+ tmp_adapter = SubsonicAdapter(
+ config_store, Path(tmp_dir_name)
+ )
+ tmp_adapter._get_json(
+ tmp_adapter._make_url("ping"),
+ timeout=2,
+ is_exponential_backoff_ping=True,
+ )
+ logging.warn(
+ "Salted auth not supported, falling back to regular "
+ "password auth"
+ )
+ except ServerError as retry_e:
+ config_store["salt_auth"] = True
+ errors["__ping__"] = (
+ "Error connecting to the server.\n"
+ f"Error {retry_e.status_code}: {str(retry_e)}"
+ )
+ else:
+ errors["__ping__"] = (
+ "Error connecting to the server.\n"
+ f"Error {e.status_code}: {str(e)}"
+ )
except Exception as e:
errors["__ping__"] = str(e)
@@ -173,7 +212,8 @@ class SubsonicAdapter(Adapter):
@staticmethod
def migrate_configuration(config_store: ConfigurationStore):
- pass
+ if "salt_auth" not in config_store:
+ config_store["salt_auth"] = True
def __init__(self, config: ConfigurationStore, data_directory: Path):
self.data_directory = data_directory
@@ -212,6 +252,7 @@ class SubsonicAdapter(Adapter):
self.username = config["username"]
self.password = cast(str, config.get_secret("password"))
self.verify_cert = config["verify_cert"]
+ self.use_salt_auth = config["salt_auth"]
self.is_shutting_down = False
self._ping_process: Optional[multiprocessing.Process] = None
@@ -341,14 +382,31 @@ class SubsonicAdapter(Adapter):
Gets the parameters that are needed for all requests to the Subsonic API. See
Subsonic API Introduction for details.
"""
- return {
+ params = {
"u": self.username,
- "p": self.password,
"c": "Sublime Music",
"f": "json",
"v": self._version.value.decode() or "1.8.0",
}
+ if self.use_salt_auth:
+ salt, token = self._generate_auth_token()
+ params["s"] = salt
+ params["t"] = token
+ else:
+ params["p"] = self.password
+
+ return params
+
+ def _generate_auth_token(self) -> Tuple[str, str]:
+ """
+ Generates the necessary authentication data to call the Subsonic API See the
+ Authentication section of www.subsonic.org/pages/api.jsp for more information
+ """
+ salt = "".join(random.choices(string.ascii_letters + string.digits, k=8))
+ token = hashlib.md5(f"{self.password}{salt}".encode()).hexdigest()
+ return (salt, token)
+
def _make_url(self, endpoint: str) -> str:
return f"{self.hostname}/rest/{endpoint}.view"
diff --git a/tests/adapter_tests/adapter_manager_tests.py b/tests/adapter_tests/adapter_manager_tests.py
index 1988cbc..b4ba330 100644
--- a/tests/adapter_tests/adapter_manager_tests.py
+++ b/tests/adapter_tests/adapter_manager_tests.py
@@ -21,6 +21,7 @@ def adapter_manager(tmp_path: Path):
server_address="https://subsonic.example.com",
username="test",
verify_cert=True,
+ salt_auth=False,
)
subsonic_config_store.set_secret("password", "testpass")
diff --git a/tests/adapter_tests/subsonic_adapter_tests.py b/tests/adapter_tests/subsonic_adapter_tests.py
index 377e8b5..3f93e3f 100644
--- a/tests/adapter_tests/subsonic_adapter_tests.py
+++ b/tests/adapter_tests/subsonic_adapter_tests.py
@@ -1,3 +1,4 @@
+import hashlib
import json
import logging
import re
@@ -21,11 +22,31 @@ def adapter(tmp_path: Path):
server_address="https://subsonic.example.com",
username="test",
verify_cert=True,
+ salt_auth=False,
)
config.set_secret("password", "testpass")
adapter = SubsonicAdapter(config, tmp_path)
adapter._is_mock = True
+
+ yield adapter
+ adapter.shutdown()
+
+
+@pytest.fixture
+def salt_auth_adapter(tmp_path: Path):
+ ConfigurationStore.MOCK = True
+ config = ConfigurationStore(
+ server_address="https://subsonic.example.com",
+ username="test",
+ verify_cert=True,
+ salt_auth=True,
+ )
+ config.set_secret("password", "testpass")
+
+ adapter = SubsonicAdapter(config, tmp_path)
+ adapter._is_mock = True
+
yield adapter
adapter.shutdown()
@@ -79,7 +100,7 @@ def test_config_form():
SubsonicAdapter.get_configuration_form(config_store)
-def test_request_making_methods(adapter: SubsonicAdapter):
+def test_plain_auth_logic(adapter: SubsonicAdapter):
expected = {
"u": "test",
"p": "testpass",
@@ -89,6 +110,48 @@ def test_request_making_methods(adapter: SubsonicAdapter):
}
assert sorted(expected.items()) == sorted(adapter._get_params().items())
+
+def test_salt_auth_logic(salt_auth_adapter: SubsonicAdapter):
+ expected = {
+ "u": "test",
+ "c": "Sublime Music",
+ "f": "json",
+ "v": "1.15.0",
+ }
+
+ params = salt_auth_adapter._get_params()
+ assert "p" not in params
+ assert "s" in params
+ salt = params["s"]
+ assert "t" in params
+ assert params["t"] == hashlib.md5(f"testpass{salt}".encode()).hexdigest()
+ assert all(key in params and params[key] == expected[key] for key in expected)
+
+
+def test_migrate_configuration_populate_salt_auth():
+ config = ConfigurationStore(
+ server_address="https://subsonic.example.com",
+ username="test",
+ verify_cert=True,
+ )
+ SubsonicAdapter.migrate_configuration(config)
+ assert "salt_auth" in config
+ assert config["salt_auth"]
+
+
+def test_migrate_configuration_salt_auth_present():
+ config = ConfigurationStore(
+ server_address="https://subsonic.example.com",
+ username="test",
+ verify_cert=True,
+ salt_auth=False,
+ )
+ SubsonicAdapter.migrate_configuration(config)
+ assert "salt_auth" in config
+ assert not config["salt_auth"]
+
+
+def test_make_url(adapter: SubsonicAdapter):
assert adapter._make_url("foo") == "https://subsonic.example.com/rest/foo.view"