diff --git a/api/config/routing.py b/api/config/routing.py new file mode 100644 index 0000000000000000000000000000000000000000..249bf51a83cfba8cc09e5660872e18cbe2404461 --- /dev/null +++ b/api/config/routing.py @@ -0,0 +1,17 @@ +from django.conf.urls import url + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter + +from funkwhale_api.common.auth import TokenAuthMiddleware +from funkwhale_api.music import consumers + + +application = ProtocolTypeRouter({ + # Empty for now (http->django views is added by default) + "websocket": TokenAuthMiddleware( + URLRouter([ + url("^api/v1/test/$", consumers.MyConsumer), + ]) + ), +}) diff --git a/api/funkwhale_api/common/auth.py b/api/funkwhale_api/common/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..6f99b3bba26b36fbce61dd03fb608cb2cb61f6ba --- /dev/null +++ b/api/funkwhale_api/common/auth.py @@ -0,0 +1,47 @@ +from urllib.parse import parse_qs + +import jwt + +from django.contrib.auth.models import AnonymousUser +from django.utils.encoding import smart_text + +from rest_framework import exceptions +from rest_framework_jwt.settings import api_settings +from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication + + + +class TokenHeaderAuth(BaseJSONWebTokenAuthentication): + def get_jwt_value(self, request): + + try: + qs = request.get('query_string', b'').decode('utf-8') + parsed = parse_qs(qs) + token = parsed['token'][0] + except KeyError: + raise exceptions.AuthenticationFailed('No token') + + if not token: + raise exceptions.AuthenticationFailed('Empty token') + + return token + + +class TokenAuthMiddleware: + """ + Custom middleware (insecure) that takes user IDs from the query string. + """ + + def __init__(self, inner): + # Store the ASGI application we were passed + self.inner = inner + + def __call__(self, scope): + auth = TokenHeaderAuth() + try: + user, token = auth.authenticate(scope) + except exceptions.AuthenticationFailed: + user = AnonymousUser() + + scope['user'] = user + return self.inner(scope) diff --git a/api/funkwhale_api/common/consumers.py b/api/funkwhale_api/common/consumers.py new file mode 100644 index 0000000000000000000000000000000000000000..26e57fc8ae5441cbb0b1cc91f0864da08bced672 --- /dev/null +++ b/api/funkwhale_api/common/consumers.py @@ -0,0 +1,11 @@ +from channels.generic.websocket import JsonWebsocketConsumer + + +class JsonAuthConsumer(JsonWebsocketConsumer): + def connect(self): + try: + assert self.scope['user'].pk is not None + except (AssertionError, AttributeError, KeyError): + return self.close() + + return self.accept() diff --git a/api/tests/channels/test_auth.py b/api/tests/channels/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..a2b7eaf0ca685c1c197ed339a5963aeceea215c2 --- /dev/null +++ b/api/tests/channels/test_auth.py @@ -0,0 +1,37 @@ +import pytest + +from rest_framework_jwt.settings import api_settings + +from funkwhale_api.common.auth import TokenAuthMiddleware + +jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER +jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER + + +@pytest.mark.parametrize('query_string', [ + b'token=wrong', + b'', +]) +def test_header_anonymous(query_string, factories): + def callback(scope): + assert scope['user'].is_anonymous + + scope = { + 'query_string': query_string + } + consumer = TokenAuthMiddleware(callback) + consumer(scope) + + +def test_header_correct_token(factories): + user = factories['users.User']() + payload = jwt_payload_handler(user) + token = jwt_encode_handler(payload) + def callback(scope): + assert scope['user'] == user + + scope = { + 'query_string': 'token={}'.format(token).encode('utf-8') + } + consumer = TokenAuthMiddleware(callback) + consumer(scope) diff --git a/api/tests/channels/test_consumers.py b/api/tests/channels/test_consumers.py new file mode 100644 index 0000000000000000000000000000000000000000..f1648efb3a614fb2f74b8e941083f99491043dd0 --- /dev/null +++ b/api/tests/channels/test_consumers.py @@ -0,0 +1,26 @@ +from funkwhale_api.common import consumers + + +def test_auth_consumer_requires_valid_user(mocker): + m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close') + scope = {'user': None} + consumer = consumers.JsonAuthConsumer(scope=scope) + consumer.connect() + m.assert_called_once_with() + + +def test_auth_consumer_requires_user_in_scope(mocker): + m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close') + scope = {} + consumer = consumers.JsonAuthConsumer(scope=scope) + consumer.connect() + m.assert_called_once_with() + + +def test_auth_consumer_accepts_connection(mocker, factories): + user = factories['users.User']() + m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.accept') + scope = {'user': user} + consumer = consumers.JsonAuthConsumer(scope=scope) + consumer.connect() + m.assert_called_once_with()