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:
Sumner Evans
2020-09-30 15:20:41 +00:00
3 changed files with 130 additions and 8 deletions

View File

@@ -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,10 +171,38 @@ class SubsonicAdapter(Adapter):
"Double check the server address." "Double check the server address."
) )
except ServerError as e: except ServerError as e:
errors["__ping__"] = ( if e.status_code in [10, 41] and config_store["salt_auth"]:
"<b>Error connecting to the server.</b>\n" # status code 10: if salt auth is not enabled, server will
f"Error {e.status_code}: {str(e)}" # 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__"] = (
"<b>Error connecting to the server.</b>\n"
f"Error {e.status_code}: {str(e)}"
)
except Exception as e: except Exception as e:
errors["__ping__"] = str(e) errors["__ping__"] = 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"

View File

@@ -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")

View File

@@ -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"