From e159561a7646bf032b9890542b8820f6fe0e75d4 Mon Sep 17 00:00:00 2001 From: SathishKumar Eswaran Date: Thu, 3 May 2018 00:57:50 -0700 Subject: [PATCH 1/4] WIP: Restricted OAuth Initial Design & Implementation (#275) restricted oauth changes & migration files --- lms/djangoapps/grades/api/views.py | 31 ++++- lms/envs/common.py | 15 +++ .../core/djangoapps/oauth_dispatch/admin.py | 2 +- .../dot_overrides/validators.py | 30 +++-- .../migrations/0002_auto_20180503_0349.py | 33 ++++++ .../core/djangoapps/oauth_dispatch/models.py | 108 +++++++++++++++++- .../core/djangoapps/oauth_dispatch/views.py | 42 ++++++- openedx/core/lib/api/view_utils.py | 4 +- openedx/core/lib/token_utils.py | 27 ++++- 9 files changed, 273 insertions(+), 19 deletions(-) create mode 100644 openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180503_0349.py diff --git a/lms/djangoapps/grades/api/views.py b/lms/djangoapps/grades/api/views.py index 81f05ad7dc95..a853456b2eb0 100644 --- a/lms/djangoapps/grades/api/views.py +++ b/lms/djangoapps/grades/api/views.py @@ -9,7 +9,11 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.response import Response - +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from edx_rest_framework_extensions.permissions import JWTRestrictedApplicationPermission +from edx_rest_framework_extensions.authentication import JwtAuthentication +from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser from courseware.access import has_access from lms.djangoapps.courseware import courses from lms.djangoapps.courseware.exceptions import CourseAccessRedirect @@ -160,6 +164,20 @@ class UserGradeView(GradeViewMixin, GenericAPIView): }] """ + authentication_classes = ( + JwtAuthentication, + SessionAuthentication, + OAuth2AuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated, JWTRestrictedApplicationPermission,) + + # needed for passing JWTRestrictedApplicationPermission checks + # for RestrictedApplications (only). A RestrictedApplication can + # only call this method if it is allowed to receive a 'grades:read' + # scope + restricted_oauth_required = True + required_scopes = ['grades:read'] + def get(self, request, course_id): """ Gets a course progress status. @@ -171,6 +189,17 @@ def get(self, request, course_id): Return: A JSON serialized representation of the requesting user's current grade status. """ + # See if the request has an explicit sattr(request, 'allowed_organizations')) + # which limits which OAuth2 clients can see the courses + # based on the association with a RestrictedApplication + if hasattr(request, 'auth') and hasattr(request, 'allowed_organization'): + course_key = CourseKey.from_string(course_id) + if course_key.org not in request.allowed_organization: + return self.make_error_response( + status_code=status.HTTP_403_FORBIDDEN, + developer_message='The OAuth2 RestrictedApplication is not associated with org.', + error_code='course_org_not_associated_with_calling_application' + ) course = self._get_course(course_id, request.user, 'load') if isinstance(course, Response): diff --git a/lms/envs/common.py b/lms/envs/common.py index 8a1b384f85ff..009fcd35bd5c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -505,6 +505,7 @@ # conform profile scope message that is presented to end-user # to lms/templates/provider/authorize.html. This may be revised later. 'profile': 'Know your name and username', + 'grades:read': 'Retrieve your grades for your enrolled courses' }, 'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired', } @@ -2402,6 +2403,8 @@ def _make_locale_paths(settings): "reddit", ] +DEFAULT_JWT_ISSUER = 'test-issuer-1', +DEFAULT_RESTRICTED_JWT_ISSUER = 'test-issuer-2' # JWT Settings JWT_AUTH = { # TODO Set JWT_SECRET_KEY to a secure value. By default, SECRET_KEY will be used. @@ -2416,6 +2419,18 @@ def _make_locale_paths(settings): 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.utils.jwt_decode_handler', # Number of seconds before JWT tokens expire 'JWT_EXPIRATION': 30, + 'JWT_ISSUERS': [ + { + 'ISSUER':'test-issuer-1', + 'SECRET_KEY':'test-secret-key-1', + 'AUDIENCE':'test-audience-1', + }, + { + 'ISSUER':'test-issuer-2', + 'SECRET_KEY':'test-secret-key-2', + 'AUDIENCE':'test-audience-2', + } + ] } # The footer URLs dictionary maps social footer names diff --git a/openedx/core/djangoapps/oauth_dispatch/admin.py b/openedx/core/djangoapps/oauth_dispatch/admin.py index 2c01b289d15c..f066a13441b4 100644 --- a/openedx/core/djangoapps/oauth_dispatch/admin.py +++ b/openedx/core/djangoapps/oauth_dispatch/admin.py @@ -79,7 +79,7 @@ class RestrictedApplicationAdmin(ModelAdmin): """ ModelAdmin for the Restricted Application """ - list_display = [u'application'] + list_display = [u'application', u'_org_associations'] site.register(RestrictedApplication, RestrictedApplicationAdmin) diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py index c77d9924a16f..eec6d39c6cd4 100644 --- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py +++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py @@ -13,7 +13,7 @@ from oauth2_provider.oauth2_validators import OAuth2Validator from pytz import utc from ratelimitbackend.backends import RateLimitMixin - +from django.conf import settings from ..models import RestrictedApplication @@ -25,10 +25,10 @@ def on_access_token_presave(sender, instance, *args, **kwargs): # pylint: disab We do this as a pre-save hook on the ORM """ - - is_application_restricted = RestrictedApplication.objects.filter(application=instance.application).exists() - if is_application_restricted: - RestrictedApplication.set_access_token_as_expired(instance) + if settings.FEATURES.get('AUTO_EXPIRE_RESTRICTED_ACCESS_TOKENS', False): + is_application_restricted = RestrictedApplication.is_token_a_restricted_application(instance) + if is_application_restricted: + RestrictedApplication.set_access_token_as_expired(instance) # TODO: Remove Django 1.11 upgrade shim @@ -113,7 +113,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): super(EdxOAuth2Validator, self).save_bearer_token(token, request, *args, **kwargs) is_application_restricted = RestrictedApplication.objects.filter(application=request.client).exists() - if is_application_restricted: + if is_application_restricted and settings.FEATURES.get('AUTO_EXPIRE_RESTRICTED_ACCESS_TOKENS', False): # Since RestrictedApplications will override the DOT defined expiry, so that access_tokens # are always expired, we need to re-read the token from the database and then calculate the # expires_in (in seconds) from what we stored in the database. This value should be a negative @@ -122,7 +122,6 @@ def save_bearer_token(self, token, request, *args, **kwargs): access_token = AccessToken.objects.get(token=token['access_token']) utc_now = datetime.utcnow().replace(tzinfo=utc) expires_in = (access_token.expires - utc_now).total_seconds() - # assert that RestrictedApplications only issue expired tokens # blow up processing if we see otherwise assert expires_in < 0 @@ -132,3 +131,20 @@ def save_bearer_token(self, token, request, *args, **kwargs): # Restore the original request attributes request.grant_type = grant_type request.user = user + + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + """ + Override the DOT implementation to add checks to make sure that a + RestrictedApplication is not granted scopes that it has not been + permitted to do + """ + + restricted_application = RestrictedApplication.get_restricted_application(client) + if restricted_application: + # caller is restricted, so we must vet the allowed scopes for that restricted application + return set(scopes).issubset(restricted_application.allowed_scopes) + + # not a restricted application, call into base implementation, + # which - basically - pulls the list of scopes from configuration settings as a global + # definition + return super(EdxOAuth2Validator, self).validate_scopes(client_id, scopes, client, request, *args, **kwargs) diff --git a/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180503_0349.py b/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180503_0349.py new file mode 100644 index 000000000000..3e7501975cdc --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180503_0349.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-03 07:49 +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): + + dependencies = [ + ('organizations', '0006_auto_20171207_0259'), + ('oauth_dispatch', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='restrictedapplication', + name='_allowed_scopes', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='restrictedapplication', + name='_org_associations', + field=models.ForeignKey(default=b'', on_delete=django.db.models.deletion.CASCADE, to='organizations.Organization'), + ), + migrations.AlterField( + model_name='restrictedapplication', + name='application', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='restricted_application', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + ] diff --git a/openedx/core/djangoapps/oauth_dispatch/models.py b/openedx/core/djangoapps/oauth_dispatch/models.py index 406b69fcc6e3..26abb1d72944 100644 --- a/openedx/core/djangoapps/oauth_dispatch/models.py +++ b/openedx/core/djangoapps/oauth_dispatch/models.py @@ -7,7 +7,12 @@ from django.db import models from oauth2_provider.settings import oauth2_settings from pytz import utc +from oauth2_provider.models import AccessToken +from organizations.models import Organization +# define default separator used to store lists +# IMPORTANT: Do not change this after data has been populated in database +_DEFAULT_SEPARATOR = ' ' class RestrictedApplication(models.Model): """ @@ -18,7 +23,21 @@ class RestrictedApplication(models.Model): so that they cannot be used to call into APIs. """ - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, null=False) + application = models.OneToOneField( + oauth2_settings.APPLICATION_MODEL, + null=False, + related_name='restricted_application' + ) + + # a space separated list of scopes that this application can request + _allowed_scopes = models.TextField(null=True) + + # a space separated list of ORGs that this application is associated with + # this field will be used to implement appropriate data filtering + # so that clients of a specific OAuth2 Application will only be + # able retrieve datasets that the OAuth2 Application is allowed to retrieve. + _org_associations = models.ForeignKey(Organization, default = '') + def __unicode__(self): """ @@ -27,6 +46,93 @@ def __unicode__(self): return u"".format( name=self.application.name ) + @classmethod + def is_token_a_restricted_application(cls, token): + """ + Returns if token is issued to a RestriectedApplication + """ + + if isinstance(token, basestring): + # if string is passed in, do the look up + token_obj = AccessToken.objects.get(token=token) + else: + token_obj = token + + return cls.get_restricted_application(token_obj.application) is not None + + @classmethod + def get_restricted_application(cls, application): + """ + For a given application, get the related restricted application + """ + return RestrictedApplication.objects.filter(application=application.id).first() + + @classmethod + def get_restricted_application_from_token(cls, token): + """ + Returns a RestrictedApplication object for a token, None is none exists + """ + + if isinstance(token, basestring): + # if string is passed in, do the look up + # TODO: Is there a way to do this with one DB lookup? + access_token = AccessToken.objects.select_related('application').filter(token=token).first() + application = access_token.application + else: + application = token.application + + return cls.get_restricted_application(application) + + def _get_delemitered_string_from_list(self, scopes_list, seperator=_DEFAULT_SEPARATOR): + """ + Helper to return a list from a delimited string + """ + return _DEFAULT_SEPARATOR.join(scopes_list) + + def _get_list_from_delimited_string(self, delimited_string, separator=_DEFAULT_SEPARATOR): + """ + Helper to return a list from a delimited string + """ + + return delimited_string.split(separator) if delimited_string else [] + + @property + def allowed_scopes(self): + """ + Translate space delimited string to a list + """ + return self._get_list_from_delimited_string(self._allowed_scopes) + + @allowed_scopes.setter + def allowed_scopes(self, value): + """ + Convert list to separated string + """ + self._allowed_scopes = _DEFAULT_SEPARATOR.join(value) + + def has_scope(self, scope): + """ + Returns in the RestrictedApplication has the requested scope + """ + + return scope in self.allowed_scopes + + @property + def org_associations(self): + """ + Translate space delimited string to a list + """ + + org_id = self._org_associations.id + + return Organization.objects.get(id=org_id).name + + def is_associated_with_org(self, org): + """ + Returns if the RestriectedApplication is associated with the requested org + """ + return org == self.org_associations + @classmethod def set_access_token_as_expired(cls, access_token): diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index 910a6d724b06..1e3021a8be32 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -22,7 +22,7 @@ from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views from openedx.core.lib.token_utils import JwtBuilder - +from .models import RestrictedApplication from . import adapters from .dot_overrides import views as dot_overrides_views @@ -85,6 +85,37 @@ def _get_client_id(self, request): else: return request.POST.get('client_id') + def _get_application_id(self, request): + """ + Return application id from provided request + """ + return dot_models.Application.objects.get(client_id=self._get_client_id(request)).id + + def is_application_restricted(self, request): + """ + Returns the appropriate adapter based on the OAuth client linked to the request. + """ + dot_id = self._get_application_id(request) + if RestrictedApplication.objects.filter(application_id=dot_id).exists(): + return True + else: + return False + + def get_associated_org(self, request): + """ + Returns the appropriate adapter based on the OAuth client linked to the request. + """ + dot_id = self._get_application_id(request) + if RestrictedApplication.objects.filter(application_id=dot_id).exists(): + return RestrictedApplication.objects.get(application_id=dot_id).org_associations + else: + return None + + def get_application_grant_type(self, request): + """ + Returns grant type of the application + """ + return dot_models.Application.objects.get(client_id=self._get_client_id(request)).authorization_grant_type class AccessTokenView(RatelimitMixin, _DispatchingView): """ @@ -102,9 +133,14 @@ def dispatch(self, request, *args, **kwargs): if response.status_code == 200 and request.POST.get('token_type', '').lower() == 'jwt': expires_in, scopes, user = self._decompose_access_token_response(request, response) - + is_application_restricted = self.is_application_restricted(request) + if is_application_restricted: + org = self.get_associated_org(request) + else: + org = None + application_grant_type = self.get_application_grant_type(request) content = { - 'access_token': JwtBuilder(user).build_token(scopes, expires_in), + 'access_token': JwtBuilder(user, is_application_restricted=is_application_restricted).build_token(scopes, expires_in, org=org, application_grant_type=application_grant_type), 'expires_in': expires_in, 'token_type': 'JWT', 'scope': ' '.join(scopes), diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index ed552ae11c6f..cff681d9ba4f 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -13,7 +13,7 @@ from rest_framework.request import clone_request from rest_framework.response import Response from six import text_type - +from edx_rest_framework_extensions.permissions import JWTRestrictedApplicationPermission from openedx.core.lib.api.authentication import ( OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser @@ -103,6 +103,8 @@ def _decorator(func_or_class): func_or_class.permission_classes += (IsAuthenticated,) if is_user: func_or_class.permission_classes += (IsUserInUrl,) + # always check access by restricted OAuth2 applications + func_or_class.permission_classes += (JWTRestrictedApplicationPermission, ) return func_or_class return _decorator diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index edbd52905e6b..cc574b92ca11 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -29,13 +29,23 @@ class JwtBuilder(object): secret (string): Overrides configured JWT secret (signing) key. Unused if an asymmetric signature is requested. """ - def __init__(self, user, asymmetric=False, secret=None): + def __init__(self, user, asymmetric=False, secret=None,is_application_restricted=None): self.user = user self.asymmetric = asymmetric self.secret = secret self.jwt_auth = configuration_helpers.get_value('JWT_AUTH', settings.JWT_AUTH) + self.default_jwt_issuer = configuration_helpers.get_value('DEFAULT_JWT_ISSUER', settings.DEFAULT_JWT_ISSUER) + self.default_restricted_jwt_issuer = configuration_helpers.get_value('DEFAULT_RESTRICTED_JWT_ISSUER', settings.DEFAULT_RESTRICTED_JWT_ISSUER) + if is_application_restricted: + for issuer in self.jwt_auth['JWT_ISSUERS']: + if self.default_restricted_jwt_issuer == issuer['ISSUER']: + self.issuer = issuer + else: + for issuer in self.jwt_auth['JWT_ISSUERS']: + if self.default_jwt_issuer == issuer['ISSUER']: + self.issuer = issuer - def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None): + def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None, org=None, application_grant_type=None): """Returns a JWT access token. Arguments: @@ -51,14 +61,21 @@ def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None) """ now = int(time()) expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION'] + filters = {} + if org: + filters['content_org'] = org + if application_grant_type != u'client-credentials': + filters['user'] = 'me' payload = { # TODO Consider getting rid of this claim since we don't use it. - 'aud': aud if aud else self.jwt_auth['JWT_AUDIENCE'], + 'aud': aud if aud else self.issuer['AUDIENCE'], 'exp': now + expires_in, 'iat': now, - 'iss': self.jwt_auth['JWT_ISSUER'], + 'iss': self.issuer['ISSUER'], 'preferred_username': self.user.username, 'scopes': scopes, + 'filters': filters, + 'version': '1.0', 'sub': anonymous_id_for_user(self.user, None), } @@ -109,7 +126,7 @@ def encode(self, payload): keys.add(RSAKey(key=RSA.importKey(settings.JWT_PRIVATE_SIGNING_KEY))) algorithm = 'RS512' else: - key = self.secret if self.secret else self.jwt_auth['JWT_SECRET_KEY'] + key = self.secret if self.secret else self.issuer['SECRET_KEY'] keys.add({'key': key, 'kty': 'oct'}) algorithm = self.jwt_auth['JWT_ALGORITHM'] From b1176d30fb4bb07dfa2337001560b41cb3c92bef Mon Sep 17 00:00:00 2001 From: SathishKumar Eswaran Date: Fri, 18 May 2018 16:32:42 -0700 Subject: [PATCH 2/4] Implement Associate Available Scopes & Organizations with Applications Associate Available Scopes with Applications Associate Available Organizations with Applications Waffle Switch for oauth2.unexpired_restricted_applications Also, addressed code review feedback's --- lms/djangoapps/grades/api/views.py | 6 +- lms/envs/common.py | 5 +- .../core/djangoapps/oauth_dispatch/admin.py | 36 +++++--- .../dot_overrides/validators.py | 14 ++-- .../migrations/0002_auto_20180515_1350.py | 54 ++++++++++++ .../core/djangoapps/oauth_dispatch/models.py | 82 +++++++++++++++++++ .../core/djangoapps/oauth_dispatch/scopes.py | 20 +++++ .../core/djangoapps/oauth_dispatch/utils.py | 10 +++ .../core/djangoapps/oauth_dispatch/views.py | 30 +++---- openedx/core/lib/token_utils.py | 17 ++-- 10 files changed, 224 insertions(+), 50 deletions(-) create mode 100644 openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180515_1350.py create mode 100644 openedx/core/djangoapps/oauth_dispatch/scopes.py create mode 100644 openedx/core/djangoapps/oauth_dispatch/utils.py diff --git a/lms/djangoapps/grades/api/views.py b/lms/djangoapps/grades/api/views.py index a853456b2eb0..d9a3cedbb35e 100644 --- a/lms/djangoapps/grades/api/views.py +++ b/lms/djangoapps/grades/api/views.py @@ -9,7 +9,6 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework.generics import GenericAPIView, ListAPIView from rest_framework.response import Response -from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from edx_rest_framework_extensions.permissions import JWTRestrictedApplicationPermission from edx_rest_framework_extensions.authentication import JwtAuthentication @@ -166,7 +165,6 @@ class UserGradeView(GradeViewMixin, GenericAPIView): """ authentication_classes = ( JwtAuthentication, - SessionAuthentication, OAuth2AuthenticationAllowInactiveUser, ) permission_classes = (IsAuthenticated, JWTRestrictedApplicationPermission,) @@ -192,9 +190,9 @@ def get(self, request, course_id): # See if the request has an explicit sattr(request, 'allowed_organizations')) # which limits which OAuth2 clients can see the courses # based on the association with a RestrictedApplication - if hasattr(request, 'auth') and hasattr(request, 'allowed_organization'): + if hasattr(request, 'auth') and hasattr(request, 'filters'): course_key = CourseKey.from_string(course_id) - if course_key.org not in request.allowed_organization: + if course_key.org not in request.filters['content_org']: return self.make_error_response( status_code=status.HTTP_403_FORBIDDEN, developer_message='The OAuth2 RestrictedApplication is not associated with org.', diff --git a/lms/envs/common.py b/lms/envs/common.py index 009fcd35bd5c..b33f23df60c5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -498,6 +498,7 @@ OAUTH2_PROVIDER = { 'OAUTH2_VALIDATOR_CLASS': 'openedx.core.djangoapps.oauth_dispatch.dot_overrides.validators.EdxOAuth2Validator', 'REFRESH_TOKEN_EXPIRE_SECONDS': 20160, + 'SCOPES_BACKEND_CLASS':'openedx.core.djangoapps.oauth_dispatch.scopes.DynamicScopes', 'SCOPES': { 'read': 'Read access', 'write': 'Write access', @@ -511,8 +512,8 @@ } # This is required for the migrations in oauth_dispatch.models # otherwise it fails saying this attribute is not present in Settings -OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' - +##OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' +OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth_dispatch.OauthRestrictedApplication' ################################## TEMPLATE CONFIGURATION ##################################### # Mako templating import tempfile diff --git a/openedx/core/djangoapps/oauth_dispatch/admin.py b/openedx/core/djangoapps/oauth_dispatch/admin.py index f066a13441b4..79813a65a25f 100644 --- a/openedx/core/djangoapps/oauth_dispatch/admin.py +++ b/openedx/core/djangoapps/oauth_dispatch/admin.py @@ -5,7 +5,7 @@ from django.contrib.admin import ModelAdmin, site from oauth2_provider import models -from .models import RestrictedApplication +from .models import RestrictedApplication, OauthRestrictOrganization, OauthRestrictedApplication def reregister(model_class): @@ -52,17 +52,6 @@ class DOTRefreshTokenAdmin(ModelAdmin): search_fields = [u'token', u'user__username', u'access_token__token'] -@reregister(models.Application) -class DOTApplicationAdmin(ModelAdmin): - """ - Custom Application Admin - """ - list_display = [u'name', u'user', u'client_type', u'authorization_grant_type', u'client_id'] - list_filter = [u'client_type', u'authorization_grant_type'] - raw_id_fields = [u'user'] - search_fields = [u'name', u'user__username', u'client_id'] - - @reregister(models.Grant) class DOTGrantAdmin(ModelAdmin): """ @@ -81,5 +70,26 @@ class RestrictedApplicationAdmin(ModelAdmin): """ list_display = [u'application', u'_org_associations'] - site.register(RestrictedApplication, RestrictedApplicationAdmin) + + +@reregister(OauthRestrictedApplication) +class OauthRestrictedApplicationAdmin(ModelAdmin): + """ + ModelAdmin for the Restricted Application + """ + list_display = [u'name', u'user', u'client_type', u'authorization_grant_type', u'client_id'] + list_filter = [u'client_type', u'authorization_grant_type'] + raw_id_fields = [u'user'] + search_fields = [u'name', u'user__username', u'client_id'] + + +class OauthRestrictOrganizationAdmin(ModelAdmin): + """ + ModelAdmin for the Restricted Application + """ + list_display = [u'application','organization_type'] + filter_horizontal = ('_org_associations',) + +site.register(OauthRestrictOrganization, OauthRestrictOrganizationAdmin) + diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py index eec6d39c6cd4..bc029a7a115c 100644 --- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py +++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py @@ -14,8 +14,8 @@ from pytz import utc from ratelimitbackend.backends import RateLimitMixin from django.conf import settings -from ..models import RestrictedApplication - +from ..models import RestrictedApplication, OauthRestrictedApplication +from openedx.core.djangoapps.oauth_dispatch.utils import is_oauth_scope_enforcement_enabled @receiver(pre_save, sender=AccessToken) def on_access_token_presave(sender, instance, *args, **kwargs): # pylint: disable=unused-argument @@ -25,11 +25,8 @@ def on_access_token_presave(sender, instance, *args, **kwargs): # pylint: disab We do this as a pre-save hook on the ORM """ - if settings.FEATURES.get('AUTO_EXPIRE_RESTRICTED_ACCESS_TOKENS', False): - is_application_restricted = RestrictedApplication.is_token_a_restricted_application(instance) - if is_application_restricted: - RestrictedApplication.set_access_token_as_expired(instance) - + if not is_oauth_scope_enforcement_enabled(): + OauthRestrictedApplication.set_access_token_as_expired(instance) # TODO: Remove Django 1.11 upgrade shim # SHIM: Allow users that are inactive to still authenticate while keeping rate-limiting functionality. @@ -112,8 +109,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): super(EdxOAuth2Validator, self).save_bearer_token(token, request, *args, **kwargs) - is_application_restricted = RestrictedApplication.objects.filter(application=request.client).exists() - if is_application_restricted and settings.FEATURES.get('AUTO_EXPIRE_RESTRICTED_ACCESS_TOKENS', False): + if not is_oauth_scope_enforcement_enabled(): # Since RestrictedApplications will override the DOT defined expiry, so that access_tokens # are always expired, we need to re-read the token from the database and then calculate the # expires_in (in seconds) from what we stored in the database. This value should be a negative diff --git a/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180515_1350.py b/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180515_1350.py new file mode 100644 index 000000000000..47a0a0ca5fca --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180515_1350.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-05-15 17:50 +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): + + dependencies = [ + ('organizations', '0006_auto_20171207_0259'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ('oauth_dispatch', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='OauthRestrictedApplication', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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', validators=[oauth2_provider.validators.validate_uris])), + ('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)), + ('allowed_scope', models.TextField(blank=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth_dispatch_oauthrestrictedapplication', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OauthRestrictOrganization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('organization_type', models.CharField(choices=[(b'content_provider', 'Content Provider'), (b'user_provider', 'User Provider')], default=b'content_provider', max_length=32)), + ('_org_associations', models.ManyToManyField(to='organizations.Organization')), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ], + ), + migrations.AlterField( + model_name='restrictedapplication', + name='application', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='restricted_application', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + ] diff --git a/openedx/core/djangoapps/oauth_dispatch/models.py b/openedx/core/djangoapps/oauth_dispatch/models.py index 26abb1d72944..85a17f1bd71b 100644 --- a/openedx/core/djangoapps/oauth_dispatch/models.py +++ b/openedx/core/djangoapps/oauth_dispatch/models.py @@ -9,6 +9,9 @@ from pytz import utc from oauth2_provider.models import AccessToken from organizations.models import Organization +from django.utils.translation import ugettext_lazy as _ +from oauth2_provider.models import AbstractApplication +from oauth2_provider.scopes import get_scopes_backend # define default separator used to store lists # IMPORTANT: Do not change this after data has been populated in database @@ -149,3 +152,82 @@ def verify_access_token_as_expired(cls, access_token): is set at the beginning of the epoch which is Jan. 1, 1970 """ return access_token.expires == datetime(1970, 1, 1, tzinfo=utc) + + +class OauthRestrictedApplication(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) + + def _get_list_from_delimited_string(self, delimited_string, separator=_DEFAULT_SEPARATOR): + """ + Helper to return a list from a delimited string + """ + + return delimited_string.split(separator) if delimited_string else [] + + @classmethod + def is_token_oauth_restricted_application(cls, token): + """ + Returns if token is issued to a RestriectedApplication + """ + + if isinstance(token, basestring): + # if string is passed in, do the look up + token_obj = AccessToken.objects.get(token=token) + else: + token_obj = token + + return cls.get_restricted_application(token_obj.application) is not None + + @classmethod + def get_restricted_application(cls, application): + """ + For a given application, get the related restricted application + """ + return OauthRestrictedApplication.objects.filter(id=application.id) + + @property + def allowed_scopes(self): + """ + Translate space delimited string to a list + """ + all_scopes = set(get_scopes_backend().get_all_scopes().keys()) + app_scopes = set(self._get_list_from_delimited_string(self.allowed_scope)) + return app_scopes.intersection(all_scopes) + + @classmethod + def set_access_token_as_expired(cls, instance): + """ + For access_tokens for RestrictedApplications, put the expire timestamp into the beginning of the epoch + which is Jan. 1, 1970 + """ + + instance.expires = datetime(1970, 1, 1, tzinfo=utc) + +class OauthRestrictOrganization(models.Model): + + CONTENT_PROVIDER = 'content_provider' + USER_PROVIDER = 'user_provider' + ORGANIZATION_PROVIDER_TYPES = ( + (CONTENT_PROVIDER, _('Content Provider')), + (USER_PROVIDER, _('User Provider')), + ) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, null=False) + + _org_associations = models.ManyToManyField(Organization) + + organization_type = models.CharField(max_length=32, choices=ORGANIZATION_PROVIDER_TYPES, default=CONTENT_PROVIDER) + + @property + def org_associations(self): + """ + Translate space delimited string to a list + """ + org_associations_list = [] + for each in self._org_associations.all(): + org_associations_list.append(each.name) + return org_associations_list + diff --git a/openedx/core/djangoapps/oauth_dispatch/scopes.py b/openedx/core/djangoapps/oauth_dispatch/scopes.py new file mode 100644 index 000000000000..5aa22e6abd11 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/scopes.py @@ -0,0 +1,20 @@ +""" +Django OAuth Toolkit scopes backend for the dot-dynamic-scopes package. +""" + +from django.conf import settings +from django.utils import module_loading + +from oauth2_provider.scopes import SettingsScopes + + +class DynamicScopes(SettingsScopes): + """ + Scopes backend that provides scopes from a Django model. + """ + def get_all_scopes(self): + return settings.OAUTH2_PROVIDER['SCOPES'] + + def get_available_scopes(self, application = None, request = None, *args, **kwargs): + return list(self.get_all_scopes().keys()) + diff --git a/openedx/core/djangoapps/oauth_dispatch/utils.py b/openedx/core/djangoapps/oauth_dispatch/utils.py new file mode 100644 index 000000000000..6b1c50389f69 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/utils.py @@ -0,0 +1,10 @@ +"""Utilities to assist with oauth scopes enforcement""" +from waffle import switch_is_active + + +def is_oauth_scope_enforcement_enabled(): + """ + Returns True if switch is enabled + """ + return switch_is_active('ENABLE_OAUTH_SCOPE_ENFORCEMENT') + diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index 1e3021a8be32..968ca959bdff 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -22,7 +22,7 @@ from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views from openedx.core.lib.token_utils import JwtBuilder -from .models import RestrictedApplication +from .models import RestrictedApplication, OauthRestrictOrganization,OauthRestrictedApplication from . import adapters from .dot_overrides import views as dot_overrides_views @@ -42,7 +42,7 @@ def get_adapter(self, request): """ Returns the appropriate adapter based on the OAuth client linked to the request. """ - if dot_models.Application.objects.filter(client_id=self._get_client_id(request)).exists(): + if dot_models.get_application_model().objects.filter(client_id=self._get_client_id(request)).exists(): return self.dot_adapter else: return self.dop_adapter @@ -89,25 +89,27 @@ def _get_application_id(self, request): """ Return application id from provided request """ - return dot_models.Application.objects.get(client_id=self._get_client_id(request)).id + return dot_models.get_application_model().objects.get(client_id=self._get_client_id(request)).id - def is_application_restricted(self, request): + def is_application_scopes_restricted(self, request): """ Returns the appropriate adapter based on the OAuth client linked to the request. """ dot_id = self._get_application_id(request) - if RestrictedApplication.objects.filter(application_id=dot_id).exists(): + + if OauthRestrictedApplication.objects.filter(id=dot_id).exists(): return True else: return False - def get_associated_org(self, request): + def get_associated_orgs(self, request): """ Returns the appropriate adapter based on the OAuth client linked to the request. """ dot_id = self._get_application_id(request) - if RestrictedApplication.objects.filter(application_id=dot_id).exists(): - return RestrictedApplication.objects.get(application_id=dot_id).org_associations + + if OauthRestrictOrganization.objects.filter(application_id=dot_id).exists(): + return OauthRestrictOrganization.objects.get(application_id=dot_id).org_associations else: return None @@ -115,8 +117,9 @@ def get_application_grant_type(self, request): """ Returns grant type of the application """ - return dot_models.Application.objects.get(client_id=self._get_client_id(request)).authorization_grant_type + return dot_models.get_application_model().objects.get(client_id=self._get_client_id(request)).authorization_grant_type + class AccessTokenView(RatelimitMixin, _DispatchingView): """ Handle access token requests. @@ -133,14 +136,11 @@ def dispatch(self, request, *args, **kwargs): if response.status_code == 200 and request.POST.get('token_type', '').lower() == 'jwt': expires_in, scopes, user = self._decompose_access_token_response(request, response) - is_application_restricted = self.is_application_restricted(request) - if is_application_restricted: - org = self.get_associated_org(request) - else: - org = None + orgs = self.get_associated_orgs(request) + is_application_scopes_restricted = self.is_application_scopes_restricted(request) application_grant_type = self.get_application_grant_type(request) content = { - 'access_token': JwtBuilder(user, is_application_restricted=is_application_restricted).build_token(scopes, expires_in, org=org, application_grant_type=application_grant_type), + 'access_token': JwtBuilder(user, is_application_scopes_restricted=is_application_scopes_restricted).build_token(scopes, expires_in, orgs=orgs, application_grant_type=application_grant_type), 'expires_in': expires_in, 'token_type': 'JWT', 'scope': ' '.join(scopes), diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index cc574b92ca11..38e281d9e37f 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -29,14 +29,15 @@ class JwtBuilder(object): secret (string): Overrides configured JWT secret (signing) key. Unused if an asymmetric signature is requested. """ - def __init__(self, user, asymmetric=False, secret=None,is_application_restricted=None): + def __init__(self, user, asymmetric=False, secret=None,is_application_scopes_restricted=None): + self.user = user self.asymmetric = asymmetric self.secret = secret self.jwt_auth = configuration_helpers.get_value('JWT_AUTH', settings.JWT_AUTH) self.default_jwt_issuer = configuration_helpers.get_value('DEFAULT_JWT_ISSUER', settings.DEFAULT_JWT_ISSUER) self.default_restricted_jwt_issuer = configuration_helpers.get_value('DEFAULT_RESTRICTED_JWT_ISSUER', settings.DEFAULT_RESTRICTED_JWT_ISSUER) - if is_application_restricted: + if is_application_scopes_restricted: for issuer in self.jwt_auth['JWT_ISSUERS']: if self.default_restricted_jwt_issuer == issuer['ISSUER']: self.issuer = issuer @@ -45,7 +46,7 @@ def __init__(self, user, asymmetric=False, secret=None,is_application_restricted if self.default_jwt_issuer == issuer['ISSUER']: self.issuer = issuer - def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None, org=None, application_grant_type=None): + def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None, orgs=None, application_grant_type=None): """Returns a JWT access token. Arguments: @@ -61,11 +62,13 @@ def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None, """ now = int(time()) expires_in = expires_in or self.jwt_auth['JWT_EXPIRATION'] - filters = {} - if org: - filters['content_org'] = org + filters = [] + if orgs: + for org in orgs: + filters.append('content_org:'+org) if application_grant_type != u'client-credentials': - filters['user'] = 'me' + filters.append('user:me') + payload = { # TODO Consider getting rid of this claim since we don't use it. 'aud': aud if aud else self.issuer['AUDIENCE'], From 09774d58da9b85d8b50515277670aad3dc435926 Mon Sep 17 00:00:00 2001 From: mettursathish Date: Thu, 24 May 2018 16:50:18 -0700 Subject: [PATCH 3/4] Addressing the feedback and cleaning the code --- lms/djangoapps/certificates/apis/v0/views.py | 12 +- lms/djangoapps/grades/api/views.py | 16 +-- lms/envs/common.py | 4 +- .../dot_overrides/validators.py | 19 +-- .../migrations/0002_auto_20180503_0349.py | 33 ----- .../core/djangoapps/oauth_dispatch/models.py | 113 +----------------- 6 files changed, 24 insertions(+), 173 deletions(-) delete mode 100644 openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180503_0349.py diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index ad57f093d542..70b810c4a465 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -2,6 +2,7 @@ import logging from edx_rest_framework_extensions.authentication import JwtAuthentication +from edx_rest_framework_extensions.permissions import JWTRestrictedApplicationPermission from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework.generics import GenericAPIView @@ -68,6 +69,7 @@ class CertificatesDetailView(GenericAPIView): "grade": "0.98" } """ + required_scopes = ['certificates:read'] authentication_classes = ( authentication.OAuth2AuthenticationAllowInactiveUser, @@ -76,7 +78,7 @@ class CertificatesDetailView(GenericAPIView): ) permission_classes = ( IsAuthenticated, - permissions.IsUserInUrlOrStaff + JWTRestrictedApplicationPermission ) def get(self, request, username, course_id): @@ -100,6 +102,14 @@ def get(self, request, username, course_id): data={'error_code': 'course_id_not_valid'} ) + if hasattr(request, 'auth') and hasattr(request, 'oauth_scopes_filters'): + if 'content_org' in request.oauth_scopes_filters.keys(): + if course_key.org not in request.oauth_scopes_filters['content_org']: + return Response( + status=403, + data={'error_code': 'course_org_not_associated_with_calling_application'} + ) + user_cert = get_certificate_for_user(username=username, course_key=course_key) if user_cert is None: return Response( diff --git a/lms/djangoapps/grades/api/views.py b/lms/djangoapps/grades/api/views.py index d9a3cedbb35e..4288cf4b3ef1 100644 --- a/lms/djangoapps/grades/api/views.py +++ b/lms/djangoapps/grades/api/views.py @@ -173,7 +173,6 @@ class UserGradeView(GradeViewMixin, GenericAPIView): # for RestrictedApplications (only). A RestrictedApplication can # only call this method if it is allowed to receive a 'grades:read' # scope - restricted_oauth_required = True required_scopes = ['grades:read'] def get(self, request, course_id): @@ -190,14 +189,15 @@ def get(self, request, course_id): # See if the request has an explicit sattr(request, 'allowed_organizations')) # which limits which OAuth2 clients can see the courses # based on the association with a RestrictedApplication - if hasattr(request, 'auth') and hasattr(request, 'filters'): + if hasattr(request, 'auth') and hasattr(request, 'oauth_scopes_filters'): course_key = CourseKey.from_string(course_id) - if course_key.org not in request.filters['content_org']: - return self.make_error_response( - status_code=status.HTTP_403_FORBIDDEN, - developer_message='The OAuth2 RestrictedApplication is not associated with org.', - error_code='course_org_not_associated_with_calling_application' - ) + if 'content_org' in request.oauth_scopes_filters.keys(): + if course_key.org not in request.oauth_scopes_filters['content_org']: + return self.make_error_response( + status_code=status.HTTP_403_FORBIDDEN, + developer_message='The OAuth2 RestrictedApplication is not associated with org.', + error_code='course_org_not_associated_with_calling_application' + ) course = self._get_course(course_id, request.user, 'load') if isinstance(course, Response): diff --git a/lms/envs/common.py b/lms/envs/common.py index b33f23df60c5..63a7fbfe85e7 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -506,13 +506,13 @@ # conform profile scope message that is presented to end-user # to lms/templates/provider/authorize.html. This may be revised later. 'profile': 'Know your name and username', - 'grades:read': 'Retrieve your grades for your enrolled courses' + 'grades:read': 'Retrieve your grades for your enrolled courses', + 'certificates:read': 'Retrieve your course certificates' }, 'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired', } # This is required for the migrations in oauth_dispatch.models # otherwise it fails saying this attribute is not present in Settings -##OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth_dispatch.OauthRestrictedApplication' ################################## TEMPLATE CONFIGURATION ##################################### # Mako templating diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py index bc029a7a115c..a79873f4bb75 100644 --- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py +++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py @@ -26,7 +26,7 @@ def on_access_token_presave(sender, instance, *args, **kwargs): # pylint: disab We do this as a pre-save hook on the ORM """ if not is_oauth_scope_enforcement_enabled(): - OauthRestrictedApplication.set_access_token_as_expired(instance) + RestrictedApplication.set_access_token_as_expired(instance) # TODO: Remove Django 1.11 upgrade shim # SHIM: Allow users that are inactive to still authenticate while keeping rate-limiting functionality. @@ -127,20 +127,3 @@ def save_bearer_token(self, token, request, *args, **kwargs): # Restore the original request attributes request.grant_type = grant_type request.user = user - - def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): - """ - Override the DOT implementation to add checks to make sure that a - RestrictedApplication is not granted scopes that it has not been - permitted to do - """ - - restricted_application = RestrictedApplication.get_restricted_application(client) - if restricted_application: - # caller is restricted, so we must vet the allowed scopes for that restricted application - return set(scopes).issubset(restricted_application.allowed_scopes) - - # not a restricted application, call into base implementation, - # which - basically - pulls the list of scopes from configuration settings as a global - # definition - return super(EdxOAuth2Validator, self).validate_scopes(client_id, scopes, client, request, *args, **kwargs) diff --git a/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180503_0349.py b/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180503_0349.py deleted file mode 100644 index 3e7501975cdc..000000000000 --- a/openedx/core/djangoapps/oauth_dispatch/migrations/0002_auto_20180503_0349.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-05-03 07:49 -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): - - dependencies = [ - ('organizations', '0006_auto_20171207_0259'), - ('oauth_dispatch', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='restrictedapplication', - name='_allowed_scopes', - field=models.TextField(null=True), - ), - migrations.AddField( - model_name='restrictedapplication', - name='_org_associations', - field=models.ForeignKey(default=b'', on_delete=django.db.models.deletion.CASCADE, to='organizations.Organization'), - ), - migrations.AlterField( - model_name='restrictedapplication', - name='application', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='restricted_application', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - ), - ] diff --git a/openedx/core/djangoapps/oauth_dispatch/models.py b/openedx/core/djangoapps/oauth_dispatch/models.py index 85a17f1bd71b..c0081cac37b0 100644 --- a/openedx/core/djangoapps/oauth_dispatch/models.py +++ b/openedx/core/djangoapps/oauth_dispatch/models.py @@ -17,6 +17,7 @@ # IMPORTANT: Do not change this after data has been populated in database _DEFAULT_SEPARATOR = ' ' + class RestrictedApplication(models.Model): """ This model lists which django-oauth-toolkit Applications are considered 'restricted' @@ -26,21 +27,7 @@ class RestrictedApplication(models.Model): so that they cannot be used to call into APIs. """ - application = models.OneToOneField( - oauth2_settings.APPLICATION_MODEL, - null=False, - related_name='restricted_application' - ) - - # a space separated list of scopes that this application can request - _allowed_scopes = models.TextField(null=True) - - # a space separated list of ORGs that this application is associated with - # this field will be used to implement appropriate data filtering - # so that clients of a specific OAuth2 Application will only be - # able retrieve datasets that the OAuth2 Application is allowed to retrieve. - _org_associations = models.ForeignKey(Organization, default = '') - + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, null=False) def __unicode__(self): """ @@ -49,93 +36,6 @@ def __unicode__(self): return u"".format( name=self.application.name ) - @classmethod - def is_token_a_restricted_application(cls, token): - """ - Returns if token is issued to a RestriectedApplication - """ - - if isinstance(token, basestring): - # if string is passed in, do the look up - token_obj = AccessToken.objects.get(token=token) - else: - token_obj = token - - return cls.get_restricted_application(token_obj.application) is not None - - @classmethod - def get_restricted_application(cls, application): - """ - For a given application, get the related restricted application - """ - return RestrictedApplication.objects.filter(application=application.id).first() - - @classmethod - def get_restricted_application_from_token(cls, token): - """ - Returns a RestrictedApplication object for a token, None is none exists - """ - - if isinstance(token, basestring): - # if string is passed in, do the look up - # TODO: Is there a way to do this with one DB lookup? - access_token = AccessToken.objects.select_related('application').filter(token=token).first() - application = access_token.application - else: - application = token.application - - return cls.get_restricted_application(application) - - def _get_delemitered_string_from_list(self, scopes_list, seperator=_DEFAULT_SEPARATOR): - """ - Helper to return a list from a delimited string - """ - return _DEFAULT_SEPARATOR.join(scopes_list) - - def _get_list_from_delimited_string(self, delimited_string, separator=_DEFAULT_SEPARATOR): - """ - Helper to return a list from a delimited string - """ - - return delimited_string.split(separator) if delimited_string else [] - - @property - def allowed_scopes(self): - """ - Translate space delimited string to a list - """ - return self._get_list_from_delimited_string(self._allowed_scopes) - - @allowed_scopes.setter - def allowed_scopes(self, value): - """ - Convert list to separated string - """ - self._allowed_scopes = _DEFAULT_SEPARATOR.join(value) - - def has_scope(self, scope): - """ - Returns in the RestrictedApplication has the requested scope - """ - - return scope in self.allowed_scopes - - @property - def org_associations(self): - """ - Translate space delimited string to a list - """ - - org_id = self._org_associations.id - - return Organization.objects.get(id=org_id).name - - def is_associated_with_org(self, org): - """ - Returns if the RestriectedApplication is associated with the requested org - """ - return org == self.org_associations - @classmethod def set_access_token_as_expired(cls, access_token): @@ -198,14 +98,6 @@ def allowed_scopes(self): app_scopes = set(self._get_list_from_delimited_string(self.allowed_scope)) return app_scopes.intersection(all_scopes) - @classmethod - def set_access_token_as_expired(cls, instance): - """ - For access_tokens for RestrictedApplications, put the expire timestamp into the beginning of the epoch - which is Jan. 1, 1970 - """ - - instance.expires = datetime(1970, 1, 1, tzinfo=utc) class OauthRestrictOrganization(models.Model): @@ -230,4 +122,3 @@ def org_associations(self): for each in self._org_associations.all(): org_associations_list.append(each.name) return org_associations_list - From fdc9e0e1209899c1eb06015f7b068c27010b2cc1 Mon Sep 17 00:00:00 2001 From: SathishKumar Eswaran Date: Wed, 20 Jun 2018 03:26:13 -0400 Subject: [PATCH 4/4] Adding organization name filter to authorize html Adding org in authorize html file --- .../oauth_dispatch/dot_overrides/views.py | 33 ++++++++++++++++++- .../core/djangoapps/oauth_dispatch/models.py | 4 +-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py index 363e6e86ebd4..37782ccd959a 100644 --- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py @@ -10,6 +10,32 @@ from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import AuthorizationView +from oauth2_provider import models as dot_models +from openedx.core.djangoapps.oauth_dispatch.models import ScopedOrganization + +def is_org_associated_with_appication(request): + dot_id = dot_models.get_application_model().objects.get(client_id = request.GET.get('client_id')).id + if ScopedOrganization.objects.filter(application_id=dot_id).exists(): + return True + else: + return False + +def get_associated_application_orgs(request): + if is_org_associated_with_appication(request): + orgs = ScopedOrganization.objects.get(application_id=dot_id).org_associations + return orgs + else: + return None + +def associate_org_with_scope_description(scopes,all_scopes,org): + org_scopes = ['grades:read','certificates:read'] + scope_descriptions = [] + for scope in scopes: + if scope in org_scopes: + scope_descriptions.append(all_scopes[scope] + ' with '+ org[0] ) + else: + scope_descriptions.append(all_scopes[scope]) + return scope_descriptions # TODO (ARCH-83) remove once we have full support of OAuth Scopes class EdxOAuth2AuthorizationView(AuthorizationView): @@ -38,7 +64,12 @@ def get(self, request, *args, **kwargs): scopes, credentials = self.validate_authorization_request(request) all_scopes = get_scopes_backend().get_all_scopes() - kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] + if is_org_associated_with_appication: + org = get_associated_application_orgs(request) + kwargs["scopes_descriptions"] = associate_org_with_scope_description(scopes,all_scopes,org) + else: + kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] + kwargs['scopes'] = scopes # at this point we know an Application instance with such client_id exists in the database diff --git a/openedx/core/djangoapps/oauth_dispatch/models.py b/openedx/core/djangoapps/oauth_dispatch/models.py index c0081cac37b0..0c1df1422fe9 100644 --- a/openedx/core/djangoapps/oauth_dispatch/models.py +++ b/openedx/core/djangoapps/oauth_dispatch/models.py @@ -54,7 +54,7 @@ def verify_access_token_as_expired(cls, access_token): return access_token.expires == datetime(1970, 1, 1, tzinfo=utc) -class OauthRestrictedApplication(AbstractApplication): +class ScopedApplication(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. @@ -99,7 +99,7 @@ def allowed_scopes(self): return app_scopes.intersection(all_scopes) -class OauthRestrictOrganization(models.Model): +class ScopedOrganization(models.Model): CONTENT_PROVIDER = 'content_provider' USER_PROVIDER = 'user_provider'