diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3c285046a78b08623512ad8799bf1f7823d6a2e4..6c95206429702edd60ea1d68ba7cb72852299c57 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. Changed @@ -28,6 +29,12 @@ Fixed * Fix default admin contacts +Credits +~~~~~~~ + +* We welcome new contributor 🧠Jonathan Krüger! +* We welcome new contributor ðŸ Lukas Weichelt! + `2.0`_ - 2021-10-29 ------------------- diff --git a/aleksis/core/forms.py b/aleksis/core/forms.py index 6468e6793012e4f4fe4e5c9835a7b5e6e665bf86..dd695cd06ea2c0dbc862be8c9989047d9934c0c3 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 @@ -594,6 +595,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 = ( @@ -601,6 +608,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/models.py b/aleksis/core/models.py index 3a7e123597e235a43662959df72614ed7e1ca38f..a78bfcc141d5539479983a50bbd257e6b7b7e5b1 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 @@ -1107,6 +1108,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..da6b8abf81ddc1916de7b0c7feb5a7262f453037 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" %} @@ -54,6 +62,14 @@ {{ application.redirect_uris }} </td> </tr> + <tr> + <th> + {% trans "Skip Authorisation" %} + </th> + <td> + <i class="material-icons">{{ application.skip_authorization|yesno:"check,close" }}</i> + </td> + </tr> </tbody> </table> {% endblock %} 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)