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.
This commit is contained in:
Alexis Métaireau 2024-04-19 15:25:02 +02:00
parent e2b9b161e6
commit f255c3c8a5
5 changed files with 55 additions and 22 deletions

View file

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

View file

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

View file

@ -155,6 +155,11 @@ map_urls = [
views.UpdateDataLayerPermissions.as_view(),
name="datalayer_permissions",
),
path(
"map/<int:map_id>/ws-token/",
views.get_websocket_auth_token,
name="map_websocket_auth_token",
),
]
if settings.DEFAULT_FROM_EMAIL:
map_urls.append(

View file

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

View file

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