Basic oauth support
parent
93a521346b
commit
4a461de6bc
|
@ -28,9 +28,15 @@ class Extension(mopidy.ext.Extension):
|
||||||
def get_config_schema(self):
|
def get_config_schema(self):
|
||||||
schema = super(Extension, self).get_config_schema()
|
schema = super(Extension, self).get_config_schema()
|
||||||
schema["url"] = mopidy.config.String()
|
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["username"] = mopidy.config.String(optional=True)
|
||||||
schema["password"] = mopidy.config.Secret(optional=True)
|
schema["password"] = mopidy.config.Secret(optional=True)
|
||||||
schema["cache_duration"] = mopidy.config.Integer(optional=True)
|
schema["cache_duration"] = mopidy.config.Integer(optional=True)
|
||||||
|
schema["verify_cert"] = mopidy.config.Boolean(optional=True)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def validate_config(self, config):
|
def validate_config(self, config):
|
||||||
|
@ -47,3 +53,8 @@ class Extension(mopidy.ext.Extension):
|
||||||
from . import actor
|
from . import actor
|
||||||
|
|
||||||
registry.add("backend", actor.FunkwhaleBackend)
|
registry.add("backend", actor.FunkwhaleBackend)
|
||||||
|
|
||||||
|
def get_command(self):
|
||||||
|
from . import commands
|
||||||
|
|
||||||
|
return commands.FunkwhaleCommand()
|
||||||
|
|
|
@ -24,7 +24,9 @@ class FunkwhaleBackend(pykka.ThreadingActor, backend.Backend):
|
||||||
self.uri_schemes = ["funkwhale", "fw"]
|
self.uri_schemes = ["funkwhale", "fw"]
|
||||||
|
|
||||||
def on_start(self):
|
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()
|
self.client.login()
|
||||||
logger.info(
|
logger.info(
|
||||||
'Logged in to Funkwhale as "%s" on "%s"',
|
'Logged in to Funkwhale as "%s" on "%s"',
|
||||||
|
@ -43,8 +45,12 @@ class FunkwhalePlaybackProvider(backend.PlaybackProvider):
|
||||||
if track is None:
|
if track is None:
|
||||||
return None
|
return None
|
||||||
url = track["listen_url"]
|
url = track["listen_url"]
|
||||||
|
|
||||||
if url.startswith("/"):
|
if url.startswith("/"):
|
||||||
url = self.backend.config["funkwhale"]["url"] + url
|
url = self.backend.config["funkwhale"]["url"] + url
|
||||||
if self.backend.client.token:
|
if self.backend.client.use_oauth:
|
||||||
url += "?jwt=" + self.backend.client.token
|
url += "?token=" + self.backend.client.oauth_token["access_token"]
|
||||||
|
|
||||||
|
elif self.backend.client.jwt_token:
|
||||||
|
url += "?jwt=" + self.backend.client.jwt_token
|
||||||
return url
|
return url
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import requests
|
import requests
|
||||||
|
import requests_oauthlib
|
||||||
|
|
||||||
from mopidy import httpclient, exceptions
|
from mopidy import httpclient, exceptions
|
||||||
|
|
||||||
|
@ -9,6 +12,8 @@ from . import Extension, __version__
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REQUIRED_SCOPES = ["read:libraries", "read:favorites", "read:playlists"]
|
||||||
|
|
||||||
|
|
||||||
class SessionWithUrlBase(requests.Session):
|
class SessionWithUrlBase(requests.Session):
|
||||||
# In Python 3 you could place `url_base` after `*args`, but not in Python 2.
|
# 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)
|
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("/"):
|
if not url.endswith("/"):
|
||||||
url += "/"
|
url += "/"
|
||||||
url += "api/v1/"
|
url += "api/v1/"
|
||||||
|
@ -36,14 +45,14 @@ def get_requests_session(url, proxy_config, user_agent):
|
||||||
proxy = httpclient.format_proxy(proxy_config)
|
proxy = httpclient.format_proxy(proxy_config)
|
||||||
full_user_agent = httpclient.format_user_agent(user_agent)
|
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.proxies.update({"http": proxy, "https": proxy})
|
||||||
session.headers.update({"user-agent": full_user_agent})
|
session.headers.update({"user-agent": full_user_agent})
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
def login(session, username, password):
|
def login_legacy(session, username, password):
|
||||||
if not username:
|
if not username:
|
||||||
return
|
return
|
||||||
response = session.post("token/", {"username": username, "password": password})
|
response = session.post("token/", {"username": username, "password": password})
|
||||||
|
@ -59,24 +68,53 @@ def login(session, username, password):
|
||||||
class APIClient(object):
|
class APIClient(object):
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.session = get_requests_session(
|
self.jwt_token = None
|
||||||
config["funkwhale"]["url"],
|
self.oauth_token = get_token(config)
|
||||||
proxy_config=config["proxy"],
|
if self.use_oauth:
|
||||||
user_agent="%s/%s" % (Extension.dist_name, __version__),
|
self.session = get_requests_session(
|
||||||
)
|
config["funkwhale"]["url"],
|
||||||
self.username = self.config["funkwhale"]["username"]
|
proxy_config=config["proxy"],
|
||||||
self.token = None
|
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):
|
def login(self):
|
||||||
self.username = self.config["funkwhale"]["username"]
|
self.username = self.config["funkwhale"]["username"]
|
||||||
if self.username:
|
if self.username:
|
||||||
self.token = login(
|
self.jwt_token = login_legacy(
|
||||||
self.session,
|
self.session,
|
||||||
self.config["funkwhale"]["username"],
|
self.config["funkwhale"]["username"],
|
||||||
self.config["funkwhale"]["password"],
|
self.config["funkwhale"]["password"],
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.token = None
|
self.jwt_token = None
|
||||||
|
|
||||||
def search(self, query):
|
def search(self, query):
|
||||||
response = self.session.get("search", params={"query": query})
|
response = self.session.get("search", params={"query": query})
|
||||||
|
@ -120,3 +158,28 @@ class APIClient(object):
|
||||||
next_page = payload.get("next")
|
next_page = payload.get("next")
|
||||||
if max and counter >= max:
|
if max and counter >= max:
|
||||||
next_page = None
|
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)
|
||||||
|
|
|
@ -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
|
|
@ -2,10 +2,28 @@
|
||||||
enabled = true
|
enabled = true
|
||||||
# URL of your funkwhale instance
|
# URL of your funkwhale instance
|
||||||
url = https://demo.funkwhale.audio
|
url = https://demo.funkwhale.audio
|
||||||
|
|
||||||
|
# Create an app by visiting <funkwhaleurl>/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 to use when authenticating (leave empty fo anonymous access)
|
||||||
username = demo
|
username =
|
||||||
# Password to use when authenticating (leave empty fo anonymous access)
|
# Password to use when authenticating (leave empty fo anonymous access)
|
||||||
password = demo
|
password =
|
||||||
|
|
||||||
# duration of cache entries before they are removed, in seconds
|
# duration of cache entries before they are removed, in seconds
|
||||||
# 0 to cache forever, empty to disable cache
|
# 0 to cache forever, empty to disable cache
|
||||||
cache_duration = 600
|
cache_duration = 600
|
||||||
|
|
||||||
|
# Control HTTPS certificate verification. Set it to false if you're using a self-signed certificate
|
||||||
|
verify_cert = true
|
||||||
|
|
|
@ -21,6 +21,7 @@ packages = find:
|
||||||
install_requires =
|
install_requires =
|
||||||
mopidy
|
mopidy
|
||||||
requests
|
requests
|
||||||
|
requests_oauthlib
|
||||||
pygobject
|
pygobject
|
||||||
vext
|
vext
|
||||||
|
|
||||||
|
|
|
@ -8,13 +8,17 @@ FUNKWHALE_URL = "https://test.funkwhale"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def config():
|
def config(tmpdir):
|
||||||
return {
|
return {
|
||||||
|
"core": {"data_dir": str(tmpdir)},
|
||||||
"funkwhale": {
|
"funkwhale": {
|
||||||
"url": FUNKWHALE_URL,
|
"url": FUNKWHALE_URL,
|
||||||
"username": "user",
|
"username": "user",
|
||||||
"password": "passw0rd",
|
"password": "passw0rd",
|
||||||
"cache_duration": 600,
|
"cache_duration": 600,
|
||||||
|
"client_id": "",
|
||||||
|
"client_secret": "",
|
||||||
|
"verify_cert": "",
|
||||||
},
|
},
|
||||||
"proxy": {},
|
"proxy": {},
|
||||||
}
|
}
|
||||||
|
@ -28,7 +32,10 @@ def backend(config):
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def session(backend):
|
def session(backend):
|
||||||
return mopidy_funkwhale.client.get_requests_session(
|
return mopidy_funkwhale.client.get_requests_session(
|
||||||
FUNKWHALE_URL, {}, "test/something"
|
FUNKWHALE_URL,
|
||||||
|
{},
|
||||||
|
"test/something",
|
||||||
|
base_cls=mopidy_funkwhale.client.SessionWithUrlBase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,11 @@ def test_get_default_config():
|
||||||
assert "[funkwhale]" in config
|
assert "[funkwhale]" in config
|
||||||
assert "enabled = true" in config
|
assert "enabled = true" in config
|
||||||
assert "url = https://demo.funkwhale.audio" in config
|
assert "url = https://demo.funkwhale.audio" in config
|
||||||
assert "username = demo" in config
|
assert "username =" in config
|
||||||
assert "password = demo" 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():
|
def test_get_config_schema():
|
||||||
|
|
Loading…
Reference in New Issue