From d5ec23a7fb5d4bb2e11f32ddd7c4995e3080a5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Kl=C3=BCnder?= Date: Sun, 24 Dec 2023 15:40:48 +0100 Subject: [PATCH] get access permissions without signing in --- src/c3nav/control/views/users.py | 2 +- .../0095_accesspermission_for_session.py | 29 +++++ src/c3nav/mapdata/models/access.py | 85 +++++++++++---- src/c3nav/mapdata/utils/user.py | 2 +- src/c3nav/site/views.py | 101 +++++++++--------- 5 files changed, 148 insertions(+), 71 deletions(-) create mode 100644 src/c3nav/mapdata/migrations/0095_accesspermission_for_session.py diff --git a/src/c3nav/control/views/users.py b/src/c3nav/control/views/users.py index 51f9988b..8d43c8d8 100644 --- a/src/c3nav/control/views/users.py +++ b/src/c3nav/control/views/users.py @@ -118,7 +118,7 @@ def user_detail(request, user): # todo: make class based view if form.is_valid(): token = form.get_token() token.save() - token.redeem(user) + token.redeem(user=user) messages.success(request, _('Access permissions successfully granted.')) return redirect(request.path_info) else: diff --git a/src/c3nav/mapdata/migrations/0095_accesspermission_for_session.py b/src/c3nav/mapdata/migrations/0095_accesspermission_for_session.py new file mode 100644 index 00000000..eeffc396 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0095_accesspermission_for_session.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.7 on 2023-12-24 13:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("mapdata", "0094_hub_import_prepare"), + ] + + operations = [ + migrations.AddField( + model_name="accesspermission", + name="session_token", + field=models.UUIDField(editable=False, null=True), + ), + migrations.AlterField( + model_name="accesspermission", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/src/c3nav/mapdata/models/access.py b/src/c3nav/mapdata/models/access.py index 13b82950..de151b76 100644 --- a/src/c3nav/mapdata/models/access.py +++ b/src/c3nav/mapdata/models/access.py @@ -122,14 +122,26 @@ class AccessPermissionToken(models.Model): class RedeemError(Exception): pass - def redeem(self, user=None): - if (user is None and self.redeemed) or (self.accesspermissions.exists() and not self.unlimited): + def redeem(self, /, user=None, request=None): + if user is None and request is not None: + if request.user.is_authenticated: + user = request.user + + grant_to = None + if user: + grant_to = {"user": user} + elif request: + grant_to = { + "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): raise self.RedeemError('Already redeemed.') if timezone.now() > self.valid_until + timedelta(minutes=5 if self.redeemed else 0): raise self.RedeemError('No longer valid.') - if user: + if grant_to: with transaction.atomic(): if self.author_id and self.unique_key: AccessPermission.objects.filter(author_id=self.author_id, unique_key=self.unique_key).delete() @@ -140,7 +152,7 @@ class AccessPermissionToken(models.Model): else {"access_restriction_group_id": int(restriction.pk.removeprefix("g"))} ) AccessPermission.objects.create( - user=user, + **grant_to, **to_grant, author_id=self.author_id, expire_date=restriction.expire_date, @@ -162,7 +174,8 @@ class AccessPermissionToken(models.Model): class AccessPermission(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) + session_token = models.UUIDField(null=True, editable=False) access_restriction = models.ForeignKey(AccessRestriction, on_delete=models.CASCADE, null=True) access_restriction_group = models.ForeignKey(AccessRestrictionGroup, on_delete=models.CASCADE, null=True) expire_date = models.DateTimeField(null=True, verbose_name=_('expires')) @@ -184,11 +197,33 @@ class AccessPermission(models.Model): CheckConstraint(check=(~Q(access_restriction__isnull=True, access_restriction_group__isnull=True) & ~Q(access_restriction__isnull=False, access_restriction_group__isnull=False)), name="permission_needs_restriction_or_restriction_group"), + CheckConstraint(check=(~Q(user__isnull=True, session_token__isnull=True) & + ~Q(user__isnull=False, session_token__isnull=False)), + name="permission_needs_user_or_session"), ) @staticmethod - def user_access_permission_key(user_id): - return 'mapdata:user_access_permission:%d' % user_id + def build_access_permission_key(*, session_token: str|None = None, user_id: int|None = None): + if session_token: + if user_id: + raise ValueError + return ('mapdata:session_access_permission:%s' % session_token) + elif user_id: + return 'mapdata:user_access_permission:%d' % user_id + raise ValueError + + @staticmethod + def request_access_permission_key(request): + if request.user.is_authenticated: + return AccessPermission.build_access_permission_key(user_id=request.user.pk) + return AccessPermission.build_access_permission_key( + session_token=request.session.get("accesspermission_session_token", "NONE") + ) + + def access_permission_key(self): + if self.user_id: + return AccessPermission.build_access_permission_key(user_id=self.user_id) + return AccessPermission.build_access_permission_key(session_token=self.session_token) @classmethod def queryset_for_user(cls, user, can_grant=None): @@ -199,15 +234,27 @@ class AccessPermission(models.Model): ) @classmethod - def get_for_request_with_expire_date(cls, request, can_grant=None): - if not request.user.is_authenticated: - return {} + def queryset_for_session(cls, session): + session_token = session.get("accesspermission_session_token", None) + if not session_token: + return AccessPermission.objects.none() + return AccessPermission.objects.filter(session_token=session_token).filter( + Q(expire_date__isnull=True) | Q(expire_date__gt=timezone.now()) + ) - if request.user_permissions.grant_all_access: - return {pk: None for pk in AccessRestriction.get_all()} + @classmethod + def get_for_request_with_expire_date(cls, request, can_grant=None): + if request.user.is_authenticated: + if request.user_permissions.grant_all_access: + return {pk: None for pk in AccessRestriction.get_all()} + qs = cls.queryset_for_user(request.user, can_grant) + else: + if can_grant: + return {} + qs = cls.queryset_for_session(request.session) result = tuple( - cls.queryset_for_user(request.user, can_grant).select_related( + qs.select_related( 'access_restriction_group' ).prefetch_related('access_restriction_group__accessrestrictions') ) @@ -230,15 +277,15 @@ class AccessPermission(models.Model): @classmethod def get_for_request(cls, request) -> set[int]: - if not request or not request.user.is_authenticated: + if not request: return AccessRestriction.get_all_public() - if request.user_permissions.grant_all_access: + if request.user.is_authenticated and request.user_permissions.grant_all_access: return AccessRestriction.get_all() - cache_key = cls.user_access_permission_key(request.user.pk) + cache_key = cls.request_access_permission_key(request) access_restriction_ids = cache.get(cache_key, None) - if access_restriction_ids is None: + if access_restriction_ids is None or True: permissions = cls.get_for_request_with_expire_date(request) access_restriction_ids = set(permissions.keys()) @@ -261,12 +308,12 @@ class AccessPermission(models.Model): def save(self, *args, **kwargs): with transaction.atomic(): super().save(*args, **kwargs) - transaction.on_commit(lambda: cache.delete(self.user_access_permission_key(self.user_id))) + transaction.on_commit(lambda: cache.delete(self.access_permission_key())) def delete(self, *args, **kwargs): with transaction.atomic(): super().delete(*args, **kwargs) - transaction.on_commit(lambda: cache.delete(self.user_access_permission_key(self.user_id))) + transaction.on_commit(lambda: cache.delete(self.access_permission_key())) class AccessRestrictionMixin(SerializableMixin, models.Model): diff --git a/src/c3nav/mapdata/utils/user.py b/src/c3nav/mapdata/utils/user.py index e21fc14e..93432ce4 100644 --- a/src/c3nav/mapdata/utils/user.py +++ b/src/c3nav/mapdata/utils/user.py @@ -17,7 +17,7 @@ def get_user_data(request): } if permissions: result.update({ - 'title': _('not logged in'), + 'title': _('Login'), 'subtitle': ngettext_lazy('%d area unlocked', '%d areas unlocked', len(permissions)) % len(permissions), 'permissions': tuple(permissions), }) diff --git a/src/c3nav/site/views.py b/src/c3nav/site/views.py index 47059551..ef2c8787 100644 --- a/src/c3nav/site/views.py +++ b/src/c3nav/site/views.py @@ -9,6 +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.contrib.auth.views import redirect_to_login +from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, SuspiciousOperation from django.core.paginator import Paginator from django.core.serializers.json import DjangoJSONEncoder @@ -30,7 +31,7 @@ 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 AccessPermissionToken +from c3nav.mapdata.models.access import AccessPermissionToken, AccessPermission from c3nav.mapdata.models.locations import LocationRedirect, Position, SpecificLocation, get_position_secret from c3nav.mapdata.models.report import Report, ReportUpdate from c3nav.mapdata.utils.locations import (get_location_by_id_for_request, get_location_by_slug_for_request, @@ -64,29 +65,35 @@ def check_location(location: Optional[str], request) -> Optional[SpecificLocatio def map_index(request, mode=None, slug=None, slug2=None, details=None, options=None, nearby=None, pos=None, embed=None): # check for access token - access_signed_data = request.GET.get('access') - if access_signed_data: - try: - token = AccessPermissionForm.load_signed_data(access_signed_data) - except SignedPermissionDataError as e: - return HttpResponse(str(e).encode(), content_type='text/plain', status=400) - - num_restrictions = len(token.restrictions) + access_token = request.GET.get('access') + if access_token: with transaction.atomic(): - token.save() + try: + token = AccessPermissionToken.objects.select_for_update().get(token=access_token, redeemed=False, + valid_until__gte=timezone.now()) + except AccessPermissionToken.DoesNotExist: + messages.error(request, _('This token does not exist or was already redeemed.')) + else: + num_restrictions = len(token.restrictions) + with transaction.atomic(): + token.save() + token.redeem(request=request) + token.save() - if not request.user.is_authenticated: - messages.info(request, _('You need to log in to unlock areas.')) - request.session['redeem_token_on_login'] = str(token.token) - token.redeem() - return redirect_to_login(request.path_info, 'site.login') - - token.redeem(request.user) - token.save() - - messages.success(request, ngettext_lazy('Area successfully unlocked.', - 'Areas successfully unlocked.', num_restrictions)) - return redirect('site.index') + if request.user.is_authenticated: + messages.success(request, ngettext_lazy('Area successfully unlocked.', + 'Areas successfully unlocked.', num_restrictions)) + else: + messages.success( + request, + ngettext_lazy( + 'Area successfully unlocked. ' + 'If you sign in, it will also be saved to your account.', + 'Areas successfully unlocked. ' + 'If you sign in, they will also be saved to your account.', + num_restrictions + ) + ) origin = None destination = None @@ -204,23 +211,14 @@ def close_response(request): return redirect(redirect_path) -def redeem_token_after_login(request): - token = request.session.pop('redeem_token_on_login', None) - if not token: - return - - try: - token = AccessPermissionToken.objects.get(token=token) - except AccessPermissionToken.DoesNotExist: - return - - try: - token.redeem(request.user) - except AccessPermissionToken.RedeemError: - messages.error(request, _('Areas could not be unlocked because the token has expired.')) - return - - messages.success(request, token.redeem_success_message) +def migrate_access_permissions_after_login(request): + if not request.user.is_authenticated: + raise ValueError + with transaction.atomic(): + session_token = request.session.pop("accesspermission_session_token", None) + if session_token: + AccessPermission.objects.filter(session_token=session_token).update(session_token=None, user=request.user) + transaction.on_commit(lambda: cache.delete(AccessPermission.request_access_permission_key(request))) @never_cache @@ -232,7 +230,7 @@ def login_view(request): form = AuthenticationForm(request, data=request.POST) if form.is_valid(): login(request, form.user_cache) - redeem_token_after_login(request) + migrate_access_permissions_after_login(request) return close_response(request) else: form = AuthenticationForm(request) @@ -270,7 +268,7 @@ def register_view(request): if form.is_valid(): user = form.save() login(request, user) - redeem_token_after_login(request) + migrate_access_permissions_after_login(request) return close_response(request) else: form = UserCreationForm() @@ -357,17 +355,20 @@ def access_redeem_view(request, token): num_restrictions = len(token.restrictions) if request.method == 'POST': - if not request.user.is_authenticated: - messages.info(request, _('You need to log in to unlock areas.')) - request.session['redeem_token_on_login'] = str(token.token) - token.redeem() - return redirect('site.login') - - token.redeem(request.user) + token.redeem(request=request) token.save() - messages.success(request, ngettext_lazy('Area successfully unlocked.', - 'Areas successfully unlocked.', num_restrictions)) + if request.user.is_authenticated: + messages.success(request, ngettext_lazy('Area successfully unlocked.', + 'Areas successfully unlocked.', num_restrictions)) + else: + messages.success( + request, + ngettext_lazy( + 'Area successfully unlocked. If you sign in, it will also be saved to your account.', + 'Areas successfully unlocked. If you sign in, they will also be saved to your account.', + num_restrictions + )) return redirect('site.index') return render(request, 'site/confirm.html', {