From 8497f8e3e648b94f60b87807054bbbf55cb2b930 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Sun, 4 Dec 2022 13:07:45 +0100 Subject: [PATCH] Migrate API authentication to client credentials --- README.rst | 2 +- aleksis/apps/kort/api.py | 87 ++++++++++++++----- aleksis/apps/kort/apps.py | 23 ++++- .../kort/migrations/0015_migrate_scopes.py | 19 ++++ aleksis/apps/kort/models.py | 8 +- 5 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 aleksis/apps/kort/migrations/0015_migrate_scopes.py diff --git a/README.rst b/README.rst index aa94017..87f1b87 100644 --- a/README.rst +++ b/README.rst @@ -16,8 +16,8 @@ Licence :: + Copyright © 2021, 2022 Jonathan Weth <dev@jonathanweth.de> Copyright © 2021 Margarete Grassl <grasslma@katharineum.de> - Copyright © 2021 Jonathan Weth <dev@jonathanweth.de> Licenced under the EUPL, version 1.2 or later diff --git a/aleksis/apps/kort/api.py b/aleksis/apps/kort/api.py index 5179acf..ceaad65 100644 --- a/aleksis/apps/kort/api.py +++ b/aleksis/apps/kort/api.py @@ -4,36 +4,71 @@ from django.utils import timezone from celery.result import allow_join_result from celery.states import SUCCESS -from oauth2_provider.contrib.rest_framework import TokenHasScope -from rest_framework import generics, permissions, serializers +from oauth2_provider.oauth2_backends import get_oauthlib_core +from oauthlib.common import Request as OauthlibRequest +from rest_framework import generics, serializers +from rest_framework.authentication import BaseAuthentication from rest_framework.exceptions import APIException, ValidationError from rest_framework.permissions import BasePermission from rest_framework.response import Response from rest_framework.views import APIView from aleksis.apps.kort.models import Card, CardPrinter, CardPrintJob +from aleksis.core.util.auth_helpers import AppScopes admin.autodiscover() -class CorrectPrinterPermission(BasePermission): +class OAuth2ClientAuthentication(BaseAuthentication): + """OAuth 2 authentication backend using client credentials authentication.""" + + www_authenticate_realm = "api" + + def authenticate(self, request): + """Authenticate the request with client credentials.""" + oauthlib_core = get_oauthlib_core() + uri, http_method, body, headers = oauthlib_core._extract_params(request) + oauth_request = OauthlibRequest(uri, http_method, body, headers) + + # Verify general authentication of the client + if not oauthlib_core.server.request_validator.authenticate_client(oauth_request): + # Client credentials were invalid + return None + + request.auth = oauth_request + + return (oauth_request.client.client_id, oauth_request) + + +class ClientProtectedResourcePermission(BasePermission): + def has_object_permission(self, request, view, obj): + # Verify scopes of configured application + # The OAuth request was enriched with a reference to the Application when using the + # validator above. + if not request.auth.client.allowed_scopes: + # If there are no allowed scopes, the client is not allowed to access this resource + return None + + required_scopes = set(self.get_scopes(request, view, obj) or []) + allowed_scopes = set(AppScopes().get_available_scopes(request.auth.client) or []) + return required_scopes.issubset(allowed_scopes) + + def get_scopes(self, request, view, obj): + return view.get_scopes() + + +class CorrectPrinterPermission(ClientProtectedResourcePermission): """Check whether the OAuth2 application belongs to the printer.""" - def has_object_permission(self, request, view, obj) -> bool: - token = request.auth - if token.application == obj.oauth2_application: - return True - return False + def get_scopes(self, request, view, obj): + return [obj.scope] class CorrectJobPrinterPermission(BasePermission): """Check whether the OAuth2 application belongs to the job's printer.""" - def has_object_permission(self, request, view, obj) -> bool: - token = request.auth - if token.application == obj.printer.oauth2_application: - return True - return False + def get_scopes(self, request, view, obj): + return [obj.printer.scope] class CardPrinterSerializer(serializers.ModelSerializer): @@ -91,21 +126,22 @@ class CardChipNumberSerializer(serializers.ModelSerializer): class CardPrinterDetails(generics.RetrieveAPIView): """Show details about the card printer.""" - permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission] - required_scopes = ["card_printer"] + authentication_classes = [OAuth2ClientAuthentication] + permission_classes = [CorrectPrinterPermission] serializer_class = CardPrinterSerializer queryset = CardPrinter.objects.all() def get_object(self): token = self.request.auth - return token.application.card_printers.all().first() + return token.client.card_printers.all().first() class CardPrinterUpdateStatus(generics.UpdateAPIView): """Update the status of the card printer.""" - permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission] - required_scopes = ["card_printer"] + authentication_classes = [OAuth2ClientAuthentication] + + permission_classes = [CorrectPrinterPermission] serializer_class = CardPrinterStatusSerializer queryset = CardPrinter.objects.all() @@ -120,8 +156,9 @@ class CardPrinterUpdateStatus(generics.UpdateAPIView): class GetNextPrintJob(APIView): """Get the next print job.""" - permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission] - required_scopes = ["card_printer"] + authentication_classes = [OAuth2ClientAuthentication] + + permission_classes = [CorrectPrinterPermission] serializer_class = CardPrinterSerializer queryset = CardPrinter.objects.all() @@ -140,8 +177,9 @@ class GetNextPrintJob(APIView): class CardPrintJobUpdateStatusView(generics.UpdateAPIView): """Update the status of the card printer.""" - permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectJobPrinterPermission] - required_scopes = ["card_printer"] + authentication_classes = [OAuth2ClientAuthentication] + + permission_classes = [CorrectJobPrinterPermission] serializer_class = CardPrintJobStatusSerializer queryset = CardPrintJob.objects.all() @@ -149,8 +187,9 @@ class CardPrintJobUpdateStatusView(generics.UpdateAPIView): class CardPrintJobSetChipNumberView(generics.UpdateAPIView): """Update the status of the card printer.""" - permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectJobPrinterPermission] - required_scopes = ["card_printer"] + authentication_classes = [OAuth2ClientAuthentication] + + permission_classes = [CorrectJobPrinterPermission] serializer_class = CardChipNumberSerializer queryset = CardPrintJob.objects.all() diff --git a/aleksis/apps/kort/apps.py b/aleksis/apps/kort/apps.py index fb1d2cd..1ca3196 100644 --- a/aleksis/apps/kort/apps.py +++ b/aleksis/apps/kort/apps.py @@ -1,3 +1,6 @@ +from django.apps import apps +from django.db import models +from django.db.models import functions from django.utils.translation import gettext as _ from aleksis.core.util.apps import AppConfig @@ -13,15 +16,29 @@ class DefaultConfig(AppConfig): } licence = "EUPL-1.2+" copyright_info = ( - ([2021], "Margarete Grassl", "grasslma@katharineum.de"), ( - [2021], + [2021, 2022], "Jonathan Weth", "dev@jonathanweth.de", ), + ([2021], "Margarete Grassl", "grasslma@katharineum.de"), ) @classmethod def get_all_scopes(cls) -> dict[str, str]: """Return all OAuth scopes and their descriptions for this app.""" - return {"card_printer": _("Access and manage printer status and print jobs")} + CardPrinter = apps.get_model("kort", "CardPrinter") + label_prefix = _("Access and manage printer status and print jobs") + scopes = dict( + CardPrinter.objects.annotate( + scope=functions.Concat( + models.Value(f"{CardPrinter.SCOPE_PREFIX}_"), + models.F("id"), + output_field=models.CharField(), + ), + label=functions.Concat(models.Value(f"{label_prefix}: "), models.F("name")), + ) + .values_list("scope", "label") + .distinct() + ) + return scopes diff --git a/aleksis/apps/kort/migrations/0015_migrate_scopes.py b/aleksis/apps/kort/migrations/0015_migrate_scopes.py new file mode 100644 index 0000000..5173713 --- /dev/null +++ b/aleksis/apps/kort/migrations/0015_migrate_scopes.py @@ -0,0 +1,19 @@ +from django.db import migrations + +def _migrate_scopes(apps, schema_editor): + CardPrinter = apps.get_model("kort", "CardPrinter") + for printer in CardPrinter.objects.all(): + application = printer.oauth2_application + application.allowed_scopes = [f"card_printer_{printer.id}"] + application.authorization_grant_type = "client-credentials" + application.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('kort', '0014_auto_20220803_0025'), + ] + + operations = [ + migrations.RunPython(_migrate_scopes) + ] diff --git a/aleksis/apps/kort/models.py b/aleksis/apps/kort/models.py index b1faaf4..e84e2dc 100644 --- a/aleksis/apps/kort/models.py +++ b/aleksis/apps/kort/models.py @@ -94,9 +94,10 @@ class CardPrinter(ExtensibleModel): if not self.oauth2_application: application = OAuthApplication( client_type=OAuthApplication.CLIENT_CONFIDENTIAL, - authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE, + authorization_grant_type=OAuthApplication.GRANT_CLIENT_CREDENTIALS, name=f"Card printer: {self.name}", redirect_uris="urn:ietf:wg:oauth:2.0:oob", + allowed_scopes=[self.scope], ) application.save() self.oauth2_application = application @@ -171,6 +172,11 @@ class CardPrinter(ExtensibleModel): return jobs.first() return None + @property + def scope(self) -> str: + """Return OAuth2 scope name to access PDF file via API.""" + return f"{self.SCOPE_PREFIX}_{self.id}" + class Meta: verbose_name = _("Card printer") verbose_name_plural = _("Card printers") -- GitLab