From 8857509b2dc2c87936c5993bf6543f48f2aa262e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= <rocha@edx.org> Date: Mon, 8 Sep 2014 18:30:22 -0400 Subject: [PATCH] Add edX OAuth2 provider with OpenID Connect support --- lms/djangoapps/oauth2_handler/__init__.py | 1 + lms/djangoapps/oauth2_handler/handlers.py | 174 ++++++++++++++++++++++ lms/djangoapps/oauth2_handler/tests.py | 148 ++++++++++++++++++ lms/envs/aws.py | 5 +- lms/envs/common.py | 30 ++++ lms/envs/dev.py | 3 + lms/envs/test.py | 3 + lms/urls.py | 6 + requirements/edx/github.txt | 2 + 9 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/oauth2_handler/__init__.py create mode 100644 lms/djangoapps/oauth2_handler/handlers.py create mode 100644 lms/djangoapps/oauth2_handler/tests.py diff --git a/lms/djangoapps/oauth2_handler/__init__.py b/lms/djangoapps/oauth2_handler/__init__.py new file mode 100644 index 00000000000..fe4953f6ac5 --- /dev/null +++ b/lms/djangoapps/oauth2_handler/__init__.py @@ -0,0 +1 @@ +from oauth2_handler.handlers import IDTokenHandler, UserInfoHandler diff --git a/lms/djangoapps/oauth2_handler/handlers.py b/lms/djangoapps/oauth2_handler/handlers.py new file mode 100644 index 00000000000..32a38d29bc9 --- /dev/null +++ b/lms/djangoapps/oauth2_handler/handlers.py @@ -0,0 +1,174 @@ +""" Handlers for OpenID Connect provider. """ + +import branding +from courseware.access import has_access +from student.models import anonymous_id_for_user +from user_api.models import UserPreference +from lang_pref import LANGUAGE_KEY + + +class OpenIDHandler(object): + """ Basic OpenID Connect scope handler. """ + + def scope_openid(self, _data): + """ Only override the sub (subject) claim. """ + return ['sub'] + + def claim_sub(self, data): + """ + Return the value of the sub (subject) claim. The value should be + unique for each user. + + """ + + # Use the anonymous ID without any course as unique identifier. + # Note that this ID is derived using the value of the `SECRET_KEY` + # setting, this means that users will have different sub + # values for different deployments. + value = anonymous_id_for_user(data['user'], None) + return value + + +class ProfileHandler(object): + """ Basic OpenID Connect `profile` scope handler with `locale` claim. """ + + def scope_profile(self, _data): + """ Add the locale claim. """ + return ['locale'] + + def claim_locale(self, data): + """ + Return the locale for the users based on their preferences. + Does not return a value if the users have not set their locale preferences. + + """ + + language = UserPreference.get_preference(data['user'], LANGUAGE_KEY) + return language + + +class CourseAccessHandler(object): + """ + Defines two new scopes: `course_instructor` and `course_staff`. Each one is + valid only if the user is instructor or staff of at least one course. + + Each new scope has a corresponding claim: `instructor_courses` and + `staff_courses` that lists the course_ids for which the user as instructor + or staff privileges. + + The claims support claim request values. In other words, if no claim is + requested it returns all the courses for the corresponding privileges. If a + claim request is used, then it only returns the from the list of requested + values that have the corresponding privileges. + + For example, if the user is staff of course_a and course_b but not + course_c, the request: + + scope = openid course_staff + + will return: + + {staff_courses: [course_a, course_b] } + + If the request is: + + claims = {userinfo: {staff_courses=[course_b, course_d]}} + + the result will be: + + {staff_courses: [course_b] }. + + This is useful to quickly determine if a user has the right + privileges for a given course. + + For a description of the function naming and arguments, see: + + `oauth2_provider/oidc/handlers.py` + + """ + + def scope_course_instructor(self, data): + """ + Scope `course_instructor` valid only if the user is an instructor + of at least one course. + + """ + + course_ids = self._courses_with_access_type(data, 'instructor') + return ['instructor_courses'] if course_ids else None + + def scope_course_staff(self, data): + """ + Scope `course_staff` valid only if the user is an instructor of at + least one course. + + """ + + course_ids = self._courses_with_access_type(data, 'staff') + return ['staff_courses'] if course_ids else None + + def claim_instructor_courses(self, data): + """ + Claim `instructor_courses` with list of course_ids for which the + user has instructor privileges. + + """ + return self._courses_with_access_type(data, 'instructor') + + def claim_staff_courses(self, data): + """ + Claim `staff_courses` with list of course_ids for which the user + has staff privileges. + + """ + return self._courses_with_access_type(data, 'staff') + + def _courses_with_access_type(self, data, access_type): + """ + Utility function to list all courses for a user according to the + access type. + + The field `data` follows the handler specification in: + + `oauth2_provider/oidc/handlers.py` + + """ + + user = data['user'] + values = set(data.get('values', [])) + + courses = branding.get_visible_courses() + courses = (c for c in courses if has_access(user, access_type, c)) + course_ids = (unicode(c.id) for c in courses) + + # If values was provided, return only the requested authorized courses + if values: + return [c for c in course_ids if c in values] + else: + return [c for c in course_ids] + + +class IDTokenHandler(OpenIDHandler, ProfileHandler, CourseAccessHandler): + """ + Configure the ID Token handler for the LMS. + + Note that the values of the claims `instructor_courses` and + `staff_courses` are not included in the ID Token. The rationale is + that for global staff, the list of courses returned could be very + large. Instead they could check for specific courses using the + UserInfo endpoint. + + """ + + def claim_instructor_courses(self, data): + # Don't return list of courses in ID Tokens + return None + + def claim_staff_courses(self, data): + # Don't return list of courses in ID Tokens + return None + + +class UserInfoHandler(OpenIDHandler, ProfileHandler, CourseAccessHandler): + """ Configure the UserInfo handler for the LMS. """ + pass diff --git a/lms/djangoapps/oauth2_handler/tests.py b/lms/djangoapps/oauth2_handler/tests.py new file mode 100644 index 00000000000..9b19357dcb0 --- /dev/null +++ b/lms/djangoapps/oauth2_handler/tests.py @@ -0,0 +1,148 @@ +# pylint: disable=missing-docstring +from django.test.utils import override_settings +from django.test import TestCase + +from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE +from lang_pref import LANGUAGE_KEY +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from student.models import anonymous_id_for_user +from student.roles import CourseStaffRole, CourseInstructorRole +from student.tests.factories import UserFactory, UserProfileFactory +from user_api.models import UserPreference + +# Will also run default tests for IDTokens and UserInfo +from oauth2_provider.tests import IDTokenTestCase, UserInfoTestCase + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class BaseTestMixin(TestCase): + profile = None + + def setUp(self): + super(BaseTestMixin, self).setUp() + + self.course_key = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall') + self.course_id = unicode(self.course_key) + + self.user_factory = UserFactory + self.set_user(self.make_user()) + + def set_user(self, user): + super(BaseTestMixin, self).set_user(user) + self.profile = UserProfileFactory(user=self.user) + + +class IDTokenTest(BaseTestMixin, IDTokenTestCase): + def test_sub_claim(self): + scopes, claims = self.get_new_id_token_values('openid') + self.assertIn('openid', scopes) + + sub = claims['sub'] + + expected_sub = anonymous_id_for_user(self.user, None) + self.assertEqual(sub, expected_sub) + + def test_user_without_locale_claim(self): + scopes, claims = self.get_new_id_token_values('openid profile') + self.assertIn('profile', scopes) + self.assertNotIn('locale', claims) + + def test_user_wit_locale_claim(self): + language = 'en' + UserPreference.set_preference(self.user, LANGUAGE_KEY, language) + scopes, claims = self.get_new_id_token_values('openid profile') + + self.assertIn('profile', scopes) + + locale = claims['locale'] + self.assertEqual(language, locale) + + def test_no_special_course_access(self): + scopes, claims = self.get_new_id_token_values('openid course_instructor course_staff') + + self.assertNotIn('course_staff', scopes) + self.assertNotIn('staff_courses', claims) + + self.assertNotIn('course_instructor', scopes) + self.assertNotIn('instructor_courses', claims) + + def test_course_staff_courses(self): + CourseStaffRole(self.course_key).add_users(self.user) + + scopes, claims = self.get_new_id_token_values('openid course_staff') + + self.assertIn('course_staff', scopes) + self.assertNotIn('staff_courses', claims) # should not return courses in id_token + + def test_course_instructor_courses(self): + CourseInstructorRole(self.course_key).add_users(self.user) + + scopes, claims = self.get_new_id_token_values('openid course_instructor') + + self.assertIn('course_instructor', scopes) + self.assertNotIn('instructor_courses', claims) # should not return courses in id_token + + +class UserInfoTest(BaseTestMixin, UserInfoTestCase): + def token_for_scope(self, scope): + full_scope = 'openid %s' % scope + self.set_access_token_scope(full_scope) + + token = self.access_token.token # pylint: disable=no-member + return full_scope, token + + def get_with_scope(self, scope): + scope, token = self.token_for_scope(scope) + result, claims = self.get_userinfo(token, scope) + self.assertEqual(result.status_code, 200) + + return claims + + def get_with_claim_value(self, scope, claim, values): + _full_scope, token = self.token_for_scope(scope) + + result, claims = self.get_userinfo( + token, + claims={claim: {'values': values}} + ) + + self.assertEqual(result.status_code, 200) + return claims + + def test_request_staff_courses_using_scope(self): + CourseStaffRole(self.course_key).add_users(self.user) + claims = self.get_with_scope('course_staff') + + courses = claims['staff_courses'] + self.assertIn(self.course_id, courses) + self.assertEqual(len(courses), 1) + + def test_request_instructor_courses_using_scope(self): + CourseInstructorRole(self.course_key).add_users(self.user) + claims = self.get_with_scope('course_instructor') + + courses = claims['instructor_courses'] + self.assertIn(self.course_id, courses) + self.assertEqual(len(courses), 1) + + def test_request_staff_courses_with_claims(self): + CourseStaffRole(self.course_key).add_users(self.user) + + values = [self.course_id, 'some_invalid_course'] + claims = self.get_with_claim_value('course_staff', 'staff_courses', values) + self.assertEqual(len(claims), 2) + + courses = claims['staff_courses'] + self.assertIn(self.course_id, courses) + self.assertEqual(len(courses), 1) + + def test_request_instructor_courses_with_claims(self): + CourseInstructorRole(self.course_key).add_users(self.user) + + values = ['edX/toy/TT_2012_Fall', self.course_id, 'invalid_course_id'] + claims = self.get_with_claim_value('course_instructor', 'instructor_courses', values) + self.assertEqual(len(claims), 2) + + courses = claims['instructor_courses'] + self.assertIn(self.course_id, courses) + self.assertEqual(len(courses), 1) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index a252ad019e1..c430974d332 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -443,10 +443,13 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEAD ##### X-Frame-Options response header settings ##### X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS) - ##### Third-party auth options ################################################ THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH) +##### OAUTH2 Provider ############## +if FEATURES.get('ENABLE_OAUTH2_PROVIDER'): + OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER'] + ##### ADVANCED_SECURITY_CONFIG ##### ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {}) diff --git a/lms/envs/common.py b/lms/envs/common.py index 46feaa19f66..0b210652b39 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -123,6 +123,9 @@ FEATURES = { # with Shib. Feature was requested by Stanford's office of general counsel 'SHIB_DISABLE_TOS': False, + # Toggles OAuth2 authentication provider + 'ENABLE_OAUTH2_PROVIDER': False, + # Can be turned off if course lists need to be hidden. Effects views and templates. 'COURSES_ARE_BROWSABLE': True, @@ -335,6 +338,28 @@ STATUS_MESSAGE_PATH = ENV_ROOT / "status_message.json" ############################ OpenID Provider ################################## OPENID_PROVIDER_TRUSTED_ROOTS = ['cs50.net', '*.cs50.net'] +############################ OAUTH2 Provider ################################### + +# OpenID Connect issuer ID. Normally the URL of the authentication endpoint. + +OAUTH_OIDC_ISSUER = 'https:/example.com/oauth2' + +# OpenID Connect claim handlers + +OAUTH_OIDC_ID_TOKEN_HANDLERS = ( + 'oauth2_provider.oidc.handlers.BasicIDTokenHandler', + 'oauth2_provider.oidc.handlers.ProfileHandler', + 'oauth2_provider.oidc.handlers.EmailHandler', + 'oauth2_handler.IDTokenHandler' +) + +OAUTH_OIDC_USERINFO_HANDLERS = ( + 'oauth2_provider.oidc.handlers.BasicUserInfoHandler', + 'oauth2_provider.oidc.handlers.ProfileHandler', + 'oauth2_provider.oidc.handlers.EmailHandler', + 'oauth2_handler.UserInfoHandler' +) + ################################## EDX WEB ##################################### # This is where we stick our compiled template files. Most of the app uses Mako # templates @@ -1311,6 +1336,11 @@ INSTALLED_APPS = ( 'external_auth', 'django_openid_auth', + # OAuth2 Provider + 'provider', + 'provider.oauth2', + 'oauth2_provider', + # For the wiki 'wiki', # The new django-wiki from benjaoming 'django_notify', diff --git a/lms/envs/dev.py b/lms/envs/dev.py index d57ab3bd5b3..7294d05098f 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -204,6 +204,9 @@ OPENID_USE_AS_ADMIN_LOGIN = False OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] +############################## OAUTH2 Provider ################################ +FEATURES['ENABLE_OAUTH2_PROVIDER'] = True + ######################## MIT Certificates SSL Auth ############################ FEATURES['AUTH_USE_CERTIFICATES'] = False diff --git a/lms/envs/test.py b/lms/envs/test.py index cb493ecb661..a0e52a1d0b6 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -217,6 +217,9 @@ OPENID_UPDATE_DETAILS_FROM_SREG = True OPENID_USE_AS_ADMIN_LOGIN = False OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] +############################## OAUTH2 Provider ################################ +FEATURES['ENABLE_OAUTH2_PROVIDER'] = True + ###################### Payment ##############################3 # Enable fake payment processing page FEATURES['ENABLE_PAYMENT_FAKE'] = True diff --git a/lms/urls.py b/lms/urls.py index abb9896b96d..f8009960d03 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -459,6 +459,12 @@ if settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'): url(r'^openid/provider/xrds/$', 'external_auth.views.provider_xrds', name='openid-provider-xrds') ) +if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'): + urlpatterns += ( + url(r'^oauth2/', include('oauth2_provider.urls', namespace='oauth2')), + ) + + if settings.FEATURES.get('ENABLE_LMS_MIGRATION'): urlpatterns += ( url(r'^migrate/modules$', 'lms_migration.migrate.manage_modulestores'), diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 28f4da62321..8f7f2e472d2 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -10,6 +10,7 @@ -e git+https://github.com/edx/django-staticfiles.git@d89aae2a82f2b#egg=django-staticfiles -e git+https://github.com/edx/django-pipeline.git@88ec8a011e481918fdc9d2682d4017c835acd8be#egg=django-pipeline -e git+https://github.com/edx/django-wiki.git@cd0b2b31997afccde519fe5b3365e61a9edb143f#egg=django-wiki +-e git+https://github.com/edx/django-oauth2-provider.git@0.2.7-fork-edx-1#egg=django-oauth2-provider -e git+https://github.com/gabrielfalcao/lettuce.git@cccc3978ad2df82a78b6f9648fe2e9baddd22f88#egg=lettuce -e git+https://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev -e git+https://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk @@ -31,3 +32,4 @@ -e git+https://github.com/edx/opaque-keys.git@d45d0bd8d64c69531be69178b9505b5d38806ce0#egg=opaque-keys -e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease -e git+https://github.com/edx/i18n-tools.git@56f048af9b6868613c14aeae760548834c495011#egg=i18n-tools +-e git+https://github.com/edx/edx-oauth2-provider.git@0.2.1#egg=oauth2-provider -- GitLab