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"