diff --git a/cookbook/helper/permission_helper.py b/cookbook/helper/permission_helper.py index 2f0217f96..9f793c04b 100644 --- a/cookbook/helper/permission_helper.py +++ b/cookbook/helper/permission_helper.py @@ -6,10 +6,12 @@ from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.http import HttpResponseRedirect from django.urls import reverse, reverse_lazy from django.utils.translation import gettext as _ +from oauth2_provider.contrib.rest_framework import TokenHasScope, TokenHasReadWriteScope +from oauth2_provider.models import AccessToken from rest_framework import permissions from rest_framework.permissions import SAFE_METHODS -from cookbook.models import ShareLink, Recipe, UserPreference, UserSpace +from cookbook.models import ShareLink, Recipe, UserSpace def get_allowed_groups(groups_required): @@ -338,6 +340,34 @@ class CustomUserPermission(permissions.BasePermission): return False +class CustomTokenHasScope(TokenHasScope): + """ + Custom implementation of Django OAuth Toolkit TokenHasScope class + Only difference: if any other authentication method except OAuth2Authentication is used the scope check is ignored + IMPORTANT: do not use this class without any other permission class as it will not check anything besides token scopes + """ + + def has_permission(self, request, view): + if type(request.auth) == AccessToken: + return super().has_permission(request, view) + else: + return request.user.is_authenticated + + +class CustomTokenHasReadWriteScope(TokenHasReadWriteScope): + """ + Custom implementation of Django OAuth Toolkit TokenHasReadWriteScope class + Only difference: if any other authentication method except OAuth2Authentication is used the scope check is ignored + IMPORTANT: do not use this class without any other permission class as it will not check anything besides token scopes + """ + + def has_permission(self, request, view): + if type(request.auth) == AccessToken: + return super().has_permission(request, view) + else: + return request.user.is_authenticated + + def above_space_limit(space): # TODO add file storage limit """ Test if the space has reached any limit (e.g. max recipes, users, ..) diff --git a/cookbook/helper/scope_middleware.py b/cookbook/helper/scope_middleware.py index c3700e03b..a0218f089 100644 --- a/cookbook/helper/scope_middleware.py +++ b/cookbook/helper/scope_middleware.py @@ -1,5 +1,6 @@ from django.urls import reverse from django_scopes import scope, scopes_disabled +from oauth2_provider.contrib.rest_framework import OAuth2Authentication from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token from rest_framework.exceptions import AuthenticationFailed @@ -55,7 +56,7 @@ class ScopeMiddleware: else: if request.path.startswith(prefix + '/api/'): try: - if auth := TokenAuthentication().authenticate(request): + if auth := OAuth2Authentication().authenticate(request): user_space = auth[0].userspace_set.filter(active=True).first() if user_space: request.space = user_space.space diff --git a/cookbook/views/api.py b/cookbook/views/api.py index cfebfcc0b..05b89e179 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -12,6 +12,7 @@ from zipfile import ZipFile import requests import validators +from PIL import UnidentifiedImageError from annoying.decorators import ajax_request from annoying.functions import get_object_or_None from django.contrib import messages @@ -25,16 +26,15 @@ from django.db.models.functions import Coalesce, Lower from django.http import FileResponse, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext as _ from django_scopes import scopes_disabled from icalendar import Calendar, Event -from PIL import UnidentifiedImageError from oauth2_provider.models import AccessToken -from recipe_scrapers import scrape_html, scrape_me +from recipe_scrapers import scrape_me from recipe_scrapers._exceptions import NoSchemaFoundInWildMode from requests.exceptions import MissingSchema from rest_framework import decorators, status, viewsets -from rest_framework.authtoken.models import Token from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.decorators import api_view, permission_classes from rest_framework.exceptions import APIException, PermissionDenied @@ -51,10 +51,10 @@ from cookbook.helper import recipe_url_import as helper from cookbook.helper.HelperFunctions import str2bool from cookbook.helper.image_processing import handle_image from cookbook.helper.ingredient_parser import IngredientParser -from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner, - CustomIsOwnerReadOnly, CustomIsShare, CustomIsShared, +from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, + CustomIsOwnerReadOnly, CustomIsShared, CustomIsSpaceOwner, CustomIsUser, group_required, - is_space_owner, switch_user_active_space, above_space_limit, CustomRecipePermission, CustomUserPermission) + is_space_owner, switch_user_active_space, above_space_limit, CustomRecipePermission, CustomUserPermission, CustomTokenHasReadWriteScope) from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch from cookbook.helper.recipe_url_import import get_from_youtube_scraper, get_images_from_soup from cookbook.helper.scrapers.scrapers import text_scraper @@ -364,7 +364,7 @@ class UserViewSet(viewsets.ModelViewSet): """ queryset = User.objects serializer_class = UserSerializer - permission_classes = [CustomUserPermission] + permission_classes = [CustomUserPermission & CustomTokenHasReadWriteScope] http_method_names = ['get', 'patch'] def get_queryset(self): @@ -382,14 +382,14 @@ class UserViewSet(viewsets.ModelViewSet): class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer - permission_classes = [CustomIsAdmin] + permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] http_method_names = ['get', ] class SpaceViewSet(viewsets.ModelViewSet): queryset = Space.objects serializer_class = SpaceSerializer - permission_classes = [CustomIsOwner & CustomIsAdmin] + permission_classes = [CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope] http_method_names = ['get', 'patch'] def get_queryset(self): @@ -399,7 +399,7 @@ class SpaceViewSet(viewsets.ModelViewSet): class UserSpaceViewSet(viewsets.ModelViewSet): queryset = UserSpace.objects serializer_class = UserSpaceSerializer - permission_classes = [CustomIsSpaceOwner | CustomIsOwnerReadOnly] + permission_classes = [(CustomIsSpaceOwner | CustomIsOwnerReadOnly) & CustomTokenHasReadWriteScope] http_method_names = ['get', 'patch', 'delete'] def destroy(self, request, *args, **kwargs): @@ -417,7 +417,7 @@ class UserSpaceViewSet(viewsets.ModelViewSet): class UserPreferenceViewSet(viewsets.ModelViewSet): queryset = UserPreference.objects serializer_class = UserPreferenceSerializer - permission_classes = [CustomIsOwner, ] + permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] http_method_names = ['get', 'patch', ] def get_queryset(self): @@ -429,7 +429,7 @@ class StorageViewSet(viewsets.ModelViewSet): # TODO handle delete protect error and adjust test queryset = Storage.objects serializer_class = StorageSerializer - permission_classes = [CustomIsAdmin, ] + permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] def get_queryset(self): return self.queryset.filter(space=self.request.space) @@ -438,7 +438,7 @@ class StorageViewSet(viewsets.ModelViewSet): class SyncViewSet(viewsets.ModelViewSet): queryset = Sync.objects serializer_class = SyncSerializer - permission_classes = [CustomIsAdmin, ] + permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] def get_queryset(self): return self.queryset.filter(space=self.request.space) @@ -447,7 +447,7 @@ class SyncViewSet(viewsets.ModelViewSet): class SyncLogViewSet(viewsets.ReadOnlyModelViewSet): queryset = SyncLog.objects serializer_class = SyncLogSerializer - permission_classes = [CustomIsAdmin, ] + permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination def get_queryset(self): @@ -457,7 +457,7 @@ class SyncLogViewSet(viewsets.ReadOnlyModelViewSet): class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin): queryset = Supermarket.objects serializer_class = SupermarketSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] def get_queryset(self): self.queryset = self.queryset.filter(space=self.request.space) @@ -468,7 +468,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): queryset = SupermarketCategory.objects model = SupermarketCategory serializer_class = SupermarketCategorySerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] def get_queryset(self): self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc()) @@ -478,7 +478,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin): queryset = SupermarketCategoryRelation.objects serializer_class = SupermarketCategoryRelationSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination def get_queryset(self): @@ -490,7 +490,7 @@ class KeywordViewSet(viewsets.ModelViewSet, TreeMixin): queryset = Keyword.objects model = Keyword serializer_class = KeywordSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination @@ -498,14 +498,14 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin): queryset = Unit.objects model = Unit serializer_class = UnitSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet): queryset = FoodInheritField.objects serializer_class = FoodInheritFieldSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] def get_queryset(self): # exclude fields not yet implemented @@ -517,7 +517,7 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): queryset = Food.objects model = Food serializer_class = FoodSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination def get_queryset(self): @@ -565,7 +565,7 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): queryset = RecipeBook.objects serializer_class = RecipeBookSerializer - permission_classes = [CustomIsOwner | CustomIsShared] + permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] def get_queryset(self): self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter( @@ -584,7 +584,7 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet): """ queryset = RecipeBookEntry.objects serializer_class = RecipeBookEntrySerializer - permission_classes = [CustomIsOwner | CustomIsShared] + permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] def get_queryset(self): queryset = self.queryset.filter( @@ -612,7 +612,7 @@ class MealPlanViewSet(viewsets.ModelViewSet): """ queryset = MealPlan.objects serializer_class = MealPlanSerializer - permission_classes = [CustomIsOwner | CustomIsShared] + permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] def get_queryset(self): queryset = self.queryset.filter( @@ -637,7 +637,7 @@ class MealTypeViewSet(viewsets.ModelViewSet): """ queryset = MealType.objects serializer_class = MealTypeSerializer - permission_classes = [CustomIsOwner] + permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] def get_queryset(self): queryset = self.queryset.order_by('order', 'id').filter(created_by=self.request.user).filter( @@ -648,7 +648,7 @@ class MealTypeViewSet(viewsets.ModelViewSet): class IngredientViewSet(viewsets.ModelViewSet): queryset = Ingredient.objects serializer_class = IngredientSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination def get_serializer_class(self): @@ -672,7 +672,7 @@ class IngredientViewSet(viewsets.ModelViewSet): class StepViewSet(viewsets.ModelViewSet): queryset = Step.objects serializer_class = StepSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination query_params = [ QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), @@ -716,7 +716,7 @@ class RecipeViewSet(viewsets.ModelViewSet): queryset = Recipe.objects serializer_class = RecipeSerializer # TODO split read and write permission for meal plan guest - permission_classes = [CustomRecipePermission] + permission_classes = [CustomRecipePermission & CustomTokenHasReadWriteScope] pagination_class = RecipePagination query_params = [ @@ -917,7 +917,7 @@ class RecipeViewSet(viewsets.ModelViewSet): class ShoppingListRecipeViewSet(viewsets.ModelViewSet): queryset = ShoppingListRecipe.objects serializer_class = ShoppingListRecipeSerializer - permission_classes = [CustomIsOwner | CustomIsShared] + permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] def get_queryset(self): self.queryset = self.queryset.filter( @@ -933,7 +933,7 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet): class ShoppingListEntryViewSet(viewsets.ModelViewSet): queryset = ShoppingListEntry.objects serializer_class = ShoppingListEntrySerializer - permission_classes = [CustomIsOwner | CustomIsShared] + permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] query_params = [ QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), @@ -972,7 +972,7 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet): class ShoppingListViewSet(viewsets.ModelViewSet): queryset = ShoppingList.objects serializer_class = ShoppingListSerializer - permission_classes = [CustomIsOwner | CustomIsShared] + permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] def get_queryset(self): return self.queryset.filter( @@ -994,7 +994,7 @@ class ShoppingListViewSet(viewsets.ModelViewSet): class ViewLogViewSet(viewsets.ModelViewSet): queryset = ViewLog.objects serializer_class = ViewLogSerializer - permission_classes = [CustomIsOwner] + permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination def get_queryset(self): @@ -1005,7 +1005,7 @@ class ViewLogViewSet(viewsets.ModelViewSet): class CookLogViewSet(viewsets.ModelViewSet): queryset = CookLog.objects serializer_class = CookLogSerializer - permission_classes = [CustomIsOwner] + permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination def get_queryset(self): @@ -1015,7 +1015,7 @@ class CookLogViewSet(viewsets.ModelViewSet): class ImportLogViewSet(viewsets.ModelViewSet): queryset = ImportLog.objects serializer_class = ImportLogSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination def get_queryset(self): @@ -1025,7 +1025,7 @@ class ImportLogViewSet(viewsets.ModelViewSet): class ExportLogViewSet(viewsets.ModelViewSet): queryset = ExportLog.objects serializer_class = ExportLogSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] pagination_class = DefaultPagination def get_queryset(self): @@ -1035,7 +1035,7 @@ class ExportLogViewSet(viewsets.ModelViewSet): class BookmarkletImportViewSet(viewsets.ModelViewSet): queryset = BookmarkletImport.objects serializer_class = BookmarkletImportSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] def get_serializer_class(self): if self.action == 'list': @@ -1049,7 +1049,7 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet): class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin): queryset = UserFile.objects serializer_class = UserFileSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] parser_classes = [MultiPartParser] def get_queryset(self): @@ -1060,7 +1060,7 @@ class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin): class AutomationViewSet(viewsets.ModelViewSet, StandardFilterMixin): queryset = Automation.objects serializer_class = AutomationSerializer - permission_classes = [CustomIsUser] + permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] def get_queryset(self): self.queryset = self.queryset.filter(space=self.request.space).all() @@ -1070,7 +1070,7 @@ class AutomationViewSet(viewsets.ModelViewSet, StandardFilterMixin): class InviteLinkViewSet(viewsets.ModelViewSet, StandardFilterMixin): queryset = InviteLink.objects serializer_class = InviteLinkSerializer - permission_classes = [CustomIsSpaceOwner & CustomIsAdmin] + permission_classes = [CustomIsSpaceOwner & CustomIsAdmin & CustomTokenHasReadWriteScope] def get_queryset(self): if is_space_owner(self.request.user, self.request.space): @@ -1083,7 +1083,7 @@ class InviteLinkViewSet(viewsets.ModelViewSet, StandardFilterMixin): class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin): queryset = CustomFilter.objects serializer_class = CustomFilterSerializer - permission_classes = [CustomIsOwner] + permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] def get_queryset(self): self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter( @@ -1094,7 +1094,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin): class AccessTokenViewSet(viewsets.ModelViewSet): queryset = AccessToken.objects serializer_class = AccessTokenSerializer - permission_classes = [CustomIsOwner] + permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] def get_queryset(self): return self.queryset.filter(user=self.request.user) @@ -1114,16 +1114,22 @@ class CustomAuthToken(ObtainAuthToken): context={'request': request}) serializer.is_valid(raise_exception=True) user = serializer.validated_data['user'] - token, created = Token.objects.get_or_create(user=user) + if token := AccessToken.objects.filter(scope__contains='read').filter(scope__contains='write').first(): + access_token = token + else: + access_token = AccessToken.objects.create(user=request.user, token=f'tda_{str(uuid.uuid4()).replace("-","_")}', expires=(timezone.now() + timezone.timedelta(days=365*5)), scope='read write app') return Response({ - 'token': token.key, + 'id': access_token.id, + 'token': access_token.token, + 'scope': access_token.scope, + 'expires': access_token.expires, 'user_id': user.pk, }) @api_view(['POST']) # @schema(AutoSchema()) #TODO add proper schema -@permission_classes([CustomIsUser]) +@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) # TODO add rate limiting def recipe_from_source(request): """ @@ -1211,7 +1217,7 @@ def recipe_from_source(request): @api_view(['GET']) # @schema(AutoSchema()) #TODO add proper schema -@permission_classes([CustomIsAdmin]) +@permission_classes([CustomIsAdmin & CustomTokenHasReadWriteScope]) # TODO add rate limiting def reset_food_inheritance(request): """ @@ -1227,7 +1233,7 @@ def reset_food_inheritance(request): @api_view(['GET']) # @schema(AutoSchema()) #TODO add proper schema -@permission_classes([CustomIsAdmin]) +@permission_classes([CustomIsAdmin & CustomTokenHasReadWriteScope]) # TODO add rate limiting def switch_active_space(request, space_id): """ @@ -1247,7 +1253,7 @@ def switch_active_space(request, space_id): @api_view(['GET']) # @schema(AutoSchema()) #TODO add proper schema -@permission_classes([CustomIsUser]) +@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) def download_file(request, file_id): """ function to download a user file securely (wrapping as zip to prevent any context based XSS problems) @@ -1272,7 +1278,7 @@ def download_file(request, file_id): @api_view(['POST']) # @schema(AutoSchema()) #TODO add proper schema -@permission_classes([CustomIsUser]) +@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) def import_files(request): """ function to handle files passed by application importer diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 8fb2962f3..05ea05263 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -12,7 +12,6 @@ from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import Group from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError -from django.db.models import Q from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy @@ -20,13 +19,12 @@ from django.utils import timezone from django.utils.translation import gettext as _ from django_scopes import scopes_disabled from oauth2_provider.models import AccessToken -from rest_framework.authtoken.models import Token from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm, SpaceCreateForm, SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm) from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid, switch_user_active_space -from cookbook.models import (Comment, CookLog, InviteLink, MealPlan, SearchFields, SearchPreference, ShareLink, +from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference, ShareLink, Space, ViewLog, UserSpace) from cookbook.tables import (CookLogTable, ViewLogTable) from recipes.version import BUILD_REF, VERSION_NUMBER diff --git a/recipes/settings.py b/recipes/settings.py index 43bd7a0ec..56bb35022 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -239,6 +239,8 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') OAUTH2_PROVIDER = { 'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'bookmarklet': 'only access to bookmarklet'} } +READ_SCOPE = 'read' +WRITE_SCOPE = 'write' REST_FRAMEWORK = {