diff --git a/sublime/adapters/subsonic/adapter.py b/sublime/adapters/subsonic/adapter.py index bfd5ed8..cdb1126 100644 --- a/sublime/adapters/subsonic/adapter.py +++ b/sublime/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 @@ -109,6 +111,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=False, + 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: @@ -210,6 +221,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"] if "salt_auth" in config else False self.is_shutting_down = False @@ -333,14 +345,32 @@ 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": "1.15.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)) + unhashed_token = "{}{}".format(self.password, salt) + hashed_token = hashlib.md5(str.encode(unhashed_token)).hexdigest() + return (salt, hashed_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 afc6f35..355124d 100644 --- a/tests/adapter_tests/adapter_manager_tests.py +++ b/tests/adapter_tests/adapter_manager_tests.py @@ -16,6 +16,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 989e140..df48009 100644 --- a/tests/adapter_tests/subsonic_adapter_tests.py +++ b/tests/adapter_tests/subsonic_adapter_tests.py @@ -24,11 +24,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() @@ -82,7 +102,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", @@ -92,6 +112,23 @@ 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 + assert "t" in params + assert all(key in params and params[key] == expected[key] for key in expected) + + +def test_make_url(adapter: SubsonicAdapter): assert adapter._make_url("foo") == "https://subsonic.example.com/rest/foo.view"