Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion lms/djangoapps/certificates/apis/v0/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
import logging

from edx_rest_framework_extensions.authentication import JwtAuthentication
from edx_rest_framework_extensions.permissions import (
JwtHasContentOrgFilterForRequestedCourse,
JwtHasScope,
)
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.generics import GenericAPIView
Expand Down Expand Up @@ -76,8 +80,11 @@ class CertificatesDetailView(GenericAPIView):
)
permission_classes = (
IsAuthenticated,
permissions.IsUserInUrlOrStaff
JwtHasContentOrgFilterForRequestedCourse,
JwtHasScope,
permissions.IsUserInUrlOrStaff,
)
required_scopes = ['certificates:read']

def get(self, request, username, course_id):
"""
Expand Down
10 changes: 10 additions & 0 deletions lms/djangoapps/grades/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

from django.contrib.auth import get_user_model
from django.http import Http404
from edx_rest_framework_extensions.permissions import (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes need to be included in the v1 of the Grades API - and not part of v0.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, Catch!

JwtHasContentOrgFilterForRequestedCourse,
JwtHasScope,
)
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import status
Expand Down Expand Up @@ -161,6 +165,12 @@ class UserGradeView(GradeViewMixin, GenericAPIView):
}]

"""
permission_classes = GradeViewMixin.permission_classes + (
JwtHasContentOrgFilterForRequestedCourse,
JwtHasScope,
)
required_scopes = ['grades:read']

def get(self, request, course_id):
"""
Gets a course progress status.
Expand Down
50 changes: 38 additions & 12 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,21 +502,28 @@

################################## DJANGO OAUTH TOOLKIT #######################################

OAUTH2_DEFAULT_SCOPES = {
'read': 'Read access',
'write': 'Write access',
'email': 'Know your email address',
# conform profile scope message that is presented to end-user
# to lms/templates/provider/authorize.html. This may be revised later.
'profile': 'Know your name and username',
}

OAUTH2_PROVIDER = {
'OAUTH2_VALIDATOR_CLASS': 'openedx.core.djangoapps.oauth_dispatch.dot_overrides.validators.EdxOAuth2Validator',
'REFRESH_TOKEN_EXPIRE_SECONDS': 20160,
'SCOPES': {
'read': 'Read access',
'write': 'Write access',
'email': 'Know your email address',
# conform profile scope message that is presented to end-user
# to lms/templates/provider/authorize.html. This may be revised later.
'profile': 'Know your name and username',
},
'SCOPES_BACKEND_CLASS': 'openedx.core.djangoapps.oauth_dispatch.scopes.ApplicationModelScopes',
'SCOPES': dict(OAUTH2_DEFAULT_SCOPES, **{
'grades:read': 'Retrieve your grades for your enrolled courses',
'certificates:read': 'Retrieve your course certificates'
}),
'DEFAULT_SCOPES': OAUTH2_DEFAULT_SCOPES,
'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired',
'ERROR_RESPONSE_WITH_SCOPES': True,
}
# This is required for the migrations in oauth_dispatch.models
# otherwise it fails saying this attribute is not present in Settings

OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application'

################################## TEMPLATE CONFIGURATION #####################################
Expand Down Expand Up @@ -2401,19 +2408,38 @@ def _make_locale_paths(settings):
]

# JWT Settings
DEFAULT_JWT_ISSUER_URI = 'change-me'
DEFAULT_JWT_SECRET_KEY = 'change-me'
DEFAULT_JWT_AUDIENCE = 'change-me'
DEFAULT_JWT_ISSUER = {
'ISSUER': DEFAULT_JWT_ISSUER_URI,
'SECRET_KEY': DEFAULT_JWT_SECRET_KEY,
'AUDIENCE': DEFAULT_JWT_AUDIENCE,
}
RESTRICTED_APPLICATION_JWT_ISSUER = {
'ISSUER': 'change-me',
'SECRET_KEY': 'change-me',
'AUDIENCE': None,
}

JWT_AUTH = {
# TODO Set JWT_SECRET_KEY to a secure value. By default, SECRET_KEY will be used.
# 'JWT_SECRET_KEY': '',
'JWT_ALGORITHM': 'HS256',
'JWT_VERIFY_EXPIRATION': True,
# TODO Set JWT_ISSUER and JWT_AUDIENCE to values specific to your service/organization.
'JWT_ISSUER': 'change-me',
'JWT_AUDIENCE': None,
'JWT_ISSUER': DEFAULT_JWT_ISSUER_URI,
'JWT_AUDIENCE': DEFAULT_JWT_AUDIENCE,
'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'),
'JWT_LEEWAY': 1,
'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.utils.jwt_decode_handler',
# Number of seconds before JWT tokens expire
'JWT_EXPIRATION': 30,
'JWT_SUPPORTED_VERSION': '1.0.0',
'JWT_ISSUERS': [
DEFAULT_JWT_ISSUER,
RESTRICTED_APPLICATION_JWT_ISSUER,
],
}

# The footer URLs dictionary maps social footer names
Expand Down
17 changes: 14 additions & 3 deletions lms/envs/devstack_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,21 @@

OAUTH_OIDC_ISSUER = '{}/oauth2'.format(LMS_ROOT_URL)

DEFAULT_JWT_ISSUER_URI = OAUTH_OIDC_ISSUER
DEFAULT_JWT_SECRET_KEY = 'lms-secret'
DEFAULT_JWT_AUDIENCE = 'lms-key'
DEFAULT_JWT_ISSUER = {
'ISSUER': DEFAULT_JWT_ISSUER_URI,
'SECRET_KEY': DEFAULT_JWT_SECRET_KEY,
'AUDIENCE': DEFAULT_JWT_AUDIENCE,
}
JWT_AUTH.update({
'JWT_SECRET_KEY': 'lms-secret',
'JWT_ISSUER': OAUTH_OIDC_ISSUER,
'JWT_AUDIENCE': 'lms-key',
'JWT_ISSUER': DEFAULT_JWT_ISSUER_URI,
'JWT_AUDIENCE': DEFAULT_JWT_AUDIENCE,
'JWT_ISSUERS': [
DEFAULT_JWT_ISSUER,
RESTRICTED_APPLICATION_JWT_ISSUER,
],
})

FEATURES.update({
Expand Down
12 changes: 12 additions & 0 deletions openedx/core/djangoapps/oauth_dispatch/adapters/dop.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,15 @@ def get_token_scope_names(self, token):
Given an access token object, return its scopes.
"""
return scope.to_names(token.scope)

def is_client_restricted(self, client_id): # pylint: disable=unused-argument
"""
Returns true if the client is set up as a RestrictedApplication.
"""
return False

def get_authorization_filters(self, client_id): # pylint: disable=unused-argument
"""
Get the authorization filters for the given client application.
"""
return []
36 changes: 27 additions & 9 deletions openedx/core/djangoapps/oauth_dispatch/adapters/dot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
Adapter to isolate django-oauth-toolkit dependencies
"""

from oauth2_provider import models
from oauth2_provider.models import AccessToken, get_application_model

from openedx.core.djangoapps.oauth_dispatch.models import RestrictedApplication

Application = get_application_model()


class DOTAdapter(object):
Expand All @@ -17,15 +21,15 @@ def create_confidential_client(self,
user,
redirect_uri,
client_id=None,
authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE):
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE):
"""
Create an oauth client application that is confidential.
"""
return models.Application.objects.create(
return Application.objects.create(
name=name,
user=user,
client_id=client_id,
client_type=models.Application.CLIENT_CONFIDENTIAL,
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=authorization_grant_type,
redirect_uris=redirect_uri,
)
Expand All @@ -34,12 +38,12 @@ def create_public_client(self, name, user, redirect_uri, client_id=None):
"""
Create an oauth client application that is public.
"""
return models.Application.objects.create(
return Application.objects.create(
name=name,
user=user,
client_id=client_id,
client_type=models.Application.CLIENT_PUBLIC,
authorization_grant_type=models.Application.GRANT_PASSWORD,
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_PASSWORD,
redirect_uris=redirect_uri,
)

Expand All @@ -49,7 +53,7 @@ def get_client(self, **filters):

Wraps django's queryset.get() method.
"""
return models.Application.objects.get(**filters)
return Application.objects.get(**filters)

def get_client_for_token(self, token):
"""
Expand All @@ -61,7 +65,7 @@ def get_access_token(self, token_string):
"""
Given a token string, return the matching AccessToken object.
"""
return models.AccessToken.objects.get(token=token_string)
return AccessToken.objects.get(token=token_string)

def normalize_scopes(self, scopes):
"""
Expand All @@ -76,3 +80,17 @@ def get_token_scope_names(self, token):
Given an access token object, return its scopes.
"""
return list(token.scopes)

def is_client_restricted(self, client_id):
"""
Returns true if the client is set up as a RestrictedApplication.
"""
application = self.get_client(client_id=client_id)
return RestrictedApplication.objects.filter(application=application).exists()

def get_authorization_filters(self, client_id):
"""
Get the authorization filters for the given client application.
"""
application = self.get_client(client_id=client_id)
return getattr(application, 'authorization_filters', [])
23 changes: 11 additions & 12 deletions openedx/core/djangoapps/oauth_dispatch/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.contrib.admin import ModelAdmin, site
from oauth2_provider import models

from .models import RestrictedApplication
from .models import RestrictedApplication, ScopedApplicationOrganization


def reregister(model_class):
Expand Down Expand Up @@ -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):
"""
Expand All @@ -83,3 +72,13 @@ class RestrictedApplicationAdmin(ModelAdmin):


site.register(RestrictedApplication, RestrictedApplicationAdmin)


class ScopedApplicationOrganizationAdmin(ModelAdmin):
"""
ModelAdmin for the ScopedApplicationOrganization model.
"""
list_display = [u'application', 'short_name', 'provider_type']


site.register(ScopedApplicationOrganization, ScopedApplicationOrganizationAdmin)
17 changes: 11 additions & 6 deletions openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.dispatch import receiver
from oauth2_provider.models import AccessToken
from oauth2_provider.oauth2_validators import OAuth2Validator
from oauth2_provider.scopes import get_scopes_backend
from pytz import utc
from ratelimitbackend.backends import RateLimitMixin

Expand All @@ -25,10 +26,8 @@ def on_access_token_presave(sender, instance, *args, **kwargs): # pylint: disab

We do this as a pre-save hook on the ORM
"""

is_application_restricted = RestrictedApplication.objects.filter(application=instance.application).exists()
if is_application_restricted:
RestrictedApplication.set_access_token_as_expired(instance)
if RestrictedApplication.expire_access_token(instance.application):
instance.expires = datetime(1970, 1, 1, tzinfo=utc)


class EdxRateLimitedAllowAllUsersModelBackend(RateLimitMixin, UserModelBackend):
Expand Down Expand Up @@ -101,8 +100,7 @@ def save_bearer_token(self, token, request, *args, **kwargs):

super(EdxOAuth2Validator, self).save_bearer_token(token, request, *args, **kwargs)

is_application_restricted = RestrictedApplication.objects.filter(application=request.client).exists()
if is_application_restricted:
if RestrictedApplication.expire_access_token(request.client):
# Since RestrictedApplications will override the DOT defined expiry, so that access_tokens
# are always expired, we need to re-read the token from the database and then calculate the
# expires_in (in seconds) from what we stored in the database. This value should be a negative
Expand All @@ -121,3 +119,10 @@ def save_bearer_token(self, token, request, *args, **kwargs):
# Restore the original request attributes
request.grant_type = grant_type
request.user = user

def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs):
"""
Ensure required scopes are permitted (as specified in the settings file)
"""
available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request)
return set(scopes).issubset(set(available_scopes))
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-06-06 13:52
from __future__ import unicode_literals

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_mysql.models
import oauth2_provider.generators
import oauth2_provider.validators


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
('oauth_dispatch', '0001_initial'),
]

operations = [
migrations.CreateModel(
name='ScopedApplication',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)),
('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated', validators=[oauth2_provider.validators.validate_uris])),
('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)),
('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)),
('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)),
('name', models.CharField(blank=True, max_length=255)),
('skip_authorization', models.BooleanField(default=False)),
('scopes', django_mysql.models.ListCharField(models.CharField(max_length=32), help_text='Comma-separated list of scopes that this application will be allowed to request.', max_length=330, size=10)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth_dispatch_scopedapplication', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ScopedApplicationOrganization',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('short_name', models.CharField(help_text='The short_name of an existing Organization.', max_length=255)),
('provider_type', models.CharField(choices=[(b'content_provider', 'Content Provider')], default=b'content_provider', max_length=32)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizations', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
],
),
]
Loading