diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1e02f91134b40dca486de487296a37226f21ad1c..98c11d0143aefdcfc195db3f80d8da4a2d5c4e9b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,7 @@ Added * Provide an ``ExtensiblePolymorphicModel`` to support the features of extensible models for polymorphic models and vice-versa. * Implement optional Sentry integration for error and performance tracing. +* Option to limit allowed scopes per application, including mixin to enforce that limit on OAuth resource views * Support trusted OAuth applications that leave out the authorisation screen. * Add birthplace to Person model. diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index fd611ad0f417e3050847a8fd7b6f3b213892be00..2259b294c3dde52d5e833b1b76196da112ee480c 100644 --- a/aleksis/core/forms.py +++ b/aleksis/core/forms.py @@ -31,6 +31,7 @@ from .registries import ( person_preferences_registry, site_preferences_registry, ) +from .util.auth_helpers import AppScopes from .util.core_helpers import get_site_preferences @@ -595,6 +596,12 @@ class ListActionForm(ActionForm): class OAuthApplicationForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["allowed_scopes"].widget = forms.SelectMultiple( + choices=list(AppScopes().get_all_scopes().items()) + ) + class Meta: model = OAuthApplication fields = ( @@ -602,6 +609,7 @@ class OAuthApplicationForm(forms.ModelForm): "client_id", "client_secret", "client_type", + "allowed_scopes", "redirect_uris", "skip_authorization", ) diff --git a/aleksis/core/migrations/0026_oauthapplication_allowed_scopes.py b/aleksis/core/migrations/0026_oauthapplication_allowed_scopes.py new file mode 100644 index 0000000000000000000000000000000000000000..23e1b40b56a2ce2fbe1082b8728adea642f3e82b --- /dev/null +++ b/aleksis/core/migrations/0026_oauthapplication_allowed_scopes.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-11-05 10:37 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0025_oauth_align_user_fk'), + ] + + operations = [ + migrations.AddField( + model_name='oauthapplication', + name='allowed_scopes', + field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=32), blank=True, null=True, size=None, verbose_name='Allowed scopes that clients can request'), + ), + ] diff --git a/aleksis/core/migrations/0023_person_place_of_birth.py b/aleksis/core/migrations/0027_person_place_of_birth.py similarity index 87% rename from aleksis/core/migrations/0023_person_place_of_birth.py rename to aleksis/core/migrations/0027_person_place_of_birth.py index 3f5f285e774b4c976968be35d276c30ef15c517f..53c5bf169a3e852f6a83d9681bf6a9dd7666bb1d 100644 --- a/aleksis/core/migrations/0023_person_place_of_birth.py +++ b/aleksis/core/migrations/0027_person_place_of_birth.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('core', '0022_public_favicon'), + ('core', '0026_oauthapplication_allowed_scopes'), ] operations = [ diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 2af438c093406ec5e5e792c22cb639d9ded7a0e6..9fd7b97de400e794e6a6842dc82f6da85fa280ad 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Group as DjangoGroup from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField from django.contrib.sites.models import Site from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator @@ -1108,6 +1109,14 @@ class OAuthApplication(AbstractApplication): max_length=32, choices=AbstractApplication.GRANT_TYPES, blank=True, null=True ) + # Optional list of alloewd scopes + allowed_scopes = ArrayField( + models.CharField(max_length=32), + verbose_name=_("Allowed scopes that clients can request"), + null=True, + blank=True, + ) + def allows_grant_type(self, *grant_types: set[str]) -> bool: allowed_grants = get_site_preferences()["auth__oauth_allowed_grants"] diff --git a/aleksis/core/templates/oauth2_provider/application/detail.html b/aleksis/core/templates/oauth2_provider/application/detail.html index 4e896dad70d5860516e7f3caedd9b424305b8025..8e55f91122debafbb0a86984a8166768cf879457 100644 --- a/aleksis/core/templates/oauth2_provider/application/detail.html +++ b/aleksis/core/templates/oauth2_provider/application/detail.html @@ -46,6 +46,14 @@ {{ application.client_type }} </td> </tr> + <tr> + <th> + {% trans "Allowed scopes" %} + </th> + <td> + {{ application.allowed_scopes|join:", " }} + </td> + </tr> <tr> <th> {% trans "Redirect URIs" %} diff --git a/aleksis/core/util/auth_helpers.py b/aleksis/core/util/auth_helpers.py index 4f6e403cb678ff4a39f0199b0670ff6b3d8cdb45..21acddda5acef95d6fa7c9636e999717d899ca0b 100644 --- a/aleksis/core/util/auth_helpers.py +++ b/aleksis/core/util/auth_helpers.py @@ -10,6 +10,10 @@ from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from oauth2_provider.models import AbstractApplication from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.scopes import BaseScopes +from oauth2_provider.views.mixins import ( + ClientProtectedResourceMixin as _ClientProtectedResourceMixin, +) +from oauthlib.common import Request as OauthlibRequest from .apps import AppConfig from .core_helpers import get_site_preferences, has_person @@ -106,6 +110,9 @@ class AppScopes(BaseScopes): scopes = [] for app in AppConfig.__subclasses__(): scopes += app.get_available_scopes() + # Filter by allowed scopes of requesting application + if application and application.allowed_scopes: + scopes = list(filter(lambda scope: scope in application.allowed_scopes, scopes)) return scopes def get_default_scopes( @@ -118,4 +125,43 @@ class AppScopes(BaseScopes): scopes = [] for app in AppConfig.__subclasses__(): scopes += app.get_default_scopes() + # Filter by allowed scopes of requesting application + if application and application.allowed_scopes: + scopes = list(filter(lambda scope: scope in application.allowed_scopes, scopes)) return scopes + + +class ClientProtectedResourceMixin(_ClientProtectedResourceMixin): + """Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1`. + + This involves authenticating with any of: HTTP Basic Auth, Client Credentials and + Access token in that order. Breaks off after first validation. + + This sub-class extends the functionality of Django OAuth Toolkit's mixin with support + for AlekSIS's `allowed_scopes` feature. For applications that have configured allowed + scopes, the required scopes for the view are checked to be a subset of the application's + allowed scopes (best to be combined with ScopedResourceMixin). + """ + + def authenticate_client(self, request: HttpRequest) -> bool: + """Return a boolean representing if client is authenticated with client credentials. + + If the view has configured required scopes, they are verified against the application's + allowed scopes. + """ + # Build an OAuth request so we can handle client information + core = self.get_oauthlib_core() + uri, http_method, body, headers = core._extract_params(request) + oauth_request = OauthlibRequest(uri, http_method, body, headers) + + # Verify general authentication of the client + if not core.server.request_validator.authenticate_client(oauth_request): + # Client credentials were invalid + return False + + # Verify scopes of configured application + # The OAuth request was enriched with a reference to the Application when using the + # validator above. + required_scopes = set(self.get_scopes() or []) + allowed_scopes = set(AppScopes().get_available_scopes(oauth_request.client) or []) + return required_scopes.issubset(allowed_scopes)