mirror of
https://github.com/umap-project/umap.git
synced 2025-04-28 19:42:36 +02:00
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:
parent
e2b9b161e6
commit
f255c3c8a5
5 changed files with 55 additions and 22 deletions
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
21
umap/ws.py
21
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():
|
||||
|
|
Loading…
Reference in a new issue