From f255c3c8a500c0a683a5c89007e19b5c0082274a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexis=20M=C3=A9taireau?= Date: Fri, 19 Apr 2024 15:25:02 +0200 Subject: [PATCH] feat(websockets): Authenticate with signed tokens. Authentication is now done using a signed token provided by the Django server, sent by the JS client and checked by the WebSocket server. The token contains a `permissions` key that's checked to ensure the user has access to the map "room", where events will be shared by the peers. --- pyproject.toml | 1 - umap/settings/base.py | 5 ----- umap/urls.py | 5 +++++ umap/views.py | 45 +++++++++++++++++++++++++++++++++++-------- umap/ws.py | 21 ++++++++++++-------- 5 files changed, 55 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e8c59da7..2b4852d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ dependencies = [ "django-agnocomplete==2.2.0", "django-environ==0.11.2", "django-probes==1.7.0", - "django-sesame==3.2.2", "Pillow==10.3.0", "psycopg==3.1.19", "pydantic==2.7.0", diff --git a/umap/settings/base.py b/umap/settings/base.py index 536e5ed6..f9fb2c4e 100644 --- a/umap/settings/base.py +++ b/umap/settings/base.py @@ -289,11 +289,6 @@ if SOCIAL_AUTH_OPENSTREETMAP_OAUTH2_KEY and SOCIAL_AUTH_OPENSTREETMAP_OAUTH2_SEC AUTHENTICATION_BACKENDS += ("django.contrib.auth.backends.ModelBackend",) -# Websockets configuration -AUTHENTICATION_BACKENDS += ("sesame.backends.ModelBackend",) - -SESAME_MAX_AGE = 30 - LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/umap/urls.py b/umap/urls.py index 28564b55..ecc9353a 100644 --- a/umap/urls.py +++ b/umap/urls.py @@ -155,6 +155,11 @@ map_urls = [ views.UpdateDataLayerPermissions.as_view(), name="datalayer_permissions", ), + path( + "map//ws-token/", + views.get_websocket_auth_token, + name="map_websocket_auth_token", + ), ] if settings.DEFAULT_FROM_EMAIL: map_urls.append( diff --git a/umap/views.py b/umap/views.py index c8806b1c..b3557d12 100644 --- a/umap/views.py +++ b/umap/views.py @@ -24,7 +24,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage from django.core.exceptions import PermissionDenied from django.core.mail import send_mail from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.core.signing import BadSignature, Signer +from django.core.signing import BadSignature, Signer, TimestampSigner from django.core.validators import URLValidator, ValidationError from django.http import ( Http404, @@ -40,7 +40,6 @@ from django.shortcuts import get_object_or_404 from django.urls import resolve, reverse, reverse_lazy from django.utils import translation from django.utils.encoding import smart_bytes -from django.utils.http import http_date from django.utils.timezone import make_aware from django.utils.translation import gettext as _ from django.views.decorators.cache import cache_control @@ -327,9 +326,9 @@ class UserDownload(DetailView, SearchMixin): zip_file.writestr(file_name, geojson_file.getvalue()) response = HttpResponse(zip_buffer.getvalue(), content_type="application/zip") - response["Content-Disposition"] = ( - 'attachment; filename="umap_backup_complete.zip"' - ) + response[ + "Content-Disposition" + ] = 'attachment; filename="umap_backup_complete.zip"' return response @@ -675,9 +674,9 @@ class MapDownload(DetailView): def render_to_response(self, context, *args, **kwargs): umapjson = self.object.generate_umapjson(self.request) response = simple_json_response(**umapjson) - response["Content-Disposition"] = ( - f'attachment; filename="umap_backup_{self.object.slug}.umap"' - ) + response[ + "Content-Disposition" + ] = f'attachment; filename="umap_backup_{self.object.slug}.umap"' return response @@ -778,6 +777,36 @@ class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView): return response +def get_websocket_auth_token(request, map_id, map_inst): + """Return an signed authentication token for the currently + connected user, allowing edits for this map over WebSocket. + + If the user is anonymous, return a signed token with the map id. + + The returned token is a signed object with the following keys: + - user: user primary key OR "anonymous" + - map_id: the map id + - permissions: a list of allowed permissions for this user and this map + """ + map_object: Map = Map.objects.get(pk=map_id) + + if map_object.can_edit(request.user, request): + permissions = ["edit"] + if map_object.can_delete(request.user, request): + permissions.append("owner") + + if request.user.is_authenticated: + user = request.user.pk + else: + user = "anonymous" + signed_token = TimestampSigner().sign_object( + {"user": user, "map_id": map_id, "permissions": permissions} + ) + return simple_json_response(token=signed_token) + else: + return HttpResponseForbidden + + class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView): model = Map form_class = MapSettingsForm diff --git a/umap/ws.py b/umap/ws.py index 644032e6..0806af26 100644 --- a/umap/ws.py +++ b/umap/ws.py @@ -7,8 +7,9 @@ from typing import Literal import django import websockets from django.conf import settings +from django.core.signing import TimestampSigner from pydantic import BaseModel -from pydantic.types import UUID4 +from websockets import WebSocketClientProtocol from websockets.server import serve # This needs to run before the django-specific imports @@ -29,7 +30,6 @@ CONNECTIONS = defaultdict(set) class JoinMessage(BaseModel): kind: str = "join" token: str - map_id: UUID4 class OperationMessage(BaseModel): @@ -39,11 +39,15 @@ class OperationMessage(BaseModel): data: dict -async def join_and_listen(map_id, websocket): +async def join_and_listen( + map_id: int, permissions: list, user: str | int, websocket: WebSocketClientProtocol +): """Join a "room" whith other connected peers. New messages will be broadcasted to other connected peers. """ + print(f"{user} joined room #{map_id}") + # FIXME: Persist permissions and user info. CONNECTIONS[map_id].add(websocket) try: async for raw_message in websocket: @@ -67,12 +71,13 @@ async def handler(websocket): # The first event should always be 'join' message: JoinMessage = JoinMessage.model_validate_json(raw_message) + signed = TimestampSigner().unsign_object(message.token, max_age=30) + user, map_id, permissions = signed.values() - user: User = await asyncio.to_thread(get_user, message.token) - map_obj: Map = await asyncio.to_thread(Map.objects.get, message.map_id) - - if map_obj.can_edit(user): - await join_and_listen(message.map_id, websocket) + # We trust the signed info from the server to give access + # If the user can edit the map, let her in + if "edit" in signed["permissions"]: + await join_and_listen(map_id, permissions, user, websocket) async def main():