From bfb69353bfe8f04a45be269b9720f0d65619cbab Mon Sep 17 00:00:00 2001 From: mahdavi Date: Mon, 8 Jun 2020 10:01:31 +0430 Subject: [PATCH] collect all gooyal oauth modules as gooyal oauth --- apps/gooyal_oauth/__init__.py | 0 apps/gooyal_oauth/admin.py | 32 +++++++ apps/gooyal_oauth/apps.py | 5 + apps/gooyal_oauth/forms.py | 51 ++++++++++ apps/gooyal_oauth/migrations/0001_initial.py | 49 ++++++++++ apps/gooyal_oauth/migrations/__init__.py | 0 apps/gooyal_oauth/models.py | 97 ++++++++++++++++++++ apps/gooyal_oauth/scopes.py | 25 +++++ apps/gooyal_oauth/signals.py | 26 ++++++ gooyal_accounts/settings.py | 1 + 10 files changed, 286 insertions(+) create mode 100644 apps/gooyal_oauth/__init__.py create mode 100755 apps/gooyal_oauth/admin.py create mode 100755 apps/gooyal_oauth/apps.py create mode 100644 apps/gooyal_oauth/forms.py create mode 100644 apps/gooyal_oauth/migrations/0001_initial.py create mode 100644 apps/gooyal_oauth/migrations/__init__.py create mode 100644 apps/gooyal_oauth/models.py create mode 100644 apps/gooyal_oauth/scopes.py create mode 100644 apps/gooyal_oauth/signals.py diff --git a/apps/gooyal_oauth/__init__.py b/apps/gooyal_oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_oauth/admin.py b/apps/gooyal_oauth/admin.py new file mode 100755 index 0000000..9abf286 --- /dev/null +++ b/apps/gooyal_oauth/admin.py @@ -0,0 +1,32 @@ +""" +Django admin configuration for the gooyal-restrict-scopes package. +""" + +from django.contrib import admin +from django.contrib.admin.sites import NotRegistered + +from oauth2_provider.admin import ApplicationAdmin + +from .models import Application +from .forms import ApplicationForm +from .models import Scope + + +# The restricted application is registered by Django OAuth Toolkit, but we want +# to provide our own admin that uses our form +try: + admin.site.unregister(Application) +except NotRegistered: + pass + +@admin.register(Application) +class RestrictedApplicationAdmin(ApplicationAdmin): + form = ApplicationForm + + +@admin.register(Scope) +class ScopeAdmin(admin.ModelAdmin): + list_display = ('name', 'description', 'is_default') + + +# admin.site.register(RestrictedApplication, RestrictedApplicationAdmin) diff --git a/apps/gooyal_oauth/apps.py b/apps/gooyal_oauth/apps.py new file mode 100755 index 0000000..cf6fa74 --- /dev/null +++ b/apps/gooyal_oauth/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GooyalOauthConfig(AppConfig): + name = 'apps.gooyal_oauth' diff --git a/apps/gooyal_oauth/forms.py b/apps/gooyal_oauth/forms.py new file mode 100644 index 0000000..a7d83bd --- /dev/null +++ b/apps/gooyal_oauth/forms.py @@ -0,0 +1,51 @@ +""" +Django forms for use with the gooyal-restrict-scopes package. +""" + +from django import forms + +from oauth2_provider.scopes import get_scopes_backend + + +class DelimitedListField(forms.MultipleChoiceField): + """ + Django form field that allows for the use of list widgets with a text field + containing a delimited list. + """ + delimiter = ',' + + def __init__(self, delimiter=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.delimiter = delimiter or self.delimiter + + def prepare_value(self, value): + #  If the value is already a list or tuple, just use it as-is + if isinstance(value, (list, tuple)): return value + #  Otherwise, prepare the value by splitting on the delimiter, trimming + #  leading and trailing whitespace and excluding empty values + return [p.strip() for p in value.split(self.delimiter) if p.strip()] + + def clean(self, value): + #  Let the parent clean the value first, then join the result using the + # specified delimiter + return self.delimiter.join(super().clean(value)) + + +class ApplicationForm(forms.ModelForm): + """ + Form for creating or updating a restricted application. + """ + #  allowed_scope is a space-delimited list, but we want to present + #  a selection of valid scopes with checkboxes + allowed_scope = DelimitedListField( + label='Allowed scopes', + #  The choices and initial values are callables, because the scopes might + #  not be available at import type, e.g. if coming from the database + choices=lambda: get_scopes_backend().get_all_scopes().items(), + initial=lambda: get_scopes_backend().get_default_scopes(), + delimiter=' ', + widget=forms.CheckboxSelectMultiple + ) + + class Meta: + exclude = () diff --git a/apps/gooyal_oauth/migrations/0001_initial.py b/apps/gooyal_oauth/migrations/0001_initial.py new file mode 100644 index 0000000..05c2bf7 --- /dev/null +++ b/apps/gooyal_oauth/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 3.0.6 on 2020-06-08 05:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import oauth2_provider.generators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Scope', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The name of the scope.', max_length=255, unique=True)), + ('description', models.TextField(help_text='A brief description of the scope. This text is displayed to users when authorising access for the scope.')), + ('is_default', models.BooleanField(default=False, help_text='Indicates if this scope should be included in the default scopes.')), + ('application', models.ForeignKey(blank=True, help_text='The application to which the scope belongs.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scopes', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ], + ), + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), + ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), + ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), + ('name', models.CharField(blank=True, max_length=255)), + ('skip_authorization', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('allowed_scope', models.TextField(blank=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='gooyal_oauth_application', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/gooyal_oauth/migrations/__init__.py b/apps/gooyal_oauth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_oauth/models.py b/apps/gooyal_oauth/models.py new file mode 100644 index 0000000..c484d5c --- /dev/null +++ b/apps/gooyal_oauth/models.py @@ -0,0 +1,97 @@ +""" +Django models for the gooyal-restrict-scopes package. +""" + +import requests +from django.conf import settings +from django.db import models +from oauth2_provider.models import AbstractApplication +from oauth2_provider.scopes import get_scopes_backend +from oauth2_provider.settings import oauth2_settings + + +class Application(AbstractApplication): + """ + Application model for use with Django OAuth Toolkit that allows the scopes + available to an application to be restricted on a per-application basis. + """ + allowed_scope = models.TextField(blank=True) + + @property + def allowed_scopes(self): + """ + Returns the set of allowed scope names for this application. + """ + all_scopes = set(get_scopes_backend().get_all_scopes().keys()) + app_scopes = set(self.allowed_scope.split()) + return app_scopes.intersection(all_scopes) + + +class Scope(models.Model): + """ + Django model for an OAuth scope. + """ + #: The application that created the scope + #  NOTE: This is not used to limit access to the scope in any way - we want the + # scope to be available to other applications in order to request access + #   to the resource it protects! + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, + models.CASCADE, + #  This field is nullable because it is only set for scopes created by + #  external resource servers, which have a corresponding OAuth application + #  record on the authorisation server + blank=True, null=True, + help_text='The application to which the scope belongs.', + related_name='scopes' + ) + #: The name of the scope + name = models.CharField( + max_length=255, + unique=True, + help_text='The name of the scope.' + ) + #: A brief description of the scope + description = models.TextField( + help_text='A brief description of the scope. This text is displayed ' + 'to users when authorising access for the scope.' + ) + #: Indicates if the scope should be included in the default scopes + is_default = models.BooleanField( + default=False, + help_text='Indicates if this scope should be included in the default scopes.' + ) + + @classmethod + def register(cls, name, description, is_default=False): + """ + Registers a scope with the given values. It always creates an instance in + the local database, but if this resource server has an external authorisation + server, it will also register the scope there. + + Returns ``True`` on success. Should raise on failure. + """ + endpoint = settings.RESOURCE_SERVER_REGISTER_SCOPE_URL + if endpoint: + #  If the endpoint is set, make the callout to the authz server + token = "Bearer {}".format(oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN) + #  Let any failures bubble up + # The idea is to call this method during deployment as a post-migrate + #  hook, so we want failures to halt the deployment + response = requests.post( + endpoint, + json={ + 'name': name, + 'description': description, + 'is_default': is_default + }, + headers={"Authorization": token} + ) + #  Raise the exception for anything other than 20x responses + response.raise_for_status() + #  Always create/update the scope record locally + _ = Scope.objects.update_or_create( + name=name, + defaults={'description': description, 'is_default': is_default} + ) + return True diff --git a/apps/gooyal_oauth/scopes.py b/apps/gooyal_oauth/scopes.py new file mode 100644 index 0000000..ae4a58b --- /dev/null +++ b/apps/gooyal_oauth/scopes.py @@ -0,0 +1,25 @@ +""" +Django OAuth Toolkit scopes backend for the gooyal-dynamic-scopes package. +""" + +from django.conf import settings +from django.utils import module_loading + +from oauth2_provider.scopes import BaseScopes + +from .models import Scope + + +class DynamicScopes(BaseScopes): + """ + Scopes backend that provides scopes from a Django model. + """ + + def get_all_scopes(self): + return {scope.name: scope.description for scope in Scope.objects.all()} + + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + return list(self.get_all_scopes().keys()) + + def get_default_scopes(self, application=None, request=None, *args, **kwargs): + return [scope.name for scope in Scope.objects.filter(is_default=True)] diff --git a/apps/gooyal_oauth/signals.py b/apps/gooyal_oauth/signals.py new file mode 100644 index 0000000..3f2af94 --- /dev/null +++ b/apps/gooyal_oauth/signals.py @@ -0,0 +1,26 @@ +""" +Django signal handlers for the gooyal-dynamic-scopes package. +""" + +from django.conf import settings + +from oauth2_provider.settings import oauth2_settings + +from .models import Scope + + +def register_scopes(app_config, verbosity=2, interactive=True, **kwargs): + """ + ``post_migrate`` signal handler that ensures the scopes required for the + introspection and register-scope endpoints are registered when running as an + authorisation server. + + The signal is connected in ``apps.py``. + """ + #  Register any scopes in the oauth2_provider settings + for name, description in oauth2_settings.SCOPES.items(): + Scope.register( + name, + description, + name in oauth2_settings.DEFAULT_SCOPES + ) diff --git a/gooyal_accounts/settings.py b/gooyal_accounts/settings.py index 19486a9..edf1045 100644 --- a/gooyal_accounts/settings.py +++ b/gooyal_accounts/settings.py @@ -44,6 +44,7 @@ INSTALLED_APPS = [ 'apps.transactions', 'apps.gooyal_restrict_scopes', 'apps.gooyal_dynamic_scopes', + 'apps.gooyal_oauth', ] MIDDLEWARE = [