Merge pull request #18429 from edx/douglashall/oauth_scopes_part2

ARCH-116 OAuth2 Scopes Implementation
This commit is contained in:
Douglas Hall
2018-06-28 11:17:17 -04:00
committed by GitHub
23 changed files with 596 additions and 172 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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({

View File

@@ -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"]

View File

@@ -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>

View File

@@ -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 []

View File

@@ -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

View File

@@ -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,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):
"""
@@ -75,6 +64,31 @@ class DOTGrantAdmin(ModelAdmin):
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', u'skip_authorization']
raw_id_fields = [u'user']
search_fields = [u'name', u'user__username', u'client_id']
class ApplicationAccessAdmin(ModelAdmin):
"""
ModelAdmin for ApplicationAccess
"""
list_display = [u'application', u'scopes']
class ApplicationOrganizationAdmin(ModelAdmin):
"""
ModelAdmin for ApplicationOrganization
"""
list_display = [u'application', u'organization', u'relation_type']
class RestrictedApplicationAdmin(ModelAdmin):
"""
ModelAdmin for the Restricted Application
@@ -82,4 +96,6 @@ class RestrictedApplicationAdmin(ModelAdmin):
list_display = [u'application']
site.register(ApplicationAccess, ApplicationAccessAdmin)
site.register(ApplicationOrganization, ApplicationOrganizationAdmin)
site.register(RestrictedApplication, RestrictedApplicationAdmin)

View File

@@ -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:

View File

@@ -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))

View File

@@ -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']

View File

@@ -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')]),
),
]

View File

@@ -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]))

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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]))

View File

@@ -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),
)

View File

@@ -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)
@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)
# 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))
# 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)
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)
# 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
)
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.

View File

@@ -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)

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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),
}