diff --git a/mopidy_funkwhale/__init__.py b/mopidy_funkwhale/__init__.py index 3cdc9f9..97c8ca7 100644 --- a/mopidy_funkwhale/__init__.py +++ b/mopidy_funkwhale/__init__.py @@ -28,9 +28,15 @@ class Extension(mopidy.ext.Extension): def get_config_schema(self): schema = super(Extension, self).get_config_schema() schema["url"] = mopidy.config.String() + schema["authorization_endpoint"] = mopidy.config.String(optional=True) + schema["token_endpoint"] = mopidy.config.String(optional=True) + schema["client_secret"] = mopidy.config.String(optional=True) + schema["client_id"] = mopidy.config.String(optional=True) + schema["username"] = mopidy.config.String(optional=True) schema["password"] = mopidy.config.Secret(optional=True) schema["cache_duration"] = mopidy.config.Integer(optional=True) + schema["verify_cert"] = mopidy.config.Boolean(optional=True) return schema def validate_config(self, config): @@ -47,3 +53,8 @@ class Extension(mopidy.ext.Extension): from . import actor registry.add("backend", actor.FunkwhaleBackend) + + def get_command(self): + from . import commands + + return commands.FunkwhaleCommand() diff --git a/mopidy_funkwhale/actor.py b/mopidy_funkwhale/actor.py index a2b221c..c574989 100644 --- a/mopidy_funkwhale/actor.py +++ b/mopidy_funkwhale/actor.py @@ -24,7 +24,9 @@ class FunkwhaleBackend(pykka.ThreadingActor, backend.Backend): self.uri_schemes = ["funkwhale", "fw"] def on_start(self): - if self.client.username is not None: + if self.config["funkwhale"]["client_id"]: + logger.info('Using OAuth2 connection"') + elif self.client.username is not None: self.client.login() logger.info( 'Logged in to Funkwhale as "%s" on "%s"', @@ -43,8 +45,12 @@ class FunkwhalePlaybackProvider(backend.PlaybackProvider): if track is None: return None url = track["listen_url"] + if url.startswith("/"): url = self.backend.config["funkwhale"]["url"] + url - if self.backend.client.token: - url += "?jwt=" + self.backend.client.token + if self.backend.client.use_oauth: + url += "?token=" + self.backend.client.oauth_token["access_token"] + + elif self.backend.client.jwt_token: + url += "?jwt=" + self.backend.client.jwt_token return url diff --git a/mopidy_funkwhale/client.py b/mopidy_funkwhale/client.py index 200fff8..6ca55ae 100644 --- a/mopidy_funkwhale/client.py +++ b/mopidy_funkwhale/client.py @@ -1,7 +1,10 @@ from __future__ import unicode_literals +import json import logging +import os import requests +import requests_oauthlib from mopidy import httpclient, exceptions @@ -9,6 +12,8 @@ from . import Extension, __version__ logger = logging.getLogger(__name__) +REQUIRED_SCOPES = ["read:libraries", "read:favorites", "read:playlists"] + class SessionWithUrlBase(requests.Session): # In Python 3 you could place `url_base` after `*args`, but not in Python 2. @@ -28,7 +33,11 @@ class SessionWithUrlBase(requests.Session): return super(SessionWithUrlBase, self).request(method, modified_url, **kwargs) -def get_requests_session(url, proxy_config, user_agent): +class OAuth2Session(SessionWithUrlBase, requests_oauthlib.OAuth2Session): + pass + + +def get_requests_session(url, proxy_config, user_agent, base_cls, **kwargs): if not url.endswith("/"): url += "/" url += "api/v1/" @@ -36,14 +45,14 @@ def get_requests_session(url, proxy_config, user_agent): proxy = httpclient.format_proxy(proxy_config) full_user_agent = httpclient.format_user_agent(user_agent) - session = SessionWithUrlBase(url_base=url) + session = base_cls(url_base=url, **kwargs) session.proxies.update({"http": proxy, "https": proxy}) session.headers.update({"user-agent": full_user_agent}) return session -def login(session, username, password): +def login_legacy(session, username, password): if not username: return response = session.post("token/", {"username": username, "password": password}) @@ -59,24 +68,53 @@ def login(session, username, password): class APIClient(object): def __init__(self, config): self.config = config - self.session = get_requests_session( - config["funkwhale"]["url"], - proxy_config=config["proxy"], - user_agent="%s/%s" % (Extension.dist_name, __version__), - ) - self.username = self.config["funkwhale"]["username"] - self.token = None + self.jwt_token = None + self.oauth_token = get_token(config) + if self.use_oauth: + self.session = get_requests_session( + config["funkwhale"]["url"], + proxy_config=config["proxy"], + user_agent="%s/%s" % (Extension.dist_name, __version__), + base_cls=OAuth2Session, + client_id=self.config["funkwhale"]["client_id"], + token=self.oauth_token, + auto_refresh_url=config["funkwhale"]["url"] + + config["funkwhale"].get("token_endpoint") + or "/api/v1/oauth/token/", + auto_refresh_kwargs={ + "client_id": self.config["funkwhale"]["client_id"], + "client_secret": self.config["funkwhale"]["client_id"], + }, + token_updater=self.refresh_token, + ) + else: + self.session = get_requests_session( + config["funkwhale"]["url"], + proxy_config=config["proxy"], + user_agent="%s/%s" % (Extension.dist_name, __version__), + base_cls=SessionWithUrlBase, + ) + self.username = self.config["funkwhale"]["username"] + self.session.verify = config["funkwhale"].get("verify_cert", True) + + @property + def use_oauth(self): + return self.config["funkwhale"]["client_id"] and self.oauth_token + + def refresh_token(self, token): + self.oauth_token = token + set_token(token, config) def login(self): self.username = self.config["funkwhale"]["username"] if self.username: - self.token = login( + self.jwt_token = login_legacy( self.session, self.config["funkwhale"]["username"], self.config["funkwhale"]["password"], ) else: - self.token = None + self.jwt_token = None def search(self, query): response = self.session.get("search", params={"query": query}) @@ -120,3 +158,28 @@ class APIClient(object): next_page = payload.get("next") if max and counter >= max: next_page = None + + +def get_token(config): + import mopidy_funkwhale + + data_dir = mopidy_funkwhale.Extension.get_data_dir(config) + try: + with open(os.path.join(data_dir, "token"), "r") as f: + raw = f.read() + except IOError: + return None + try: + return json.loads(raw) + except (TypeError, ValueError): + logger.error("Cannot decode token data, you may need to relogin") + + +def set_token(token_data, config): + import mopidy_funkwhale + + data_dir = mopidy_funkwhale.Extension.get_data_dir(config) + print(data_dir) + content = json.dumps(token_data) + with open(os.path.join(data_dir, "token"), "w") as f: + f.write(content) diff --git a/mopidy_funkwhale/commands.py b/mopidy_funkwhale/commands.py new file mode 100644 index 0000000..8d29206 --- /dev/null +++ b/mopidy_funkwhale/commands.py @@ -0,0 +1,89 @@ +from mopidy import commands, compat, exceptions + +import requests_oauthlib + +from . import client + + +def urlencode(data): + try: + import urllib.parse + + return urllib.parse.urlencode(data) + except ImportError: + # python 2 + import urllib + + return urllib.urlencode(data) + + +class FunkwhaleCommand(commands.Command): + def __init__(self): + super(FunkwhaleCommand, self).__init__() + self.add_child("login", LoginCommand()) + + +class LoginCommand(commands.Command): + help = ( + "Display authorization URL and instructions to connect with Funkwhale server." + ) + + def run(self, args, config): + import mopidy_funkwhale + + url = config["funkwhale"]["url"] + authorize_endpoint = ( + config["funkwhale"].get("authorize_endpoint") or "/authorize" + ) + token_endpoint = ( + config["funkwhale"].get("token_endpoint") or "/api/v1/oauth/token/" + ) + client_id = config["funkwhale"]["client_id"] + client_secret = config["funkwhale"]["client_secret"] + if not client_id or not client_secret: + params = { + "name": "Mopidy-Funkwhale", + "scopes": " ".join(client.REQUIRED_SCOPES), + "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", + } + app_url = url + "/settings/applications/new?" + urlencode(params) + print( + "\nMissing client_id or client_secret! To connect to your Funkwhale account:\n\n" + "1. Create an app by visiting {}" + "\n2. Ensure the created app has 'urn:ietf:wg:oauth:2.0:oob' as " + "redirect URI, and the following scopes: {}" + "\n3. Update the client_id and client_secret values in the [funkwhale] section of your mopidy configuration, to match the values of the created application" + "\n4. Relaunch this command".format( + app_url, ", ".join(client.REQUIRED_SCOPES) + ) + ) + return 1 + + oauth = requests_oauthlib.OAuth2Session( + client_id, + redirect_uri="urn:ietf:wg:oauth:2.0:oob", + scope=client.REQUIRED_SCOPES, + ) + oauth.verify = config["funkwhale"].get("verify_cert", True) + authorize_url, state = oauth.authorization_url(url + authorize_endpoint) + print( + "\nTo login:\n\n" + "1. Visit the following URL: {}" + "\n2. Authorize the application" + "\n3. Copy-paste the token you obtained and press enter".format( + authorize_url + ) + ) + + prompt = "\nEnter the token:" + + authorization_code = compat.input(prompt) + token = oauth.fetch_token( + url + token_endpoint, + code=authorization_code, + client_id=client_id, + client_secret=client_secret, + ) + client.set_token(token, config) + print("Login successful!") + return 0 diff --git a/mopidy_funkwhale/ext.conf b/mopidy_funkwhale/ext.conf index 1b53865..22d05a5 100644 --- a/mopidy_funkwhale/ext.conf +++ b/mopidy_funkwhale/ext.conf @@ -2,10 +2,28 @@ enabled = true # URL of your funkwhale instance url = https://demo.funkwhale.audio + +# Create an app by visiting /settings/applications/new +# and past the client id and secret below. Use "urn:ietf:wg:oauth:2.0:oob" +# as the app redirect URL, and request the following scopes: +# - read:libraries +client_id = +client_secret = + +# you don't need to update this +authorization_endpoint = /authorize +token_endpoint = /api/v1/oauth/token/ + +## Settings below are for legacy password-based auth, you should use OAuth instead + # Username to use when authenticating (leave empty fo anonymous access) -username = demo +username = # Password to use when authenticating (leave empty fo anonymous access) -password = demo +password = + # duration of cache entries before they are removed, in seconds # 0 to cache forever, empty to disable cache cache_duration = 600 + +# Control HTTPS certificate verification. Set it to false if you're using a self-signed certificate +verify_cert = true diff --git a/setup.cfg b/setup.cfg index a0cc44c..15880a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,7 @@ packages = find: install_requires = mopidy requests + requests_oauthlib pygobject vext diff --git a/tests/conftest.py b/tests/conftest.py index 7f40532..90ebafb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,13 +8,17 @@ FUNKWHALE_URL = "https://test.funkwhale" @pytest.fixture() -def config(): +def config(tmpdir): return { + "core": {"data_dir": str(tmpdir)}, "funkwhale": { "url": FUNKWHALE_URL, "username": "user", "password": "passw0rd", "cache_duration": 600, + "client_id": "", + "client_secret": "", + "verify_cert": "", }, "proxy": {}, } @@ -28,7 +32,10 @@ def backend(config): @pytest.fixture() def session(backend): return mopidy_funkwhale.client.get_requests_session( - FUNKWHALE_URL, {}, "test/something" + FUNKWHALE_URL, + {}, + "test/something", + base_cls=mopidy_funkwhale.client.SessionWithUrlBase, ) diff --git a/tests/test_extension.py b/tests/test_extension.py index 9b79528..9aaf8e3 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -11,8 +11,11 @@ def test_get_default_config(): assert "[funkwhale]" in config assert "enabled = true" in config assert "url = https://demo.funkwhale.audio" in config - assert "username = demo" in config - assert "password = demo" in config + assert "username =" in config + assert "password =" in config + assert "client_id =" in config + assert "client_secret =" in config + assert "verify_cert =" in config def test_get_config_schema():