From 168a6a5af61ef629ce8eb5266771c3ed09322f6a Mon Sep 17 00:00:00 2001 From: Jenny Danzmayr Date: Thu, 15 Aug 2024 03:31:16 +0200 Subject: [PATCH] fixed redemption of signed access tokens --- src/c3nav/control/forms.py | 17 +++++--- src/c3nav/control/views/access.py | 6 ++- src/c3nav/mapdata/models/access.py | 65 +++++++++++++++++++++++++++++- src/c3nav/site/views.py | 18 ++++++--- 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/src/c3nav/control/forms.py b/src/c3nav/control/forms.py index 62f96c65..f01fdace 100644 --- a/src/c3nav/control/forms.py +++ b/src/c3nav/control/forms.py @@ -40,12 +40,15 @@ class AccessPermissionForm(Form): # remember author if this form is saved self.author = author or request.user - author_permissions = request.user_permissions if request else author.permissions + author_permissions = request.user_permissions if request else UserPermissions.get_for_user(author) self.expire_date = expire_date # determine which access permissions the author can grant - self.author_access_permissions = AccessPermission.get_for_request_with_expire_date(request, can_grant=True) + if request: + self.author_access_permissions = AccessPermission.get_for_request_with_expire_date(request, can_grant=True) + else: + self.author_access_permissions = AccessPermission.get_for_user_with_expire_date(author, can_grant=True) access_restrictions = AccessRestriction.objects.filter( pk__in=self.author_access_permissions.keys() @@ -62,7 +65,11 @@ class AccessPermissionForm(Form): } # get access permission groups - groups = AccessRestrictionGroup.qs_for_request(request).prefetch_related( + if request: + groups = AccessRestrictionGroup.qs_for_request(request) + else: + groups = AccessRestrictionGroup.qs_for_user(author) + groups = groups.prefetch_related( Prefetch('accessrestrictions', AccessRestriction.objects.only('pk')) ) self.group_contents: dict[int, set[int]] = { @@ -199,7 +206,7 @@ class AccessPermissionForm(Form): data = { 'id': self.data['access_restrictions'], 'time': int(time.time()), - 'valid_until': int(self.cleaned_data['expires'].strftime('%s')), + 'valid_until': int(self.cleaned_data['expires'].strftime('%s')) if self.cleaned_data['expires'] else None, 'author': self.author.pk, } if key is not None: @@ -265,7 +272,7 @@ class AccessPermissionForm(Form): raise SignedPermissionDataError('Author does not exist.') api_secrets = author.api_secrets.filter( - scope_grant_permission=True + scope_grant_permissions=True ).valid_only().values_list('api_secret', flat=True) if not api_secrets: raise SignedPermissionDataError('Author has no API secret.') diff --git a/src/c3nav/control/views/access.py b/src/c3nav/control/views/access.py index 92170848..d1683120 100644 --- a/src/c3nav/control/views/access.py +++ b/src/c3nav/control/views/access.py @@ -1,3 +1,4 @@ +from contextlib import suppress from urllib.parse import urlencode from django.conf import settings @@ -22,8 +23,9 @@ def grant_access(request): # todo: make class based view token = form.get_token() token.save() if settings.DEBUG: - signed_data = form.get_signed_data() - print('/?'+urlencode({'access': signed_data})) + with suppress(ValueError): + signed_data = form.get_signed_data() + print('/?'+urlencode({'access': signed_data})) return redirect(reverse('control.access.qr', kwargs={'token': token.token})) else: form = AccessPermissionForm(request=request) diff --git a/src/c3nav/mapdata/models/access.py b/src/c3nav/mapdata/models/access.py index 96dc3b50..ff7e7fc0 100644 --- a/src/c3nav/mapdata/models/access.py +++ b/src/c3nav/mapdata/models/access.py @@ -84,6 +84,20 @@ class AccessRestrictionGroup(TitledMixin, models.Model): filter_perms = all_permissions - permissions return ~Q(accessrestrictions__pk__in=filter_perms) + @classmethod + def qs_for_user(cls, user): + return cls.objects.filter(cls.q_for_user(user)) + + @classmethod + def q_for_user(cls, user): + if user.is_authenticated and user.is_superuser: + return Q() + all_permissions = AccessRestriction.get_all() + permissions = AccessPermission.get_for_user(user) + # now we filter out groups where the user doesn't have a permission for all members + filter_perms = all_permissions - permissions + return ~Q(accessrestrictions__pk__in=filter_perms) + def default_valid_until(): return timezone.now()+timedelta(seconds=20) @@ -134,7 +148,7 @@ class AccessPermissionToken(models.Model): "session_token": request.session.setdefault("accesspermission_session_token", str(uuid.uuid4())) } - if (grant_to is None and self.redeemed) or (self.accesspermissions.exists() and not self.unlimited): + if (grant_to is None and self.redeemed) or (self.pk and self.accesspermissions.exists() and not self.unlimited): raise self.RedeemError('Already redeemed.') if timezone.now() > self.valid_until + timedelta(minutes=5 if self.redeemed else 0): @@ -290,6 +304,55 @@ class AccessPermission(models.Model): access_restriction_ids = set(permissions.keys()) + expire_date = min((e for e in permissions.values() if e), default=timezone.now() + timedelta(seconds=120)) + cache.set(cache_key, access_restriction_ids, max(0.0, (expire_date - timezone.now()).total_seconds())) + return set(access_restriction_ids) | AccessRestriction.get_all_public() + + @classmethod + def get_for_user_with_expire_date(cls, user, can_grant=None): + from c3nav.control.models import UserPermissions + if UserPermissions.get_for_user(user).grant_all_access: + return {pk: None for pk in AccessRestriction.get_all()} + qs = cls.queryset_for_user(user, can_grant) + + result = tuple( + qs.select_related( + 'access_restriction_group' + ).prefetch_related('access_restriction_group__accessrestrictions') + ) + + # collect permissions (can be multiple for one restriction) + permissions = {} + for permission in result: + if permission.access_restriction_id: + permissions.setdefault(permission.access_restriction_id, set()).add(permission.expire_date) + if permission.access_restriction_group_id: + for member in permission.access_restriction_group.accessrestrictions.all(): + permissions.setdefault(member.pk, set()).add(permission.expire_date) + + # get latest expire date for each permission + permissions = { + access_restriction_id: None if None in expire_dates else max(expire_dates) + for access_restriction_id, expire_dates in permissions.items() + } + return permissions + + @classmethod + def get_for_user(cls, user) -> set[int]: + from c3nav.control.models import UserPermissions + if not user or not user.is_authenticated: + return AccessRestriction.get_all_public() + + if UserPermissions.get_for_user(user).grant_all_access: + return AccessRestriction.get_all() + + cache_key = cls.build_access_permission_key(user_id=user.pk) + access_restriction_ids = cache.get(cache_key, None) + if access_restriction_ids is None or True: + permissions = cls.get_for_user_with_expire_date(user) + + access_restriction_ids = set(permissions.keys()) + expire_date = min((e for e in permissions.values() if e), default=timezone.now()+timedelta(seconds=120)) cache.set(cache_key, access_restriction_ids, max(0.0, (expire_date-timezone.now()).total_seconds())) return set(access_restriction_ids) | AccessRestriction.get_all_public() diff --git a/src/c3nav/site/views.py b/src/c3nav/site/views.py index 6f133bde..869cef56 100644 --- a/src/c3nav/site/views.py +++ b/src/c3nav/site/views.py @@ -9,7 +9,7 @@ from django.contrib.auth import login, logout from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm from django.core.cache import cache -from django.core.exceptions import ObjectDoesNotExist, SuspiciousOperation +from django.core.exceptions import ObjectDoesNotExist, SuspiciousOperation, ValidationError from django.core.paginator import Paginator from django.core.serializers.json import DjangoJSONEncoder from django.db import transaction @@ -27,6 +27,7 @@ from django.views.decorators.http import etag from c3nav import __version__ as c3nav_version from c3nav.api.models import Secret +from c3nav.control.forms import AccessPermissionForm, SignedPermissionDataError from c3nav.mapdata.grid import grid from c3nav.mapdata.models import Location, Source from c3nav.mapdata.models.access import AccessPermission, AccessPermissionToken @@ -67,16 +68,21 @@ def map_index(request, mode=None, slug=None, slug2=None, details=None, options=N if access_token: with transaction.atomic(): try: - token = AccessPermissionToken.objects.select_for_update().get(token=access_token, redeemed=False, - valid_until__gte=timezone.now()) - except AccessPermissionToken.DoesNotExist: + if ':' in access_token: + token = AccessPermissionForm.load_signed_data(access_token) + else: + token = AccessPermissionToken.objects.select_for_update().get(token=access_token, redeemed=False, + valid_until__gte=timezone.now()) + except (AccessPermissionToken.DoesNotExist, ValueError, ValidationError, SignedPermissionDataError): messages.error(request, _('This token does not exist or was already redeemed.')) else: num_restrictions = len(token.restrictions) with transaction.atomic(): - token.save() + if token.pk: + token.save() token.redeem(request=request) - token.save() + if token.pk: + token.save() if request.user.is_authenticated: messages.success(request, ngettext_lazy('Area successfully unlocked.',