diff --git a/apps/gooyal_dynamic_scopes/__init__.py b/apps/gooyal_dynamic_scopes/__init__.py new file mode 100644 index 0000000..2bc2894 --- /dev/null +++ b/apps/gooyal_dynamic_scopes/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.gooyal_dynamic_scopes.apps.AppConfig' diff --git a/apps/gooyal_dynamic_scopes/admin.py b/apps/gooyal_dynamic_scopes/admin.py new file mode 100644 index 0000000..ec86326 --- /dev/null +++ b/apps/gooyal_dynamic_scopes/admin.py @@ -0,0 +1,12 @@ +""" +Django admin configuration for the gooyal-dynamic-scopes package. +""" + +from django.contrib import admin + +from .models import Scope + + +@admin.register(Scope) +class ScopeAdmin(admin.ModelAdmin): + list_display = ('name', 'description', 'is_default') diff --git a/apps/gooyal_dynamic_scopes/apps.py b/apps/gooyal_dynamic_scopes/apps.py new file mode 100644 index 0000000..af07204 --- /dev/null +++ b/apps/gooyal_dynamic_scopes/apps.py @@ -0,0 +1,14 @@ +""" +Django app config for the gooyal-dynamic-scopes package. +""" + +from django.apps import AppConfig as BaseAppConfig +from django.db.models.signals import post_migrate + + +class AppConfig(BaseAppConfig): + name = 'apps.gooyal_dynamic_scopes' + + def ready(self): + from .signals import register_scopes + post_migrate.connect(register_scopes, sender=self) diff --git a/apps/gooyal_dynamic_scopes/migrations/0001_initial.py b/apps/gooyal_dynamic_scopes/migrations/0001_initial.py new file mode 100644 index 0000000..8c7b7d3 --- /dev/null +++ b/apps/gooyal_dynamic_scopes/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-04 13:38 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + 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, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ], + ), + ] diff --git a/apps/gooyal_dynamic_scopes/migrations/__init__.py b/apps/gooyal_dynamic_scopes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_dynamic_scopes/models.py b/apps/gooyal_dynamic_scopes/models.py new file mode 100644 index 0000000..86758a3 --- /dev/null +++ b/apps/gooyal_dynamic_scopes/models.py @@ -0,0 +1,79 @@ +""" +Django models for the gooyal-dynamic-scopes package. +""" + +from django.db import models +from django.conf import settings + +import requests + +from oauth2_provider.settings import oauth2_settings + + +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.' + ) + #: 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_dynamic_scopes/scopes.py b/apps/gooyal_dynamic_scopes/scopes.py new file mode 100644 index 0000000..ae4a58b --- /dev/null +++ b/apps/gooyal_dynamic_scopes/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_dynamic_scopes/signals.py b/apps/gooyal_dynamic_scopes/signals.py new file mode 100644 index 0000000..3f2af94 --- /dev/null +++ b/apps/gooyal_dynamic_scopes/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/apps/gooyal_dynamic_scopes/views.py b/apps/gooyal_dynamic_scopes/views.py new file mode 100644 index 0000000..2719c79 --- /dev/null +++ b/apps/gooyal_dynamic_scopes/views.py @@ -0,0 +1,103 @@ +""" +Django views for the gooyal-dynamic-scopes package. +""" + +import json +import functools + +from django.conf import settings +from django.http import HttpResponse, HttpResponseForbidden +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods, require_POST + +from oauthlib.oauth2 import Server + +from oauth2_provider.oauth2_backends import OAuthLibCore +from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.views import IntrospectTokenView + +from .models import Scope + + +def protected_resource(scopes=None): + """ + Implementation of protected_resource decorator that saves the client on the + request for the view function to use. + + Cribbed from django-oauth-toolkit. + """ + _scopes = scopes or [] + + def decorator(view_func): + @functools.wraps(view_func) + def _validate(request, *args, **kwargs): + validator = OAuth2Validator() + core = OAuthLibCore(Server(validator)) + valid, oauthlib_req = core.verify_request(request, scopes=_scopes) + if valid: + request.client = oauthlib_req.client + request.resource_owner = oauthlib_req.user + return view_func(request, *args, **kwargs) + return HttpResponseForbidden() + + return _validate + + return decorator + + +@require_http_methods(['GET', 'POST']) +@csrf_exempt +@protected_resource(scopes=[settings.INTROSPECT_SCOPE]) +def introspect_token(request): + """ + Version of the introspection view protected by a regular scope instead of + read-write scopes. + + Also allows for the required scope to be changed using a setting. + """ + if request.method == 'GET': + token = request.GET.get("token", None) + else: + token = request.POST.get("token", None) + return IntrospectTokenView.get_token_response(token) + + +@require_POST +@csrf_exempt +@protected_resource(scopes=[settings.REGISTER_SCOPE_SCOPE]) +def register_scope(request): + """ + Implements an endpoint for registering a scope. + """ + #  Get the scope data from the request body + scope_data = json.loads(request.body) if request.body else {} + try: + try: + #  If a scope with the given name already exists, find it + scope = Scope.objects.get(name=scope_data['name']) + except Scope.DoesNotExist: + #  If no scope with the given name exists, create it + _ = Scope.objects.create( + application=request.client, + name=scope_data['name'], + description=scope_data['description'], + is_default=scope_data.get('is_default', False) + ) + #  Respond with a 201 Created + return HttpResponse(status=201) + except KeyError as exc: + #  A key missing in the data should be reported as a bad request + return HttpResponse( + status=400, + content="'{}' must be given in request data".format(exc.args[0]), + content_type='text/plain' + ) + #  If the scope does exist, check that the current application is the + #  owner of the scope before updating it + if scope.application and scope.application == request.client: + scope.description = scope_data['description'] + scope.is_default = scope_data.get('is_default', False) + scope.save() + return HttpResponse(status=200) + else: + return HttpResponse(status=403) diff --git a/apps/gooyal_restrict_scopes/__init__.py b/apps/gooyal_restrict_scopes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_restrict_scopes/admin.py b/apps/gooyal_restrict_scopes/admin.py new file mode 100755 index 0000000..068721b --- /dev/null +++ b/apps/gooyal_restrict_scopes/admin.py @@ -0,0 +1,25 @@ +""" +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 RestrictedApplication +from .forms import RestrictedApplicationForm + + +# 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(RestrictedApplication) +except NotRegistered: + pass + +@admin.register(RestrictedApplication) +class RestrictedApplicationAdmin(ApplicationAdmin): + form = RestrictedApplicationForm + +# admin.site.register(RestrictedApplication, RestrictedApplicationAdmin) diff --git a/apps/gooyal_restrict_scopes/apps.py b/apps/gooyal_restrict_scopes/apps.py new file mode 100755 index 0000000..70b337a --- /dev/null +++ b/apps/gooyal_restrict_scopes/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class GooyalRestrictScopesConfig(AppConfig): + name = 'apps.gooyal_restrict_scopes' diff --git a/apps/gooyal_restrict_scopes/forms.py b/apps/gooyal_restrict_scopes/forms.py new file mode 100644 index 0000000..ff91147 --- /dev/null +++ b/apps/gooyal_restrict_scopes/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 RestrictedApplicationForm(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_restrict_scopes/migrations/0001_initial.py b/apps/gooyal_restrict_scopes/migrations/0001_initial.py new file mode 100755 index 0000000..0698919 --- /dev/null +++ b/apps/gooyal_restrict_scopes/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-01 15:44 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import oauth2_provider.generators +import oauth2_provider.validators + + +class Migration(migrations.Migration): + + initial = True + run_before = [ + ('oauth2_provider', '0001_initial'), + ] + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RestrictedApplication', + 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_restrict_scopes_restrictedapplication', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/gooyal_restrict_scopes/migrations/__init__.py b/apps/gooyal_restrict_scopes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_restrict_scopes/models.py b/apps/gooyal_restrict_scopes/models.py new file mode 100644 index 0000000..b47cc20 --- /dev/null +++ b/apps/gooyal_restrict_scopes/models.py @@ -0,0 +1,25 @@ +""" +Django models for the gooyal-restrict-scopes package. +""" + +from django.db import models + +from oauth2_provider.models import AbstractApplication +from oauth2_provider.scopes import get_scopes_backend + + +class RestrictedApplication(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) diff --git a/apps/gooyal_restrict_scopes/scopes.py b/apps/gooyal_restrict_scopes/scopes.py new file mode 100644 index 0000000..1c1ac62 --- /dev/null +++ b/apps/gooyal_restrict_scopes/scopes.py @@ -0,0 +1,43 @@ +""" +Django OAuth Toolkit scopes backend for the gooyal-restrict-scopes package. +""" + +from django.conf import settings +from django.utils import module_loading + +from oauth2_provider.scopes import BaseScopes + + +class RestrictApplicationScopes(BaseScopes): + """ + Scopes backend that wraps another backend and restricts the scopes available + to an application based on the application's ``allowed_scopes``. + """ + def __init__(self): + # Initialise the wrapped backend from settings + self._wrapped = module_loading.import_string( + getattr(settings, 'GOOYAL_RESTRICT_SCOPES', {}).get( + 'WRAPPED_SCOPES_BACKEND_CLASS', + 'oauth2_provider.scopes.SettingsScopes' + ) + )() + + def get_all_scopes(self): + # Just return all the available scopes from the wrapped backend + return self._wrapped.get_all_scopes() + + def get_available_scopes(self, application = None, request = None, *args, **kwargs): + # Get the available scopes from the wrapped backend, then filter them + # based on the allowed_scopes of the application + scopes = self._wrapped.get_available_scopes(application, request, *args, **kwargs) + if application: + scopes = [s for s in scopes if s in application.allowed_scopes] + return scopes + + def get_default_scopes(self, application = None, request = None, *args, **kwargs): + # Get the default scopes from the wrapped backend, then filter them + # based on the allowed_scopes of the application + scopes = self._wrapped.get_default_scopes(application, request, *args, **kwargs) + if application: + scopes = [s for s in scopes if s in application.allowed_scopes] + return scopes diff --git a/apps/transactions/migrations/0007_auto_20200519_2324.py b/apps/transactions/migrations/0007_auto_20200519_2324.py new file mode 100644 index 0000000..67dfd27 --- /dev/null +++ b/apps/transactions/migrations/0007_auto_20200519_2324.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.4 on 2020-05-19 23:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('transactions', '0006_auto_20200420_0538'), + ] + + operations = [ + migrations.AlterField( + model_name='transaction', + name='state', + field=models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'incomplete'), (5, 'success'), (6, 'failed'), (7, 'expected_failure')], default=1), + ), + ] diff --git a/apps/transactions/views.py b/apps/transactions/views.py index ceea5c3..017c140 100755 --- a/apps/transactions/views.py +++ b/apps/transactions/views.py @@ -80,6 +80,8 @@ class TransactionReceipt(generics.RetrieveAPIView): return Response(serializer.data) +# TODO: withdraw and deposit service + class ServiceTransactionSubmit(generics.CreateAPIView): permission_classes = [IsAuthenticatedOrTokenHasScope] serializer_class = ServiceTransactionSerializer @@ -110,6 +112,6 @@ class ServiceTransactionVerify(generics.RetrieveAPIView): instance = self.get_object() if instance.state == STATE_CHOICES.pending: instance.verify() - + serializer = self.get_serializer(instance) return Response(serializer.data) diff --git a/apps/users/constans.py b/apps/users/constans.py index d4ea889..4e13669 100644 --- a/apps/users/constans.py +++ b/apps/users/constans.py @@ -10,5 +10,5 @@ STATE_CHOICES = Choices( (4, 'incomplete', 'incomplete'), # started and wait for internal progress to complete (5, 'success', 'success'), (6, 'failed', 'failed'), - (7, 'expected_failure', 'incomplete'), # no exact data available but guessed to be failed + (7, 'expected_failure', 'expected_failure'), # no exact data available but guessed to be failed ) \ No newline at end of file diff --git a/gooyal_accounts/settings.py b/gooyal_accounts/settings.py index 94b25b7..19486a9 100644 --- a/gooyal_accounts/settings.py +++ b/gooyal_accounts/settings.py @@ -42,6 +42,8 @@ INSTALLED_APPS = [ 'corsheaders', 'apps.users', 'apps.transactions', + 'apps.gooyal_restrict_scopes', + 'apps.gooyal_dynamic_scopes', ] MIDDLEWARE = [ @@ -55,8 +57,16 @@ MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', ] +# Tell Django OAuth Toolkit to use the RestrictedApplication model +OAUTH2_PROVIDER_APPLICATION_MODEL = 'gooyal_restrict_scopes.RestrictedApplication' +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' +OAUTH2_PROVIDER_GRANT_MODEL = 'oauth2_provider.Grant' +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' + OAUTH2_PROVIDER = { # this is the list of available scopes + # 'SCOPES_BACKEND_CLASS': 'apps.gooyal_restrict_scopes.scopes.RestrictApplicationScopes', + 'SCOPES_BACKEND_CLASS': 'apps.gooyal_dynamic_scopes.scopes.DynamicScopes', 'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'groups': 'Access to your groups', @@ -64,6 +74,16 @@ OAUTH2_PROVIDER = { 'OAUTH2_VALIDATOR_CLASS': 'utils.oauth2_provider.MultiGatewayOAuth2Validator' } +GOOYAL_RESTRICT_SCOPES = { + 'WRAPPED_SCOPES_BACKEND_CLASS': 'oauth2_provider.scopes.SettingsScopes', +} + + +# GOOYAL_DYNAMIC_SCOPES +RESOURCE_SERVER_REGISTER_SCOPE_URL = None +INTROSPECT_SCOPE = None +REGISTER_SCOPE_SCOPE = None + REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', diff --git a/gooyal_accounts/urls.py b/gooyal_accounts/urls.py index c707a86..299b65b 100644 --- a/gooyal_accounts/urls.py +++ b/gooyal_accounts/urls.py @@ -14,6 +14,7 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.conf import settings +from django.conf.urls import url from django.conf.urls.static import static from django.contrib.auth.views import LogoutView from django.urls import path, include @@ -34,6 +35,7 @@ urlpatterns = [ path('', home, name='home'), path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')), + # url(r'^oauth2/register_scope/$', register_scope, name = 'register-scope'), path('account/', AccountView.as_view(), name='account'), path('users/', UserListView.as_view()),