diff --git a/cms/envs/common.py b/cms/envs/common.py index fc4e314ec09c1cbc0d21f1a3006719a837d7cb6d..9a1b1f6f5cba61c26dfc1b69ca248b2fda941277 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -94,6 +94,14 @@ from lms.envs.common import ( REDIRECT_CACHE_TIMEOUT, REDIRECT_CACHE_KEY_PREFIX, + # This is required for the migrations in oauth_dispatch.models + # otherwise it fails saying this attribute is not present in Settings + # Although Studio does not enable OAuth2 Provider capability, the new approach + # to generating test databases will discover and try to create all tables + # and this setting needs to be present + OAUTH2_PROVIDER_APPLICATION_MODEL, + DEFAULT_JWT_ISSUER, + RESTRICTED_APPLICATION_JWT_ISSUER, JWT_AUTH, USERNAME_REGEX_PARTIAL, @@ -1452,13 +1460,6 @@ HELP_TOKENS_LANGUAGE_CODE = lambda settings: settings.LANGUAGE_CODE HELP_TOKENS_VERSION = lambda settings: doc_version() derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION') -# This is required for the migrations in oauth_dispatch.models -# otherwise it fails saying this attribute is not present in Settings -# Although Studio does not exable OAuth2 Provider capability, the new approach -# to generating test databases will discover and try to create all tables -# and this setting needs to be present -OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' - # Used with Email sending RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5 RETRY_ACTIVATION_EMAIL_TIMEOUT = 0.5 diff --git a/lms/envs/common.py b/lms/envs/common.py index a3f604a71217add2684f97cfec5837174ed1953a..1fbe4134f1e684e1ee43980784d0ac84a06cf76b 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -496,18 +496,27 @@ OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS = 30 ################################## DJANGO OAUTH TOOLKIT ####################################### +# Scope description strings are presented to the user +# on the application authorization page. See +# lms/templates/oauth2_provider/authorize.html for details. +OAUTH2_DEFAULT_SCOPES = { + 'read': _('Read access'), + 'write': _('Write access'), + 'email': _('Know your email address'), + 'profile': _('Know your name and username'), +} + OAUTH2_PROVIDER = { 'OAUTH2_VALIDATOR_CLASS': 'openedx.core.djangoapps.oauth_dispatch.dot_overrides.validators.EdxOAuth2Validator', 'REFRESH_TOKEN_EXPIRE_SECONDS': 20160, - 'SCOPES': { - 'read': 'Read access', - 'write': 'Write access', - 'email': 'Know your email address', - # 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', - }, + 'SCOPES_BACKEND_CLASS': 'openedx.core.djangoapps.oauth_dispatch.scopes.ApplicationModelScopes', + 'SCOPES': dict(OAUTH2_DEFAULT_SCOPES, **{ + 'grades:read': _('Retrieve your grades for your enrolled courses'), + 'certificates:read': _('Retrieve your course certificates'), + }), + 'DEFAULT_SCOPES': OAUTH2_DEFAULT_SCOPES, 'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired', + 'ERROR_RESPONSE_WITH_SCOPES': True, } # This is required for the migrations in oauth_dispatch.models # otherwise it fails saying this attribute is not present in Settings @@ -2396,22 +2405,6 @@ SOCIAL_MEDIA_FOOTER_NAMES = [ "reddit", ] -# JWT Settings -JWT_AUTH = { - # TODO Set JWT_SECRET_KEY to a secure value. By default, SECRET_KEY will be used. - # 'JWT_SECRET_KEY': '', - 'JWT_ALGORITHM': 'HS256', - 'JWT_VERIFY_EXPIRATION': True, - # TODO Set JWT_ISSUER and JWT_AUDIENCE to values specific to your service/organization. - 'JWT_ISSUER': 'change-me', - 'JWT_AUDIENCE': None, - 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'), - 'JWT_LEEWAY': 1, - 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.utils.jwt_decode_handler', - # Number of seconds before JWT tokens expire - 'JWT_EXPIRATION': 30, -} - # The footer URLs dictionary maps social footer names # to URLs defined in configuration. SOCIAL_MEDIA_FOOTER_URLS = {} @@ -3184,6 +3177,41 @@ NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png" +################################ Settings for JWTs ################################ + +DEFAULT_JWT_ISSUER = { + 'ISSUER': 'change-me', + 'SECRET_KEY': SECRET_KEY, + 'AUDIENCE': 'change-me', +} + +RESTRICTED_APPLICATION_JWT_ISSUER = { + 'ISSUER': 'change-me', + 'SECRET_KEY': SECRET_KEY, + 'AUDIENCE': 'change-me', +} + +JWT_AUTH = { + 'JWT_ALGORITHM': 'HS256', + 'JWT_VERIFY_EXPIRATION': True, + + 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'), + 'JWT_LEEWAY': 1, + 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.utils.jwt_decode_handler', + + # Number of seconds before JWT tokens expire + 'JWT_EXPIRATION': 30, + 'JWT_SUPPORTED_VERSION': '1.1.0', + + 'JWT_SECRET_KEY': DEFAULT_JWT_ISSUER['SECRET_KEY'], + 'JWT_ISSUER': DEFAULT_JWT_ISSUER['ISSUER'], + 'JWT_AUDIENCE': DEFAULT_JWT_ISSUER['AUDIENCE'], + 'JWT_ISSUERS': [ + DEFAULT_JWT_ISSUER, + RESTRICTED_APPLICATION_JWT_ISSUER, + ], +} + ################################ Settings for Microsites ################################ ### Select an implementation for the microsite backend diff --git a/lms/envs/devstack_docker.py b/lms/envs/devstack_docker.py index 73b838058ffbe477ac430920b39ffeea368db77c..8ce464f8cb2fd1b71bff90e497e0fb25cfca399c 100644 --- a/lms/envs/devstack_docker.py +++ b/lms/envs/devstack_docker.py @@ -27,10 +27,18 @@ CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:18150' OAUTH_OIDC_ISSUER = '{}/oauth2'.format(LMS_ROOT_URL) +DEFAULT_JWT_ISSUER = { + 'ISSUER': OAUTH_OIDC_ISSUER, + 'SECRET_KEY': 'lms-secret', + 'AUDIENCE': 'lms-key', +} JWT_AUTH.update({ - 'JWT_SECRET_KEY': 'lms-secret', - 'JWT_ISSUER': OAUTH_OIDC_ISSUER, - 'JWT_AUDIENCE': 'lms-key', + 'JWT_ISSUER': DEFAULT_JWT_ISSUER['ISSUER'], + 'JWT_AUDIENCE': DEFAULT_JWT_ISSUER['AUDIENCE'], + 'JWT_ISSUERS': [ + DEFAULT_JWT_ISSUER, + RESTRICTED_APPLICATION_JWT_ISSUER, + ], }) FEATURES.update({ diff --git a/lms/envs/test.py b/lms/envs/test.py index 38d9a8bce8d254649912bace5a8b028d952a4e8a..db627a32b600ffc4b99ff0795a6a1590761edcdf 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -274,6 +274,19 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True # don't cache courses for testing OIDC_COURSE_HANDLER_CACHE_TIMEOUT = 0 +########################### Settings for JWTs ################################## +RESTRICTED_APPLICATION_JWT_ISSUER = { + 'ISSUER': 'restricted-app', + 'SECRET_KEY': 'restricted-secret', + 'AUDIENCE': 'restricted-app', +} +JWT_AUTH.update({ + 'JWT_ISSUERS': [ + DEFAULT_JWT_ISSUER, + RESTRICTED_APPLICATION_JWT_ISSUER, + ], +}) + ########################### External REST APIs ################################# FEATURES['ENABLE_MOBILE_REST_API'] = True FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True @@ -553,12 +566,6 @@ FEATURES['ORGANIZATIONS_APP'] = True # Financial assistance page FEATURES['ENABLE_FINANCIAL_ASSISTANCE_FORM'] = True -JWT_AUTH.update({ - 'JWT_SECRET_KEY': 'test-secret', - 'JWT_ISSUER': 'https://test-provider/oauth2', - 'JWT_AUDIENCE': 'test-key', -}) - COURSE_CATALOG_API_URL = 'https://catalog.example.com/api/v1' COMPREHENSIVE_THEME_DIRS = [REPO_ROOT / "themes", REPO_ROOT / "common/test"] diff --git a/lms/templates/oauth2_provider/authorize.html b/lms/templates/oauth2_provider/authorize.html index 75146eeaa47bce5ecffa2507954baf6725ed3560..6310a844c710552683ee6f54d98293510f4473c7 100644 --- a/lms/templates/oauth2_provider/authorize.html +++ b/lms/templates/oauth2_provider/authorize.html @@ -27,6 +27,16 @@ <li>{{ scope }}</li> {% endfor %} </ul> + + {% if content_orgs %} + <p>{% trans "These permissions will be granted for data from your courses associated with the following content providers:" %}</p> + <ul> + {% for org_name in content_orgs %} + <li>{{ org_name }}</li> + {% endfor %} + </ul> + {% endif %} + <p>{% trans "Please click the 'Allow' button to grant these permissions to the above application. Otherwise, to withhold these permissions, please click the 'Cancel' button." %} </p> diff --git a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py index b12994b96f6cd6d6d1a9dd6525d809f88fd4892b..8223f7610aa6091e01a8ec2ac3ae31f480d25f26 100644 --- a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py +++ b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py @@ -68,3 +68,15 @@ class DOPAdapter(object): Given an access token object, return its scopes. """ return scope.to_names(token.scope) + + def is_client_restricted(self, client_id): # pylint: disable=unused-argument + """ + Returns true if the client is set up as a RestrictedApplication. + """ + return False + + def get_authorization_filters(self, client_id): # pylint: disable=unused-argument + """ + Get the authorization filters for the given client application. + """ + return [] diff --git a/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py b/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py index e1ac9705dbc39681f49a732fa47f5d243c0ad863..2408731edd8c0a95caed0a924bed389512f41802 100644 --- a/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py +++ b/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py @@ -4,6 +4,8 @@ Adapter to isolate django-oauth-toolkit dependencies from oauth2_provider import models +from openedx.core.djangoapps.oauth_dispatch.models import RestrictedApplication + class DOTAdapter(object): """ @@ -11,6 +13,7 @@ class DOTAdapter(object): """ backend = object() + FILTER_USER_ME = u'user:me' def create_confidential_client(self, name, @@ -30,7 +33,8 @@ class DOTAdapter(object): redirect_uris=redirect_uri, ) - def create_public_client(self, name, user, redirect_uri, client_id=None): + def create_public_client(self, name, user, redirect_uri, client_id=None, + grant_type=models.Application.GRANT_PASSWORD): """ Create an oauth client application that is public. """ @@ -39,7 +43,7 @@ class DOTAdapter(object): user=user, client_id=client_id, client_type=models.Application.CLIENT_PUBLIC, - authorization_grant_type=models.Application.GRANT_PASSWORD, + authorization_grant_type=grant_type, redirect_uris=redirect_uri, ) @@ -76,3 +80,26 @@ class DOTAdapter(object): Given an access token object, return its scopes. """ return list(token.scopes) + + def is_client_restricted(self, client_id): + """ + Returns true if the client is set up as a RestrictedApplication. + """ + application = self.get_client(client_id=client_id) + return RestrictedApplication.objects.filter(application=application).exists() + + def get_authorization_filters(self, client_id): + """ + Get the authorization filters for the given client application. + """ + application = self.get_client(client_id=client_id) + filters = [org_relation.to_jwt_filter_claim() for org_relation in application.organizations.all()] + + # Allow applications configured with the client credentials grant type to access + # data for all users. This will enable these applications to fetch data in bulk. + # Applications configured with all other grant types should only have access + # to data for the request user. + if application.authorization_grant_type != application.GRANT_CLIENT_CREDENTIALS: + filters.append(self.FILTER_USER_ME) + + return filters diff --git a/openedx/core/djangoapps/oauth_dispatch/admin.py b/openedx/core/djangoapps/oauth_dispatch/admin.py index 2c01b289d15c9abd3f2beecebdb874f330777b33..54689f2d8caa1aabbb93990efa7c0df173e12abe 100644 --- a/openedx/core/djangoapps/oauth_dispatch/admin.py +++ b/openedx/core/djangoapps/oauth_dispatch/admin.py @@ -5,7 +5,7 @@ Override admin configuration for django-oauth-toolkit from django.contrib.admin import ModelAdmin, site from oauth2_provider import models -from .models import RestrictedApplication +from .models import RestrictedApplication, ApplicationAccess, ApplicationOrganization def reregister(model_class): @@ -52,27 +52,41 @@ class DOTRefreshTokenAdmin(ModelAdmin): search_fields = [u'token', u'user__username', u'access_token__token'] -@reregister(models.Application) +@reregister(models.Grant) +class DOTGrantAdmin(ModelAdmin): + """ + Custom Grant Admin + """ + date_hierarchy = u'expires' + list_display = [u'code', u'user', u'application', u'expires'] + list_filter = [u'application'] + raw_id_fields = [u'user'] + search_fields = [u'code', u'user__username'] + + +@reregister(models.get_application_model()) 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'] + list_filter = [u'client_type', u'authorization_grant_type', u'skip_authorization'] raw_id_fields = [u'user'] search_fields = [u'name', u'user__username', u'client_id'] -@reregister(models.Grant) -class DOTGrantAdmin(ModelAdmin): +class ApplicationAccessAdmin(ModelAdmin): """ - Custom Grant Admin + ModelAdmin for ApplicationAccess """ - date_hierarchy = u'expires' - list_display = [u'code', u'user', u'application', u'expires'] - list_filter = [u'application'] - raw_id_fields = [u'user'] - search_fields = [u'code', u'user__username'] + list_display = [u'application', u'scopes'] + + +class ApplicationOrganizationAdmin(ModelAdmin): + """ + ModelAdmin for ApplicationOrganization + """ + list_display = [u'application', u'organization', u'relation_type'] class RestrictedApplicationAdmin(ModelAdmin): @@ -82,4 +96,6 @@ class RestrictedApplicationAdmin(ModelAdmin): list_display = [u'application'] +site.register(ApplicationAccess, ApplicationAccessAdmin) +site.register(ApplicationOrganization, ApplicationOrganizationAdmin) site.register(RestrictedApplication, RestrictedApplicationAdmin) diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst index 6cad2345cdccf9ff0adfaaa68327176630870f84..134bdc450981857390343763338c2e0849ea229d 100644 --- a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst +++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst @@ -100,7 +100,8 @@ organization information to the granting end-user. "content_org:Microsoft" -* For a token created via a *Client Credentials grant type*, the token +* For a token created on behalf of a user (*not* created via a + *Client Credentials grant type*), the token is further restricted specifically for the granting user. And so, a "user" filter with the value "me" would be added for this grant type. For example: diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py index a44337a789b8d68bda3c45b9494922b603793ce4..5214622995fdf25d53e82e25ab5e92d75b0c4b1d 100644 --- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py +++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py @@ -11,6 +11,7 @@ from django.db.models.signals import pre_save from django.dispatch import receiver from oauth2_provider.models import AccessToken from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.scopes import get_scopes_backend from pytz import utc from ratelimitbackend.backends import RateLimitMixin @@ -20,15 +21,10 @@ from ..models import RestrictedApplication @receiver(pre_save, sender=AccessToken) def on_access_token_presave(sender, instance, *args, **kwargs): # pylint: disable=unused-argument """ - A hook on the AccessToken. Since we do not have protected scopes, we must mark all - AccessTokens as expired for 'restricted applications'. - - We do this as a pre-save hook on the ORM + Mark AccessTokens as expired for 'restricted applications' if required. """ - - is_application_restricted = RestrictedApplication.objects.filter(application=instance.application).exists() - if is_application_restricted: - RestrictedApplication.set_access_token_as_expired(instance) + if RestrictedApplication.should_expire_access_token(instance.application): + instance.expires = datetime(1970, 1, 1, tzinfo=utc) class EdxRateLimitedAllowAllUsersModelBackend(RateLimitMixin, UserModelBackend): @@ -101,8 +97,7 @@ class EdxOAuth2Validator(OAuth2Validator): 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 RestrictedApplication.should_expire_access_token(request.client): # 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 @@ -121,3 +116,10 @@ class EdxOAuth2Validator(OAuth2Validator): # Restore the original request attributes request.grant_type = grant_type request.user = user + + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + """ + Ensure required scopes are permitted (as specified in the settings file) + """ + available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request) + return set(scopes).issubset(set(available_scopes)) diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py index 363e6e86ebd4d38ec280319f2d8d8d5d466624dc..0485f1c665480dafadb6e260358e5d05cde0a044 100644 --- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/views.py @@ -10,6 +10,8 @@ from oauth2_provider.scopes import get_scopes_backend from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import AuthorizationView +from openedx.core.djangoapps.oauth_dispatch.models import ApplicationOrganization + # TODO (ARCH-83) remove once we have full support of OAuth Scopes class EdxOAuth2AuthorizationView(AuthorizationView): @@ -43,7 +45,12 @@ class EdxOAuth2AuthorizationView(AuthorizationView): # at this point we know an Application instance with such client_id exists in the database application = get_application_model().objects.get(client_id=credentials['client_id']) + content_orgs = ApplicationOrganization.get_related_org_names( + application, + relation_type=ApplicationOrganization.RELATION_TYPE_CONTENT_ORG + ) kwargs['application'] = application + kwargs['content_orgs'] = content_orgs kwargs['client_id'] = credentials['client_id'] kwargs['redirect_uri'] = credentials['redirect_uri'] kwargs['response_type'] = credentials['response_type'] diff --git a/openedx/core/djangoapps/oauth_dispatch/migrations/0004_auto_20180626_1349.py b/openedx/core/djangoapps/oauth_dispatch/migrations/0004_auto_20180626_1349.py new file mode 100644 index 0000000000000000000000000000000000000000..63266b36c8f9f8f881290477363a2ccceda6189e --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/migrations/0004_auto_20180626_1349.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-06-26 17:49 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_mysql.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0006_auto_20171207_0259'), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ('oauth_dispatch', '0003_application_data'), + ] + + operations = [ + migrations.CreateModel( + name='ApplicationAccess', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scopes', django_mysql.models.ListCharField(models.CharField(max_length=32), help_text='Comma-separated list of scopes that this application will be allowed to request.', max_length=825, size=25)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, unique=True)), + ], + ), + migrations.CreateModel( + name='ApplicationOrganization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('relation_type', models.CharField(choices=[(b'content_org', 'Content Provider')], default=b'content_org', max_length=32)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizations', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='organizations.Organization')), + ], + ), + migrations.RemoveField( + model_name='scopedapplication', + name='user', + ), + migrations.RemoveField( + model_name='scopedapplicationorganization', + name='application', + ), + migrations.DeleteModel( + name='ScopedApplication', + ), + migrations.DeleteModel( + name='ScopedApplicationOrganization', + ), + migrations.AlterUniqueTogether( + name='applicationorganization', + unique_together=set([('application', 'relation_type', 'organization')]), + ), + ] diff --git a/openedx/core/djangoapps/oauth_dispatch/models.py b/openedx/core/djangoapps/oauth_dispatch/models.py index 0faeb1f0f8a28234c5d3b4fa5d937c0330990150..97e090cbcc957547d20e9d32fbfce7288610a17e 100644 --- a/openedx/core/djangoapps/oauth_dispatch/models.py +++ b/openedx/core/djangoapps/oauth_dispatch/models.py @@ -7,10 +7,13 @@ from datetime import datetime from django.db import models from django.utils.translation import ugettext_lazy as _ from django_mysql.models import ListCharField -from oauth2_provider.models import AbstractApplication from oauth2_provider.settings import oauth2_settings +from organizations.models import Organization from pytz import utc +from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES +from openedx.core.djangoapps.request_cache import get_request_or_stub + class RestrictedApplication(models.Model): """ @@ -23,6 +26,9 @@ class RestrictedApplication(models.Model): application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, null=False, on_delete=models.CASCADE) + class Meta: + app_label = 'oauth_dispatch' + def __unicode__(self): """ Return a unicode representation of this object @@ -32,12 +38,11 @@ class RestrictedApplication(models.Model): ) @classmethod - def set_access_token_as_expired(cls, access_token): - """ - For access_tokens for RestrictedApplications, put the expire timestamp into the beginning of the epoch - which is Jan. 1, 1970 - """ - access_token.expires = datetime(1970, 1, 1, tzinfo=utc) + def should_expire_access_token(cls, application): + set_token_expired = not ENFORCE_JWT_SCOPES.is_enabled() + jwt_not_requested = get_request_or_stub().POST.get('token_type', '').lower() != 'jwt' + restricted_application = cls.objects.filter(application=application).exists() + return restricted_application and (jwt_not_requested or set_token_expired) @classmethod def verify_access_token_as_expired(cls, access_token): @@ -48,19 +53,12 @@ class RestrictedApplication(models.Model): return access_token.expires == datetime(1970, 1, 1, tzinfo=utc) -class ScopedApplication(AbstractApplication): +class ApplicationAccess(models.Model): """ - Custom Django OAuth Toolkit Application model that enables the definition - of scopes that are authorized for the given Application. + Specifies access control information for the associated Application. """ - FILTER_USER_ME = 'user:me' - - # TODO: Remove the id field once we perform the inital migrations for this model. - # We need to copy data over from the oauth2_provider.models.Application model to - # this new model with the intial migration and the model IDs will need to match - # so that existing AccessTokens will still work when switching over to the new model. - # Once we have the data copied over we can move back to an auto-increment primary key. - id = models.IntegerField(primary_key=True) + + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, unique=True, related_name='access') scopes = ListCharField( base_field=models.CharField(max_length=32), size=25, @@ -71,63 +69,68 @@ class ScopedApplication(AbstractApplication): class Meta: app_label = 'oauth_dispatch' + @classmethod + def get_scopes(cls, application): + return cls.objects.get(application=application).scopes + def __unicode__(self): """ Return a unicode representation of this object. """ - return u"<ScopedApplication '{name}'>".format( - name=self.name + return u"{application_name}:{scopes}".format( + application_name=self.application.name, + scopes=self.scopes, ) - @property - def authorization_filters(self): - """ - Return the list of authorization filters for this application. - """ - filters = [':'.join([org.provider_type, org.short_name]) for org in self.organizations.all()] - if self.authorization_grant_type == self.GRANT_CLIENT_CREDENTIALS: - filters.append(self.FILTER_USER_ME) - return filters - -class ScopedApplicationOrganization(models.Model): +class ApplicationOrganization(models.Model): """ - Associates an organization to a given ScopedApplication including the - provider type of the organization so that organization-based filters - can be added to access tokens provided to the given Application. + Associates a DOT Application to an Organization. See openedx/core/djangoapps/oauth_dispatch/docs/decisions/0007-include-organizations-in-tokens.rst for the intended use of this model. """ - CONTENT_PROVIDER_TYPE = 'content_org' - ORGANIZATION_PROVIDER_TYPES = ( - (CONTENT_PROVIDER_TYPE, _('Content Provider')), + RELATION_TYPE_CONTENT_ORG = 'content_org' + RELATION_TYPES = ( + (RELATION_TYPE_CONTENT_ORG, _('Content Provider')), ) - # In practice, short_name should match the short_name of an Organization model. - # This is not a foreign key because the organizations app is not installed by default. - short_name = models.CharField( - max_length=255, - help_text=_('The short_name of an existing Organization.'), - ) - provider_type = models.CharField( + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, related_name='organizations') + organization = models.ForeignKey(Organization) + relation_type = models.CharField( max_length=32, - choices=ORGANIZATION_PROVIDER_TYPES, - default=CONTENT_PROVIDER_TYPE, - ) - application = models.ForeignKey( - oauth2_settings.APPLICATION_MODEL, - related_name='organizations', + choices=RELATION_TYPES, + default=RELATION_TYPE_CONTENT_ORG, ) class Meta: app_label = 'oauth_dispatch' + unique_together = ('application', 'relation_type', 'organization') + + @classmethod + def get_related_org_names(cls, application, relation_type=None): + """ + Return the names of the Organizations related to the given DOT Application. + + Filter by relation_type if provided. + """ + queryset = application.organizations.all() + if relation_type: + queryset = queryset.filter(relation_type=relation_type) + return [r.organization.name for r in queryset] def __unicode__(self): """ Return a unicode representation of this object. """ - return u"<ScopedApplicationOrganization '{application_name}':'{org}'>".format( + return u"{application_name}:{organization}:{relation_type}".format( application_name=self.application.name, - org=self.short_name, + organization=self.organization.short_name, + relation_type=self.relation_type, ) + + def to_jwt_filter_claim(self): + """ + Serialize for use in JWT filter claim. + """ + return unicode(':'.join([self.relation_type, self.organization.short_name])) diff --git a/openedx/core/djangoapps/oauth_dispatch/scopes.py b/openedx/core/djangoapps/oauth_dispatch/scopes.py new file mode 100644 index 0000000000000000000000000000000000000000..ddce0bd3f307a04e2497ba3a5646550184a3326a --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/scopes.py @@ -0,0 +1,23 @@ +""" +Custom Django OAuth Toolkit scopes backends. +""" + +from oauth2_provider.scopes import SettingsScopes + +from openedx.core.djangoapps.oauth_dispatch.models import ApplicationAccess + + +class ApplicationModelScopes(SettingsScopes): + """ + Scopes backend that determines available scopes using the ApplicationAccess model. + """ + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + """ Returns valid scopes configured for the given application. """ + try: + application_scopes = ApplicationAccess.get_scopes(application) + except ApplicationAccess.DoesNotExist: + application_scopes = [] + + default_scopes = self.get_default_scopes() + all_scopes = self.get_all_scopes().keys() + return set(application_scopes + default_scopes).intersection(all_scopes) diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/factories.py b/openedx/core/djangoapps/oauth_dispatch/tests/factories.py index 100c166c2735d4f0c5db888acc9b4995fa1672f8..07341e8ad5219d72848e07a8f7eaf9e7f875e9a8 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/factories.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/factories.py @@ -8,7 +8,9 @@ from factory.fuzzy import FuzzyText import pytz from oauth2_provider.models import Application, AccessToken, RefreshToken +from organizations.tests.factories import OrganizationFactory +from openedx.core.djangoapps.oauth_dispatch.models import ApplicationAccess, ApplicationOrganization from student.tests.factories import UserFactory @@ -23,6 +25,23 @@ class ApplicationFactory(DjangoModelFactory): authorization_grant_type = 'Client credentials' +class ApplicationAccessFactory(DjangoModelFactory): + class Meta(object): + model = ApplicationAccess + + application = factory.SubFactory(ApplicationFactory) + scopes = ['grades:read'] + + +class ApplicationOrganizationFactory(DjangoModelFactory): + class Meta(object): + model = ApplicationOrganization + + application = factory.SubFactory(ApplicationFactory) + organization = factory.SubFactory(OrganizationFactory) + relation_type = ApplicationOrganization.RELATION_TYPE_CONTENT_ORG + + class AccessTokenFactory(DjangoModelFactory): class Meta(object): model = AccessToken diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py index cc2de1050081f2316d82810d937bbecc211abd70..a92421e057a87f3f6605623c35be547db1d1e083 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py @@ -13,7 +13,8 @@ from student.models import UserProfile, anonymous_id_for_user class AccessTokenMixin(object): """ Mixin for tests dealing with OAuth 2 access tokens. """ - def assert_valid_jwt_access_token(self, access_token, user, scopes=None, should_be_expired=False): + def assert_valid_jwt_access_token(self, access_token, user, scopes=None, should_be_expired=False, filters=None, + jwt_issuer=settings.DEFAULT_JWT_ISSUER, should_be_restricted=None): """ Verify the specified JWT access token is valid, and belongs to the specified user. @@ -26,8 +27,9 @@ class AccessTokenMixin(object): dict: Decoded JWT payload """ scopes = scopes or [] - audience = settings.JWT_AUTH['JWT_AUDIENCE'] - issuer = settings.JWT_AUTH['JWT_ISSUER'] + audience = jwt_issuer['AUDIENCE'] + issuer = jwt_issuer['ISSUER'] + secret_key = jwt_issuer['SECRET_KEY'] def _decode_jwt(verify_expiration): """ @@ -36,7 +38,7 @@ class AccessTokenMixin(object): """ return jwt.decode( access_token, - settings.JWT_AUTH['JWT_SECRET_KEY'], + secret_key, algorithms=[settings.JWT_AUTH['JWT_ALGORITHM']], audience=audience, issuer=issuer, @@ -55,6 +57,7 @@ class AccessTokenMixin(object): 'iss': issuer, 'preferred_username': user.username, 'scopes': scopes, + 'version': settings.JWT_AUTH['JWT_SUPPORTED_VERSION'], 'sub': anonymous_id_for_user(user, None), } @@ -74,6 +77,12 @@ class AccessTokenMixin(object): 'given_name': user.first_name, }) + if filters: + expected['filters'] = filters + + if should_be_restricted is not None: + expected['is_restricted'] = should_be_restricted + self.assertDictContainsSubset(expected, payload) # Since we suppressed checking of expiry diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_models.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..9045f1f995c9f02242ecee2da5c5fa13ec7a49d9 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_models.py @@ -0,0 +1,20 @@ +""" +Tests for oauth_dispatch models. +""" +from django.test import TestCase + +from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationOrganizationFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +@skip_unless_lms +class ApplicationOrganizationTestCase(TestCase): + """ + Tests for the ApplicationOrganization model. + """ + def test_to_jwt_filter_claim(self): + """ Verify to_jwt_filter_claim returns the expected serialization of the model. """ + org_relation = ApplicationOrganizationFactory() + organization = org_relation.organization + jwt_filter_claim = org_relation.to_jwt_filter_claim() + assert jwt_filter_claim == unicode(':'.join([org_relation.relation_type, organization.short_name])) diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py new file mode 100644 index 0000000000000000000000000000000000000000..dab7d186fde4097334a67bff2d97d3ae90f5dbaa --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py @@ -0,0 +1,33 @@ +""" +Tests for custom DOT scopes backend. +""" +import ddt +from django.conf import settings +from django.test import TestCase + +from openedx.core.djangoapps.oauth_dispatch.scopes import ApplicationModelScopes +from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationAccessFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + + +@skip_unless_lms +@ddt.ddt +class ApplicationModelScopesTestCase(TestCase): + """ + Tests for the ApplicationModelScopes custom DOT scopes backend. + """ + @ddt.data( + ([], []), + (['unsupported_scope:read'], []), + (['grades:read'], ['grades:read']), + (['grades:read', 'certificates:read'], ['grades:read', 'certificates:read']), + ) + @ddt.unpack + def test_get_available_scopes(self, application_scopes, expected_additional_scopes): + """ Verify the settings backend returns the expected available scopes. """ + application_access = ApplicationAccessFactory(scopes=application_scopes) + scopes = ApplicationModelScopes() + self.assertEqual( + set(scopes.get_available_scopes(application_access.application)), + set(settings.OAUTH2_DEFAULT_SCOPES.keys() + expected_additional_scopes), + ) diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py index 347bae8684c816c30eed798638afbab40deb7405..125fc5b164eb0ac0cc37c543122eb08e98fd5154 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py @@ -12,7 +12,9 @@ from django.conf import settings from django.urls import reverse from django.test import RequestFactory, TestCase, override_settings from oauth2_provider import models as dot_models +from organizations.tests.factories import OrganizationFactory +from openedx.core.djangoapps.oauth_dispatch.toggles import ENFORCE_JWT_SCOPES from provider import constants from student.tests.factories import UserFactory from third_party_auth.tests.utils import ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinGoogle @@ -97,6 +99,15 @@ class _DispatchingViewTestCase(TestCase): client_id='dop-app-client-id', ) + self.dot_app_access = models.ApplicationAccess.objects.create( + application=self.dot_app, + scopes=['grades:read'], + ) + self.dot_app_org = models.ApplicationOrganization.objects.create( + application=self.dot_app, + organization=OrganizationFactory() + ) + # Create a "restricted" DOT Application which means any AccessToken/JWT # generated for this application will be immediately expired self.restricted_dot_app = self.dot_adapter.create_public_client( @@ -107,14 +118,14 @@ class _DispatchingViewTestCase(TestCase): ) models.RestrictedApplication.objects.create(application=self.restricted_dot_app) - def _post_request(self, user, client, token_type=None): + def _post_request(self, user, client, token_type=None, scope=None): """ - Call the view with a POST request objectwith the appropriate format, + Call the view with a POST request object with the appropriate format, returning the response object. """ - return self.client.post(self.url, self._post_body(user, client, token_type)) # pylint: disable=no-member + return self.client.post(self.url, self._post_body(user, client, token_type, scope)) # pylint: disable=no-member - def _post_body(self, user, client, token_type=None): + def _post_body(self, user, client, token_type=None, scope=None): """ Return a dictionary to be used as the body of the POST request """ @@ -132,20 +143,28 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa self.url = reverse('access_token') self.view_class = views.AccessTokenView - def _post_body(self, user, client, token_type=None): + def _post_body(self, user, client, token_type=None, scope=None): """ Return a dictionary to be used as the body of the POST request """ + grant_type = getattr(client, 'authorization_grant_type', dot_models.Application.GRANT_PASSWORD) body = { 'client_id': client.client_id, - 'grant_type': 'password', - 'username': user.username, - 'password': 'test', + 'grant_type': grant_type.replace('-', '_'), } + if grant_type == dot_models.Application.GRANT_PASSWORD: + body['username'] = user.username + body['password'] = 'test' + elif grant_type == dot_models.Application.GRANT_CLIENT_CREDENTIALS: + body['client_secret'] = client.client_secret + if token_type: body['token_type'] = token_type + if scope: + body['scope'] = scope + return body @ddt.data('dop_app', 'dot_app') @@ -159,21 +178,24 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa self.assertIn('scope', data) self.assertIn('token_type', data) - def test_restricted_access_token_fields(self): - response = self._post_request(self.user, self.restricted_dot_app) - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertIn('access_token', data) - self.assertIn('expires_in', data) - self.assertIn('scope', data) - self.assertIn('token_type', data) - - # Restricted applications have immediately expired tokens - self.assertLess(data['expires_in'], 0) - - # double check that the token stored in the DB is marked as expired - access_token = dot_models.AccessToken.objects.get(token=data['access_token']) - self.assertTrue(models.RestrictedApplication.verify_access_token_as_expired(access_token)) + @ddt.data(False, True) + def test_restricted_non_jwt_access_token_fields(self, enforce_jwt_scopes_enabled): + with ENFORCE_JWT_SCOPES.override(enforce_jwt_scopes_enabled): + response = self._post_request(self.user, self.restricted_dot_app) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertIn('access_token', data) + self.assertIn('expires_in', data) + self.assertIn('scope', data) + self.assertIn('token_type', data) + + # Verify token expiration. + self.assertEqual(data['expires_in'] < 0, True) + access_token = dot_models.AccessToken.objects.get(token=data['access_token']) + self.assertEqual( + models.RestrictedApplication.verify_access_token_as_expired(access_token), + True + ) @ddt.data('dop_app', 'dot_app') def test_jwt_access_token(self, client_attr): @@ -183,28 +205,41 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa data = json.loads(response.content) self.assertIn('expires_in', data) self.assertEqual(data['token_type'], 'JWT') - self.assert_valid_jwt_access_token(data['access_token'], self.user, data['scope'].split(' ')) + self.assert_valid_jwt_access_token( + data['access_token'], + self.user, + data['scope'].split(' '), + should_be_restricted=False, + ) - def test_restricted_jwt_access_token(self): + @ddt.data( + (False, True, settings.DEFAULT_JWT_ISSUER), + (True, False, settings.RESTRICTED_APPLICATION_JWT_ISSUER), + ) + @ddt.unpack + def test_restricted_jwt_access_token(self, enforce_jwt_scopes_enabled, expiration_expected, + jwt_issuer_expected): """ Verify that when requesting a JWT token from a restricted Application within the DOT subsystem, that our claims is marked as already expired (i.e. expiry set to Jan 1, 1970) """ - response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt') - self.assertEqual(response.status_code, 200) - data = json.loads(response.content) - self.assertIn('expires_in', data) - - # jwt must indicate that it is already expired - self.assertLess(data['expires_in'], 0) - self.assertEqual(data['token_type'], 'JWT') - self.assert_valid_jwt_access_token( - data['access_token'], - self.user, - data['scope'].split(' '), - should_be_expired=True - ) + with ENFORCE_JWT_SCOPES.override(enforce_jwt_scopes_enabled): + response = self._post_request(self.user, self.restricted_dot_app, token_type='jwt') + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + + self.assertIn('expires_in', data) + self.assertEqual(data['expires_in'] < 0, expiration_expected) + self.assertEqual(data['token_type'], 'JWT') + self.assert_valid_jwt_access_token( + data['access_token'], + self.user, + data['scope'].split(' '), + should_be_expired=expiration_expected, + jwt_issuer=jwt_issuer_expected, + should_be_restricted=True, + ) def test_restricted_access_token(self): """ @@ -238,6 +273,38 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa data = json.loads(response.content) self.assertNotIn('refresh_token', data) + @ddt.data(dot_models.Application.GRANT_CLIENT_CREDENTIALS, dot_models.Application.GRANT_PASSWORD) + def test_jwt_access_token_scopes_and_filters(self, grant_type): + """ + Verify the JWT contains the expected scopes and filters. + """ + dot_app = self.dot_adapter.create_public_client( + name='test dot application', + user=self.user, + redirect_uri=DUMMY_REDIRECT_URL, + client_id='dot-app-client-id-{grant_type}'.format(grant_type=grant_type), + grant_type=grant_type, + ) + dot_app_access = models.ApplicationAccess.objects.create( + application=dot_app, + scopes=['grades:read'], + ) + models.ApplicationOrganization.objects.create( + application=dot_app, + organization=OrganizationFactory() + ) + scopes = dot_app_access.scopes + filters = self.dot_adapter.get_authorization_filters(dot_app.client_id) + response = self._post_request(self.user, dot_app, token_type='jwt', scope=scopes) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assert_valid_jwt_access_token( + data['access_token'], + self.user, + scopes, + filters=filters, + ) + @ddt.ddt @httpretty.activate @@ -251,7 +318,7 @@ class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAut self.view_class = views.AccessTokenExchangeView super(TestAccessTokenExchangeView, self).setUp() - def _post_body(self, user, client, token_type=None): + def _post_body(self, user, client, token_type=None, scope=None): return { 'client_id': client.client_id, 'access_token': self.access_token, @@ -283,6 +350,14 @@ class TestAuthorizationView(_DispatchingViewTestCase): redirect_uri=DUMMY_REDIRECT_URL, client_id='confidential-dot-app-client-id', ) + models.ApplicationAccess.objects.create( + application=self.dot_app, + scopes=['grades:read'], + ) + self.dot_app_org = models.ApplicationOrganization.objects.create( + application=self.dot_app, + organization=OrganizationFactory() + ) self.dop_app = self.dop_adapter.create_confidential_client( name='test dop client', user=self.user, @@ -327,7 +402,7 @@ class TestAuthorizationView(_DispatchingViewTestCase): 'response_type': 'code', 'state': 'random_state_string', 'redirect_uri': DUMMY_REDIRECT_URL, - 'scope': 'profile' + 'scope': 'profile grades:read' }, follow=True, ) @@ -335,6 +410,7 @@ class TestAuthorizationView(_DispatchingViewTestCase): # are the requested scopes on the page? We only requested 'profile', lets make # sure the page only lists that one self.assertContains(response, settings.OAUTH2_PROVIDER['SCOPES']['profile']) + self.assertContains(response, settings.OAUTH2_PROVIDER['SCOPES']['grades:read']) self.assertNotContains(response, settings.OAUTH2_PROVIDER['SCOPES']['read']) self.assertNotContains(response, settings.OAUTH2_PROVIDER['SCOPES']['write']) self.assertNotContains(response, settings.OAUTH2_PROVIDER['SCOPES']['email']) @@ -355,6 +431,12 @@ class TestAuthorizationView(_DispatchingViewTestCase): '<button type="submit" class="btn btn-authorization-allow" name="allow" value="Authorize"/>Allow</button>' ) + # Are the content provider organizations listed on the page? + self.assertContains( + response, + '<li>{org}</li>'.format(org=self.dot_app_org.organization.name) + ) + def _check_dot_response(self, response): """ Check that django-oauth-toolkit gives an appropriate authorization response. diff --git a/openedx/core/djangoapps/oauth_dispatch/toggles.py b/openedx/core/djangoapps/oauth_dispatch/toggles.py new file mode 100644 index 0000000000000000000000000000000000000000..fe1fd23909f82e9e70178dfd77db916ba6387641 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/toggles.py @@ -0,0 +1,11 @@ +""" +Feature toggle code for oauth_dispatch. +""" + +from edx_rest_framework_extensions.config import SWITCH_ENFORCE_JWT_SCOPES + +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace, WaffleSwitch + +WAFFLE_NAMESPACE = 'oauth2' +OAUTH2_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE) +ENFORCE_JWT_SCOPES = WaffleSwitch(OAUTH2_SWITCHES, SWITCH_ENFORCE_JWT_SCOPES) diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index 8429fb333d1af1ac5f92f932e2b7dbc10ce3714a..fb61caed2080535d1c55d2dd16553623593894cb 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -25,6 +25,7 @@ from openedx.core.lib.token_utils import JwtBuilder from . import adapters from .dot_overrides import views as dot_overrides_views +from .toggles import ENFORCE_JWT_SCOPES class _DispatchingView(View): @@ -101,10 +102,28 @@ class AccessTokenView(RatelimitMixin, _DispatchingView): response = super(AccessTokenView, self).dispatch(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) + client_id = self._get_client_id(request) + adapter = self.get_adapter(request) + expires_in, scopes, user = self._decompose_access_token_response(adapter, response) + issuer, secret, audience, filters, is_client_restricted = self._get_client_specific_claims( + client_id, + adapter + ) content = { - 'access_token': JwtBuilder(user).build_token(scopes, expires_in), + 'access_token': JwtBuilder( + user, + secret=secret, + issuer=issuer, + ).build_token( + scopes, + expires_in, + aud=audience, + additional_claims={ + 'filters': filters, + 'is_restricted': is_client_restricted, + }, + ), 'expires_in': expires_in, 'token_type': 'JWT', 'scope': ' '.join(scopes), @@ -113,17 +132,37 @@ class AccessTokenView(RatelimitMixin, _DispatchingView): return response - def _decompose_access_token_response(self, request, response): + def _decompose_access_token_response(self, adapter, response): """ Decomposes the access token in the request to an expiration date, scopes, and User. """ content = json.loads(response.content) access_token = content['access_token'] scope = content['scope'] - access_token_obj = self.get_adapter(request).get_access_token(access_token) - user = access_token_obj.user scopes = scope.split(' ') + user = adapter.get_access_token(access_token).user expires_in = content['expires_in'] return expires_in, scopes, user + def _get_client_specific_claims(self, client_id, adapter): + """ Get claims that are specific to the client. """ + # If JWT scope enforcement is enabled, we need to sign tokens + # given to restricted application with a separate secret which + # other IDAs do not have access to. This prevents restricted + # applications from getting access to API endpoints available + # on other IDAs which have not yet been protected with the + # scope-related DRF permission classes. Once all endpoints have + # been protected we can remove this if/else and go back to using + # a single secret. + # TODO: ARCH-162 + is_client_restricted = adapter.is_client_restricted(client_id) + if ENFORCE_JWT_SCOPES.is_enabled() and is_client_restricted: + issuer_setting = 'RESTRICTED_APPLICATION_JWT_ISSUER' + else: + issuer_setting = 'DEFAULT_JWT_ISSUER' + + jwt_issuer = getattr(settings, issuer_setting) + filters = adapter.get_authorization_filters(client_id) + return jwt_issuer['ISSUER'], jwt_issuer['SECRET_KEY'], jwt_issuer['AUDIENCE'], filters, is_client_restricted + class AuthorizationView(_DispatchingView): """ diff --git a/openedx/core/lib/tests/test_token_utils.py b/openedx/core/lib/tests/test_token_utils.py index 59ba8cdab6434e8cea117aa21af19d4aa58bc7c0..10ed16a64139c647dc3e2795f3d7a63bff79c591 100644 --- a/openedx/core/lib/tests/test_token_utils.py +++ b/openedx/core/lib/tests/test_token_utils.py @@ -47,14 +47,23 @@ class TestJwtBuilder(mixins.AccessTokenMixin, TestCase): token = JwtBuilder(self.user).build_token(scopes, self.expires_in) self.assert_valid_jwt_access_token(token, self.user, scopes) - def test_override_secret_and_audience(self): + def test_override_secret_and_audience_and_issuer(self): """ - Verify that the signing key and audience can be overridden. + Verify that the signing key, audience, and issuer can be overridden. """ secret = 'avoid-this' audience = 'avoid-this-too' + issuer = 'avoid-this-too' scopes = [] - token = JwtBuilder(self.user, secret=secret).build_token(scopes, self.expires_in, aud=audience) + token = JwtBuilder( + self.user, + secret=secret, + issuer=issuer, + ).build_token( + scopes, + self.expires_in, + aud=audience, + ) - jwt.decode(token, secret, audience=audience) + jwt.decode(token, secret, audience=audience, issuer=issuer) diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index edbd52905e6bd2482b38a33f2343262dabac167c..9780912afc01e7548eebdf3364f0bafff2b0dd70 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -8,7 +8,6 @@ from django.utils.functional import cached_property from jwkest.jwk import KEYS, RSAKey from jwkest.jws import JWS -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from student.models import UserProfile, anonymous_id_for_user @@ -27,13 +26,15 @@ class JwtBuilder(object): Keyword Arguments: asymmetric (Boolean): Whether the JWT should be signed with this app's private key. secret (string): Overrides configured JWT secret (signing) key. Unused if an asymmetric signature is requested. + issuer (string): Overrides configured JWT issuer. """ - def __init__(self, user, asymmetric=False, secret=None): + def __init__(self, user, asymmetric=False, secret=None, issuer=None): self.user = user self.asymmetric = asymmetric self.secret = secret - self.jwt_auth = configuration_helpers.get_value('JWT_AUTH', settings.JWT_AUTH) + self.issuer = issuer + self.jwt_auth = settings.JWT_AUTH def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None): """Returns a JWT access token. @@ -56,9 +57,10 @@ class JwtBuilder(object): 'aud': aud if aud else self.jwt_auth['JWT_AUDIENCE'], 'exp': now + expires_in, 'iat': now, - 'iss': self.jwt_auth['JWT_ISSUER'], + 'iss': self.issuer if self.issuer else self.jwt_auth['JWT_ISSUER'], 'preferred_username': self.user.username, 'scopes': scopes, + 'version': self.jwt_auth['JWT_SUPPORTED_VERSION'], 'sub': anonymous_id_for_user(self.user, None), }