Basic oauth support

main
Eliot Berriot 2019-05-03 12:22:45 +02:00
parent 93a521346b
commit 4a461de6bc
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
8 changed files with 219 additions and 21 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -2,10 +2,28 @@
enabled = true
# URL of your funkwhale instance
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 = 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

View File

@ -21,6 +21,7 @@ packages = find:
install_requires =
mopidy
requests
requests_oauthlib
pygobject
vext

View File

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

View File

@ -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():