diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index ad57f093d542..a27f81a8750b 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -2,6 +2,10 @@ import logging from edx_rest_framework_extensions.authentication import JwtAuthentication +from edx_rest_framework_extensions.permissions import ( + JwtHasContentOrgFilterForRequestedCourse, + JwtHasScope, +) from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework.generics import GenericAPIView @@ -76,8 +80,11 @@ class CertificatesDetailView(GenericAPIView): ) permission_classes = ( IsAuthenticated, - permissions.IsUserInUrlOrStaff + JwtHasContentOrgFilterForRequestedCourse, + JwtHasScope, + permissions.IsUserInUrlOrStaff, ) + required_scopes = ['certificates:read'] def get(self, request, username, course_id): """ diff --git a/lms/djangoapps/grades/api/views.py b/lms/djangoapps/grades/api/views.py index a54fe58ac58b..66b75dce7aae 100644 --- a/lms/djangoapps/grades/api/views.py +++ b/lms/djangoapps/grades/api/views.py @@ -3,6 +3,10 @@ from django.contrib.auth import get_user_model from django.http import Http404 +from edx_rest_framework_extensions.permissions import ( + JwtHasContentOrgFilterForRequestedCourse, + JwtHasScope, +) from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import status @@ -161,6 +165,12 @@ class UserGradeView(GradeViewMixin, GenericAPIView): }] """ + permission_classes = GradeViewMixin.permission_classes + ( + JwtHasContentOrgFilterForRequestedCourse, + JwtHasScope, + ) + required_scopes = ['grades:read'] + def get(self, request, course_id): """ Gets a course progress status. diff --git a/lms/envs/common.py b/lms/envs/common.py index 2946023bd082..3d4a6f7ae74f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -502,21 +502,28 @@ ################################## DJANGO OAUTH TOOLKIT ####################################### +OAUTH2_DEFAULT_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', +} + 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 + OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' ################################## TEMPLATE CONFIGURATION ##################################### @@ -2401,19 +2408,38 @@ def _make_locale_paths(settings): ] # JWT Settings +DEFAULT_JWT_ISSUER_URI = 'change-me' +DEFAULT_JWT_SECRET_KEY = 'change-me' +DEFAULT_JWT_AUDIENCE = 'change-me' +DEFAULT_JWT_ISSUER = { + 'ISSUER': DEFAULT_JWT_ISSUER_URI, + 'SECRET_KEY': DEFAULT_JWT_SECRET_KEY, + 'AUDIENCE': DEFAULT_JWT_AUDIENCE, +} +RESTRICTED_APPLICATION_JWT_ISSUER = { + 'ISSUER': 'change-me', + 'SECRET_KEY': 'change-me', + 'AUDIENCE': None, +} + 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_ISSUER': DEFAULT_JWT_ISSUER_URI, + 'JWT_AUDIENCE': DEFAULT_JWT_AUDIENCE, '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.0.0', + 'JWT_ISSUERS': [ + DEFAULT_JWT_ISSUER, + RESTRICTED_APPLICATION_JWT_ISSUER, + ], } # The footer URLs dictionary maps social footer names diff --git a/lms/envs/devstack_docker.py b/lms/envs/devstack_docker.py index 73b838058ffb..3fa1959944a3 100644 --- a/lms/envs/devstack_docker.py +++ b/lms/envs/devstack_docker.py @@ -27,10 +27,21 @@ OAUTH_OIDC_ISSUER = '{}/oauth2'.format(LMS_ROOT_URL) +DEFAULT_JWT_ISSUER_URI = OAUTH_OIDC_ISSUER +DEFAULT_JWT_SECRET_KEY = 'lms-secret' +DEFAULT_JWT_AUDIENCE = 'lms-key' +DEFAULT_JWT_ISSUER = { + 'ISSUER': DEFAULT_JWT_ISSUER_URI, + 'SECRET_KEY': DEFAULT_JWT_SECRET_KEY, + 'AUDIENCE': DEFAULT_JWT_AUDIENCE, +} JWT_AUTH.update({ - 'JWT_SECRET_KEY': 'lms-secret', - 'JWT_ISSUER': OAUTH_OIDC_ISSUER, - 'JWT_AUDIENCE': 'lms-key', + 'JWT_ISSUER': DEFAULT_JWT_ISSUER_URI, + 'JWT_AUDIENCE': DEFAULT_JWT_AUDIENCE, + 'JWT_ISSUERS': [ + DEFAULT_JWT_ISSUER, + RESTRICTED_APPLICATION_JWT_ISSUER, + ], }) FEATURES.update({ diff --git a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py index b12994b96f6c..8223f7610aa6 100644 --- a/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py +++ b/openedx/core/djangoapps/oauth_dispatch/adapters/dop.py @@ -68,3 +68,15 @@ def get_token_scope_names(self, token): 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 e1ac9705dbc3..d3f204424955 100644 --- a/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py +++ b/openedx/core/djangoapps/oauth_dispatch/adapters/dot.py @@ -2,7 +2,11 @@ Adapter to isolate django-oauth-toolkit dependencies """ -from oauth2_provider import models +from oauth2_provider.models import AccessToken, get_application_model + +from openedx.core.djangoapps.oauth_dispatch.models import RestrictedApplication + +Application = get_application_model() class DOTAdapter(object): @@ -17,15 +21,15 @@ def create_confidential_client(self, user, redirect_uri, client_id=None, - authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE): + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE): """ Create an oauth client application that is confidential. """ - return models.Application.objects.create( + return Application.objects.create( name=name, user=user, client_id=client_id, - client_type=models.Application.CLIENT_CONFIDENTIAL, + client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=authorization_grant_type, redirect_uris=redirect_uri, ) @@ -34,12 +38,12 @@ def create_public_client(self, name, user, redirect_uri, client_id=None): """ Create an oauth client application that is public. """ - return models.Application.objects.create( + return Application.objects.create( name=name, user=user, client_id=client_id, - client_type=models.Application.CLIENT_PUBLIC, - authorization_grant_type=models.Application.GRANT_PASSWORD, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_PASSWORD, redirect_uris=redirect_uri, ) @@ -49,7 +53,7 @@ def get_client(self, **filters): Wraps django's queryset.get() method. """ - return models.Application.objects.get(**filters) + return Application.objects.get(**filters) def get_client_for_token(self, token): """ @@ -61,7 +65,7 @@ def get_access_token(self, token_string): """ Given a token string, return the matching AccessToken object. """ - return models.AccessToken.objects.get(token=token_string) + return AccessToken.objects.get(token=token_string) def normalize_scopes(self, scopes): """ @@ -76,3 +80,17 @@ def get_token_scope_names(self, token): 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) + return getattr(application, 'authorization_filters', []) diff --git a/openedx/core/djangoapps/oauth_dispatch/admin.py b/openedx/core/djangoapps/oauth_dispatch/admin.py index 2c01b289d15c..4fec4b9716b8 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, ScopedApplicationOrganization 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): """ @@ -83,3 +72,13 @@ class RestrictedApplicationAdmin(ModelAdmin): site.register(RestrictedApplication, RestrictedApplicationAdmin) + + +class ScopedApplicationOrganizationAdmin(ModelAdmin): + """ + ModelAdmin for the ScopedApplicationOrganization model. + """ + list_display = [u'application', 'short_name', 'provider_type'] + + +site.register(ScopedApplicationOrganization, ScopedApplicationOrganizationAdmin) diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py index a44337a789b8..0f55d076f736 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.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 @@ -25,10 +26,8 @@ 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 RestrictedApplication.expire_access_token(instance.application): + instance.expires = datetime(1970, 1, 1, tzinfo=utc) class EdxRateLimitedAllowAllUsersModelBackend(RateLimitMixin, UserModelBackend): @@ -101,8 +100,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 RestrictedApplication.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 +119,10 @@ 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): + """ + 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/migrations/0002_scopedapplication_scopedapplicationorganization.py b/openedx/core/djangoapps/oauth_dispatch/migrations/0002_scopedapplication_scopedapplicationorganization.py new file mode 100644 index 000000000000..3b1a9fa11387 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/migrations/0002_scopedapplication_scopedapplicationorganization.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-06-06 13:52 +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 +import oauth2_provider.generators +import oauth2_provider.validators + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ('oauth_dispatch', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ScopedApplication', + 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)), + ('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=330, size=10)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth_dispatch_scopedapplication', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ScopedApplicationOrganization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('short_name', models.CharField(help_text='The short_name of an existing Organization.', max_length=255)), + ('provider_type', models.CharField(choices=[(b'content_provider', 'Content Provider')], default=b'content_provider', max_length=32)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizations', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ], + ), + ] diff --git a/openedx/core/djangoapps/oauth_dispatch/migrations/0003_application_data.py b/openedx/core/djangoapps/oauth_dispatch/migrations/0003_application_data.py new file mode 100644 index 000000000000..a8b661e9fb73 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/migrations/0003_application_data.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-06-05 13:19 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations + + +def migrate_application_data(apps, schema_editor): + """ + Migrate existing DOT Application models to new ScopedApplication models. + """ + Application = apps.get_model(settings.OAUTH2_PROVIDER_APPLICATION_MODEL) + ScopedApplication = apps.get_model('oauth_dispatch', 'ScopedApplication') + + for application in Application.objects.all(): + scoped_application = ScopedApplication( + client_id=application.client_id, + user=application.user, + redirect_uris=application.redirect_uris, + client_type=application.client_type, + authorization_grant_type=application.authorization_grant_type, + client_secret=application.client_secret, + name=application.name, + skip_authorization=application.skip_authorization, + ) + scoped_application.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth_dispatch', '0002_scopedapplication_scopedapplicationorganization'), + ] + + operations = [ + migrations.RunPython(migrate_application_data), + ] diff --git a/openedx/core/djangoapps/oauth_dispatch/models.py b/openedx/core/djangoapps/oauth_dispatch/models.py index 6c661e36477e..acb521c2951b 100644 --- a/openedx/core/djangoapps/oauth_dispatch/models.py +++ b/openedx/core/djangoapps/oauth_dispatch/models.py @@ -5,9 +5,15 @@ 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 pytz import utc +from openedx.core.djangoapps.oauth_dispatch.toggles import OAUTH2_SWITCHES, UNEXPIRED_RESTRICTED_APPLICATIONS +from openedx.core.djangoapps.request_cache import get_request_or_stub + class RestrictedApplication(models.Model): """ @@ -20,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 @@ -29,12 +38,11 @@ def __unicode__(self): ) @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 expire_access_token(cls, application): + set_token_expired = not OAUTH2_SWITCHES.is_enabled(UNEXPIRED_RESTRICTED_APPLICATIONS) + 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): @@ -43,3 +51,76 @@ 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 ScopedApplication(AbstractApplication): + """ + Custom Django OAuth Toolkit Application model that enables the definition + of scopes that are authorized for the given Application. + """ + FILTER_USER_ME = 'user:me' + + scopes = ListCharField( + base_field=models.CharField(max_length=32), + size=10, + max_length=(10 * 33), # 10 * 32 character scopes, plus commas + help_text=_('Comma-separated list of scopes that this application will be allowed to request.'), + ) + + def __unicode__(self): + """ + Return a unicode representation of this object. + """ + return u"".format( + name=self.name + ) + + @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): + """ + 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. + + 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')), + ) + + # 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( + max_length=32, + choices=ORGANIZATION_PROVIDER_TYPES, + default=CONTENT_PROVIDER_TYPE, + ) + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, + related_name='organizations', + ) + + def __unicode__(self): + """ + Return a unicode representation of this object. + """ + return u"".format( + application_name=self.application.name, + org=self.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 000000000000..439f4e15a447 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/scopes.py @@ -0,0 +1,20 @@ +""" +Custom Django OAuth Toolkit scopes backends. +""" + +from oauth2_provider.scopes import SettingsScopes + + +class ApplicationModelScopes(SettingsScopes): + """ + Scopes backend that determines available scopes using the ScopedApplication model. + """ + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + """ Returns valid scopes configured for the given application. """ + application_scopes = getattr(application, 'scopes', None) + if application_scopes: + default_scopes = self.get_default_scopes() + all_scopes = self.get_all_scopes().keys() + return set(application_scopes + default_scopes).intersection(all_scopes) + else: + return super(ApplicationModelScopes, self).get_available_scopes(application, request, *args, **kwargs) diff --git a/openedx/core/djangoapps/oauth_dispatch/toggles.py b/openedx/core/djangoapps/oauth_dispatch/toggles.py new file mode 100644 index 000000000000..986c68b19252 --- /dev/null +++ b/openedx/core/djangoapps/oauth_dispatch/toggles.py @@ -0,0 +1,9 @@ +""" +Feature toggle code for oauth_dispatch. +""" + +from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace + +WAFFLE_NAMESPACE = 'oauth2' +OAUTH2_SWITCHES = WaffleSwitchNamespace(name=WAFFLE_NAMESPACE) +UNEXPIRED_RESTRICTED_APPLICATIONS = 'unexpired_restricted_applications' diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py index 8429fb333d1a..6d687fd8e806 100644 --- a/openedx/core/djangoapps/oauth_dispatch/views.py +++ b/openedx/core/djangoapps/oauth_dispatch/views.py @@ -21,6 +21,7 @@ from ratelimit.mixins import RatelimitMixin from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.lib.token_utils import JwtBuilder from . import adapters @@ -42,7 +43,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 @@ -101,10 +102,24 @@ def dispatch(self, request, *args, **kwargs): 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 = 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, + }, + ), 'expires_in': expires_in, 'token_type': 'JWT', 'scope': ' '.join(scopes), @@ -113,17 +128,33 @@ def dispatch(self, request, *args, **kwargs): 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 adapter.is_client_restricted(client_id): + jwt_issuer = configuration_helpers.get_value( + 'RESTRICTED_APPLICATION_JWT_ISSUER', + settings.RESTRICTED_APPLICATION_JWT_ISSUER + ) + issuer = jwt_issuer['ISSUER'] + secret = jwt_issuer['SECRET_KEY'] + audience = jwt_issuer['AUDIENCE'] + else: + issuer = configuration_helpers.get_value('DEFAULT_JWT_ISSUER_URI', settings.DEFAULT_JWT_ISSUER_URI) + secret = configuration_helpers.get_value('DEFAULT_JWT_SECRET_KEY', settings.DEFAULT_JWT_SECRET_KEY) + audience = configuration_helpers.get_value('DEFAULT_JWT_AUDIENCE', settings.DEFAULT_JWT_AUDIENCE) + filters = adapter.get_authorization_filters(client_id) + return issuer, secret, audience, filters + class AuthorizationView(_DispatchingView): """ diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index edbd52905e6b..76a61cb28d11 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -27,12 +27,14 @@ 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.issuer = issuer self.jwt_auth = configuration_helpers.get_value('JWT_AUTH', settings.JWT_AUTH) def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None): @@ -56,9 +58,10 @@ def build_token(self, scopes, expires_in=None, aud=None, additional_claims=None) '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), } diff --git a/requirements/edx/base.in b/requirements/edx/base.in index 9a73deb01524..92d70aa0ff25 100644 --- a/requirements/edx/base.in +++ b/requirements/edx/base.in @@ -46,6 +46,7 @@ django-memcached-hashring django-method-override==0.1.0 django-model-utils==3.0.0 django-mptt>=0.8.6,<0.9 +django-mysql django-oauth-toolkit==0.12.0 django-pyfs django-ratelimit diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9cf6de71590a..159b663568b3 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -84,6 +84,7 @@ django-method-override==0.1.0 django-model-utils==3.0.0 django-mptt==0.8.7 django-multi-email-field==0.5.1 # via edx-enterprise +django-mysql==2.3.0 django-oauth-toolkit==0.12.0 django-object-actions==0.10.0 # via edx-enterprise django-pyfs==2.0 @@ -93,7 +94,7 @@ django-require==1.0.11 django-rest-swagger==2.2.0 django-sekizai==0.10.0 django-ses==0.8.4 -django-simple-history==2.1.0 +django-simple-history==2.1.1 django-splash==0.2.2 django-statici18n==1.4.0 django-storages==1.4.1 @@ -111,11 +112,11 @@ edx-ace==0.1.8 edx-analytics-data-api-client==0.14.4 edx-ccx-keys==0.2.1 edx-celeryutils==0.2.7 -edx-completion==0.1.6 +edx-completion==0.1.7 edx-django-oauth2-provider==1.2.5 edx-django-release-util==0.3.1 edx-django-sites-extensions==2.3.1 -edx-drf-extensions==1.2.5 +edx-drf-extensions==1.3.0 edx-enterprise==0.69.1 edx-i18n-tools==0.4.5 edx-milestones==0.1.13 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 389955e92bc4..784e0a93ee8f 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -44,7 +44,7 @@ git+https://github.com/edx-solutions/xblock-drag-and-drop-v2@v2.1.6#egg=xblock-d git+https://github.com/open-craft/xblock-poll@add89e14558c30f3c8dc7431e5cd6536fff6d941#egg=xblock-poll==1.5.1 git+https://github.com/edx/xblock-utils.git@v1.1.1#egg=xblock-utils==1.1.1 -e common/lib/xmodule -alabaster==0.7.10 # via sphinx +alabaster==0.7.11 # via sphinx amqp==1.4.9 analytics-python==1.1.0 anyjson==0.3.3 @@ -104,6 +104,7 @@ django-method-override==0.1.0 django-model-utils==3.0.0 django-mptt==0.8.7 django-multi-email-field==0.5.1 +django-mysql==2.3.0 django-oauth-toolkit==0.12.0 django-object-actions==0.10.0 django-pyfs==2.0 @@ -113,7 +114,7 @@ django-require==1.0.11 django-rest-swagger==2.2.0 django-sekizai==0.10.0 django-ses==0.8.4 -django-simple-history==2.1.0 +django-simple-history==2.1.1 django-splash==0.2.2 django-statici18n==1.4.0 django-storages==1.4.1 @@ -131,11 +132,11 @@ edx-ace==0.1.8 edx-analytics-data-api-client==0.14.4 edx-ccx-keys==0.2.1 edx-celeryutils==0.2.7 -edx-completion==0.1.6 +edx-completion==0.1.7 edx-django-oauth2-provider==1.2.5 edx-django-release-util==0.3.1 edx-django-sites-extensions==2.3.1 -edx-drf-extensions==1.2.5 +edx-drf-extensions==1.3.0 edx-enterprise==0.69.1 edx-i18n-tools==0.4.5 edx-lint==0.5.5 @@ -156,7 +157,7 @@ event-tracking==0.2.4 execnet==1.5.0 extras==1.0.0 factory_boy==2.8.1 -faker==0.8.15 +faker==0.8.16 feedparser==5.1.3 firebase-token-generator==1.3.2 first==2.0.1 @@ -316,7 +317,7 @@ sqlparse==0.2.4 # via django-debug-toolbar stevedore==1.10.0 sure==1.4.11 sympy==0.7.1 -testfixtures==6.1.0 +testfixtures==6.2.0 testtools==2.3.0 text-unidecode==1.2 tox-battery==0.5.1 diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index d2024b5f306d..af88f813a97f 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -100,6 +100,7 @@ django-method-override==0.1.0 django-model-utils==3.0.0 django-mptt==0.8.7 django-multi-email-field==0.5.1 +django-mysql==2.3.0 django-oauth-toolkit==0.12.0 django-object-actions==0.10.0 django-pyfs==2.0 @@ -109,7 +110,7 @@ django-require==1.0.11 django-rest-swagger==2.2.0 django-sekizai==0.10.0 django-ses==0.8.4 -django-simple-history==2.1.0 +django-simple-history==2.1.1 django-splash==0.2.2 django-statici18n==1.4.0 django-storages==1.4.1 @@ -126,11 +127,11 @@ edx-ace==0.1.8 edx-analytics-data-api-client==0.14.4 edx-ccx-keys==0.2.1 edx-celeryutils==0.2.7 -edx-completion==0.1.6 +edx-completion==0.1.7 edx-django-oauth2-provider==1.2.5 edx-django-release-util==0.3.1 edx-django-sites-extensions==2.3.1 -edx-drf-extensions==1.2.5 +edx-drf-extensions==1.3.0 edx-enterprise==0.69.1 edx-i18n-tools==0.4.5 edx-lint==0.5.5 @@ -150,7 +151,7 @@ event-tracking==0.2.4 execnet==1.5.0 # via pytest-xdist extras==1.0.0 # via python-subunit, testtools factory_boy==2.8.1 -faker==0.8.15 # via factory-boy +faker==0.8.16 # via factory-boy feedparser==5.1.3 firebase-token-generator==1.3.2 fixtures==3.0.0 # via testtools @@ -300,7 +301,7 @@ splinter==0.8.0 stevedore==1.10.0 sure==1.4.11 sympy==0.7.1 -testfixtures==6.1.0 +testfixtures==6.2.0 testtools==2.3.0 # via fixtures, python-subunit text-unidecode==1.2 # via faker tox-battery==0.5.1