From 5c2ddc56c4d4182a3d2059a0e24693704990761f Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Sun, 25 Feb 2018 13:05:29 +0100 Subject: [PATCH] Basic channels middleware for token auth --- api/config/routing.py | 17 ++++++++++ api/funkwhale_api/common/auth.py | 47 +++++++++++++++++++++++++++ api/funkwhale_api/common/consumers.py | 11 +++++++ api/tests/channels/test_auth.py | 37 +++++++++++++++++++++ api/tests/channels/test_consumers.py | 26 +++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 api/config/routing.py create mode 100644 api/funkwhale_api/common/auth.py create mode 100644 api/funkwhale_api/common/consumers.py create mode 100644 api/tests/channels/test_auth.py create mode 100644 api/tests/channels/test_consumers.py diff --git a/api/config/routing.py b/api/config/routing.py new file mode 100644 index 0000000000..249bf51a83 --- /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 0000000000..6f99b3bba2 --- /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 0000000000..26e57fc8ae --- /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 0000000000..a2b7eaf0ca --- /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 0000000000..f1648efb3a --- /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() -- GitLab