mirror of
https://github.com/umap-project/umap.git
synced 2025-04-29 03:42:37 +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-agnocomplete==2.2.0",
|
||||||
"django-environ==0.11.2",
|
"django-environ==0.11.2",
|
||||||
"django-probes==1.7.0",
|
"django-probes==1.7.0",
|
||||||
"django-sesame==3.2.2",
|
|
||||||
"Pillow==10.3.0",
|
"Pillow==10.3.0",
|
||||||
"psycopg==3.1.19",
|
"psycopg==3.1.19",
|
||||||
"pydantic==2.7.0",
|
"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",)
|
AUTHENTICATION_BACKENDS += ("django.contrib.auth.backends.ModelBackend",)
|
||||||
|
|
||||||
# Websockets configuration
|
|
||||||
AUTHENTICATION_BACKENDS += ("sesame.backends.ModelBackend",)
|
|
||||||
|
|
||||||
SESAME_MAX_AGE = 30
|
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
|
|
|
@ -155,6 +155,11 @@ map_urls = [
|
||||||
views.UpdateDataLayerPermissions.as_view(),
|
views.UpdateDataLayerPermissions.as_view(),
|
||||||
name="datalayer_permissions",
|
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:
|
if settings.DEFAULT_FROM_EMAIL:
|
||||||
map_urls.append(
|
map_urls.append(
|
||||||
|
|
|
@ -24,7 +24,7 @@ from django.contrib.staticfiles.storage import staticfiles_storage
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
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.core.validators import URLValidator, ValidationError
|
||||||
from django.http import (
|
from django.http import (
|
||||||
Http404,
|
Http404,
|
||||||
|
@ -40,7 +40,6 @@ from django.shortcuts import get_object_or_404
|
||||||
from django.urls import resolve, reverse, reverse_lazy
|
from django.urls import resolve, reverse, reverse_lazy
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.encoding import smart_bytes
|
from django.utils.encoding import smart_bytes
|
||||||
from django.utils.http import http_date
|
|
||||||
from django.utils.timezone import make_aware
|
from django.utils.timezone import make_aware
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.decorators.cache import cache_control
|
from django.views.decorators.cache import cache_control
|
||||||
|
@ -327,9 +326,9 @@ class UserDownload(DetailView, SearchMixin):
|
||||||
zip_file.writestr(file_name, geojson_file.getvalue())
|
zip_file.writestr(file_name, geojson_file.getvalue())
|
||||||
|
|
||||||
response = HttpResponse(zip_buffer.getvalue(), content_type="application/zip")
|
response = HttpResponse(zip_buffer.getvalue(), content_type="application/zip")
|
||||||
response["Content-Disposition"] = (
|
response[
|
||||||
'attachment; filename="umap_backup_complete.zip"'
|
"Content-Disposition"
|
||||||
)
|
] = 'attachment; filename="umap_backup_complete.zip"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ -675,9 +674,9 @@ class MapDownload(DetailView):
|
||||||
def render_to_response(self, context, *args, **kwargs):
|
def render_to_response(self, context, *args, **kwargs):
|
||||||
umapjson = self.object.generate_umapjson(self.request)
|
umapjson = self.object.generate_umapjson(self.request)
|
||||||
response = simple_json_response(**umapjson)
|
response = simple_json_response(**umapjson)
|
||||||
response["Content-Disposition"] = (
|
response[
|
||||||
f'attachment; filename="umap_backup_{self.object.slug}.umap"'
|
"Content-Disposition"
|
||||||
)
|
] = f'attachment; filename="umap_backup_{self.object.slug}.umap"'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ -778,6 +777,36 @@ class MapCreate(FormLessEditMixin, PermissionsMixin, CreateView):
|
||||||
return response
|
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):
|
class MapUpdate(FormLessEditMixin, PermissionsMixin, UpdateView):
|
||||||
model = Map
|
model = Map
|
||||||
form_class = MapSettingsForm
|
form_class = MapSettingsForm
|
||||||
|
|
21
umap/ws.py
21
umap/ws.py
|
@ -7,8 +7,9 @@ from typing import Literal
|
||||||
import django
|
import django
|
||||||
import websockets
|
import websockets
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.signing import TimestampSigner
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic.types import UUID4
|
from websockets import WebSocketClientProtocol
|
||||||
from websockets.server import serve
|
from websockets.server import serve
|
||||||
|
|
||||||
# This needs to run before the django-specific imports
|
# This needs to run before the django-specific imports
|
||||||
|
@ -29,7 +30,6 @@ CONNECTIONS = defaultdict(set)
|
||||||
class JoinMessage(BaseModel):
|
class JoinMessage(BaseModel):
|
||||||
kind: str = "join"
|
kind: str = "join"
|
||||||
token: str
|
token: str
|
||||||
map_id: UUID4
|
|
||||||
|
|
||||||
|
|
||||||
class OperationMessage(BaseModel):
|
class OperationMessage(BaseModel):
|
||||||
|
@ -39,11 +39,15 @@ class OperationMessage(BaseModel):
|
||||||
data: dict
|
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.
|
"""Join a "room" whith other connected peers.
|
||||||
|
|
||||||
New messages will be broadcasted to 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)
|
CONNECTIONS[map_id].add(websocket)
|
||||||
try:
|
try:
|
||||||
async for raw_message in websocket:
|
async for raw_message in websocket:
|
||||||
|
@ -67,12 +71,13 @@ async def handler(websocket):
|
||||||
|
|
||||||
# The first event should always be 'join'
|
# The first event should always be 'join'
|
||||||
message: JoinMessage = JoinMessage.model_validate_json(raw_message)
|
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)
|
# We trust the signed info from the server to give access
|
||||||
map_obj: Map = await asyncio.to_thread(Map.objects.get, message.map_id)
|
# If the user can edit the map, let her in
|
||||||
|
if "edit" in signed["permissions"]:
|
||||||
if map_obj.can_edit(user):
|
await join_and_listen(map_id, permissions, user, websocket)
|
||||||
await join_and_listen(message.map_id, websocket)
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
|
Loading…
Reference in a new issue