Merge branch 'master' into 'master'
Added subsonic server config for salt auth logic See merge request sublime-music/sublime-music!47
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
@@ -5,6 +6,7 @@ import multiprocessing
|
|||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import random
|
import random
|
||||||
|
import string
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -111,6 +113,15 @@ class SubsonicAdapter(Adapter):
|
|||||||
helptext="If toggled, Sublime Music will periodically save the play "
|
helptext="If toggled, Sublime Music will periodically save the play "
|
||||||
"queue state so that you can resume on other devices.",
|
"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:
|
if networkmanager_imported:
|
||||||
@@ -160,6 +171,34 @@ class SubsonicAdapter(Adapter):
|
|||||||
"Double check the server address."
|
"Double check the server address."
|
||||||
)
|
)
|
||||||
except ServerError as e:
|
except ServerError as 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__"] = (
|
||||||
|
"<b>Error connecting to the server.</b>\n"
|
||||||
|
f"Error {retry_e.status_code}: {str(retry_e)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
errors["__ping__"] = (
|
errors["__ping__"] = (
|
||||||
"<b>Error connecting to the server.</b>\n"
|
"<b>Error connecting to the server.</b>\n"
|
||||||
f"Error {e.status_code}: {str(e)}"
|
f"Error {e.status_code}: {str(e)}"
|
||||||
@@ -173,7 +212,8 @@ class SubsonicAdapter(Adapter):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def migrate_configuration(config_store: ConfigurationStore):
|
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):
|
def __init__(self, config: ConfigurationStore, data_directory: Path):
|
||||||
self.data_directory = data_directory
|
self.data_directory = data_directory
|
||||||
@@ -212,6 +252,7 @@ class SubsonicAdapter(Adapter):
|
|||||||
self.username = config["username"]
|
self.username = config["username"]
|
||||||
self.password = cast(str, config.get_secret("password"))
|
self.password = cast(str, config.get_secret("password"))
|
||||||
self.verify_cert = config["verify_cert"]
|
self.verify_cert = config["verify_cert"]
|
||||||
|
self.use_salt_auth = config["salt_auth"]
|
||||||
|
|
||||||
self.is_shutting_down = False
|
self.is_shutting_down = False
|
||||||
self._ping_process: Optional[multiprocessing.Process] = None
|
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
|
Gets the parameters that are needed for all requests to the Subsonic API. See
|
||||||
Subsonic API Introduction for details.
|
Subsonic API Introduction for details.
|
||||||
"""
|
"""
|
||||||
return {
|
params = {
|
||||||
"u": self.username,
|
"u": self.username,
|
||||||
"p": self.password,
|
|
||||||
"c": "Sublime Music",
|
"c": "Sublime Music",
|
||||||
"f": "json",
|
"f": "json",
|
||||||
"v": self._version.value.decode() or "1.8.0",
|
"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:
|
def _make_url(self, endpoint: str) -> str:
|
||||||
return f"{self.hostname}/rest/{endpoint}.view"
|
return f"{self.hostname}/rest/{endpoint}.view"
|
||||||
|
|
||||||
|
@@ -21,6 +21,7 @@ def adapter_manager(tmp_path: Path):
|
|||||||
server_address="https://subsonic.example.com",
|
server_address="https://subsonic.example.com",
|
||||||
username="test",
|
username="test",
|
||||||
verify_cert=True,
|
verify_cert=True,
|
||||||
|
salt_auth=False,
|
||||||
)
|
)
|
||||||
subsonic_config_store.set_secret("password", "testpass")
|
subsonic_config_store.set_secret("password", "testpass")
|
||||||
|
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -21,11 +22,31 @@ def adapter(tmp_path: Path):
|
|||||||
server_address="https://subsonic.example.com",
|
server_address="https://subsonic.example.com",
|
||||||
username="test",
|
username="test",
|
||||||
verify_cert=True,
|
verify_cert=True,
|
||||||
|
salt_auth=False,
|
||||||
)
|
)
|
||||||
config.set_secret("password", "testpass")
|
config.set_secret("password", "testpass")
|
||||||
|
|
||||||
adapter = SubsonicAdapter(config, tmp_path)
|
adapter = SubsonicAdapter(config, tmp_path)
|
||||||
adapter._is_mock = True
|
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
|
yield adapter
|
||||||
adapter.shutdown()
|
adapter.shutdown()
|
||||||
|
|
||||||
@@ -79,7 +100,7 @@ def test_config_form():
|
|||||||
SubsonicAdapter.get_configuration_form(config_store)
|
SubsonicAdapter.get_configuration_form(config_store)
|
||||||
|
|
||||||
|
|
||||||
def test_request_making_methods(adapter: SubsonicAdapter):
|
def test_plain_auth_logic(adapter: SubsonicAdapter):
|
||||||
expected = {
|
expected = {
|
||||||
"u": "test",
|
"u": "test",
|
||||||
"p": "testpass",
|
"p": "testpass",
|
||||||
@@ -89,6 +110,48 @@ def test_request_making_methods(adapter: SubsonicAdapter):
|
|||||||
}
|
}
|
||||||
assert sorted(expected.items()) == sorted(adapter._get_params().items())
|
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"
|
assert adapter._make_url("foo") == "https://subsonic.example.com/rest/foo.view"
|
||||||
|
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user