Merge pull request #18429 from edx/douglashall/oauth_scopes_part2
ARCH-116 OAuth2 Scopes Implementation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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')]),
|
||||
),
|
||||
]
|
||||
@@ -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]))
|
||||
|
||||
23
openedx/core/djangoapps/oauth_dispatch/scopes.py
Normal file
23
openedx/core/djangoapps/oauth_dispatch/scopes.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
20
openedx/core/djangoapps/oauth_dispatch/tests/test_models.py
Normal file
20
openedx/core/djangoapps/oauth_dispatch/tests/test_models.py
Normal 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]))
|
||||
33
openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py
Normal file
33
openedx/core/djangoapps/oauth_dispatch/tests/test_scopes.py
Normal 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),
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
11
openedx/core/djangoapps/oauth_dispatch/toggles.py
Normal file
11
openedx/core/djangoapps/oauth_dispatch/toggles.py
Normal 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)
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user