From 9220420925978453edcc809531a6a6b09665be84 Mon Sep 17 00:00:00 2001 From: Ahsan Ulhaq Date: Wed, 13 Jan 2016 16:26:54 +0500 Subject: [PATCH] Add jwt auth in LMS ECOM-3419 --- lms/envs/aws.py | 1 + lms/envs/common.py | 12 ++ lms/envs/devstack.py | 7 + lms/envs/test.py | 6 + lms/urls.py | 2 - .../core/djangoapps/credentials/__init__.py | 2 +- openedx/core/djangoapps/credentials/admin.py | 2 +- .../djangoapps/credentials/api/__init__.py | 3 - .../core/djangoapps/credentials/api/urls.py | 3 - .../djangoapps/credentials/api/v1/__init__.py | 0 .../credentials/api/v1/tests/__init__.py | 0 .../djangoapps/credentials/api/v1/views.py | 3 - .../credentials/migrations/0001_initial.py | 1 + .../0002_credentialsapiconfig_cache_ttl.py | 19 --- openedx/core/djangoapps/credentials/tasks.py | 3 - .../credentials/tests/test_utils.py | 2 + openedx/core/djangoapps/credentials/urls.py | 3 - .../djangoapps/organization_api/__init__.py | 6 - .../organization_api/api/__init__.py | 0 .../djangoapps/organization_api/api/views.py | 38 ------ .../organization_api/tests/__init__.py | 0 .../organization_api/tests/test_views.py | 126 ------------------ .../core/djangoapps/organization_api/urls.py | 19 --- .../core/djangoapps/programs/tests/mixins.py | 2 - .../djangoapps/programs/tests/test_models.py | 6 +- .../djangoapps/programs/tests/test_utils.py | 2 + openedx/core/djangoapps/programs/utils.py | 3 +- .../djangoapps/user_api/accounts/views.py | 10 +- openedx/core/lib/api/authentication.py | 12 +- openedx/core/lib/api/jwt_decode_handler.py | 43 ++++++ .../core/lib/api/tests/test_authentication.py | 3 +- openedx/core/lib/tests/test_edx_api_utils.py | 30 +++-- requirements/edx/base.txt | 4 +- 33 files changed, 120 insertions(+), 253 deletions(-) delete mode 100644 openedx/core/djangoapps/credentials/api/__init__.py delete mode 100644 openedx/core/djangoapps/credentials/api/urls.py delete mode 100644 openedx/core/djangoapps/credentials/api/v1/__init__.py delete mode 100644 openedx/core/djangoapps/credentials/api/v1/tests/__init__.py delete mode 100644 openedx/core/djangoapps/credentials/api/v1/views.py delete mode 100644 openedx/core/djangoapps/credentials/migrations/0002_credentialsapiconfig_cache_ttl.py delete mode 100644 openedx/core/djangoapps/credentials/tasks.py delete mode 100644 openedx/core/djangoapps/credentials/urls.py delete mode 100644 openedx/core/djangoapps/organization_api/__init__.py delete mode 100644 openedx/core/djangoapps/organization_api/api/__init__.py delete mode 100644 openedx/core/djangoapps/organization_api/api/views.py delete mode 100644 openedx/core/djangoapps/organization_api/tests/__init__.py delete mode 100644 openedx/core/djangoapps/organization_api/tests/test_views.py delete mode 100644 openedx/core/djangoapps/organization_api/urls.py create mode 100644 openedx/core/lib/api/jwt_decode_handler.py diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 793a6e9434..f8dac1dd51 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -735,6 +735,7 @@ CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_U #### JWT configuration #### JWT_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER) JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION) +JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {})) ################# PROCTORING CONFIGURATION ################## diff --git a/lms/envs/common.py b/lms/envs/common.py index ce953101a0..e49faa4880 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2009,6 +2009,18 @@ SOCIAL_MEDIA_FOOTER_NAMES = [ "reddit", ] +# JWT Settings +JWT_AUTH = { + 'JWT_SECRET_KEY': None, + 'JWT_ALGORITHM': 'HS256', + 'JWT_VERIFY_EXPIRATION': True, + 'JWT_ISSUER': None, + 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'), + 'JWT_AUDIENCE': None, + 'JWT_LEEWAY': 1, + 'JWT_DECODE_HANDLER': 'openedx.core.lib.api.jwt_decode_handler.decode', +} + # The footer URLs dictionary maps social footer names # to URLs defined in configuration. SOCIAL_MEDIA_FOOTER_URLS = {} diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 95a8572a67..3445bf21a5 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -224,6 +224,13 @@ CORS_ALLOW_CREDENTIALS = True CORS_ORIGIN_WHITELIST = () CORS_ORIGIN_ALLOW_ALL = True +# JWT settings for devstack +JWT_AUTH.update({ + 'JWT_ALGORITHM': 'HS256', + 'JWT_SECRET_KEY': 'lms-secret', + 'JWT_ISSUER': 'http://127.0.0.1:8000/oauth2', + 'JWT_AUDIENCE': 'lms-key', +}) ##################################################################### # See if the developer has any local overrides. diff --git a/lms/envs/test.py b/lms/envs/test.py index e036dd2565..2a5e025107 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -563,3 +563,9 @@ 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', +}) diff --git a/lms/urls.py b/lms/urls.py index 81369e6650..198f00c502 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -102,8 +102,6 @@ urlpatterns = ( url(r'^api/commerce/', include('commerce.api.urls', namespace='commerce_api')), url(r'^api/credit/', include('openedx.core.djangoapps.credit.urls', app_name="credit", namespace='credit')), url(r'^rss_proxy/', include('rss_proxy.urls', namespace='rss_proxy')), - url(r'^api/organizations/', include( - 'openedx.core.djangoapps.organization_api.urls', app_name='organization_api', namespace='organization_api')), ) if settings.FEATURES["ENABLE_COMBINED_LOGIN_REGISTRATION"]: diff --git a/openedx/core/djangoapps/credentials/__init__.py b/openedx/core/djangoapps/credentials/__init__.py index d270c842b3..b7c6418ae6 100644 --- a/openedx/core/djangoapps/credentials/__init__.py +++ b/openedx/core/djangoapps/credentials/__init__.py @@ -2,5 +2,5 @@ edX Platform support for credentials. This package will be used as a wrapper for interacting with the credentials -service to provide support for learners and authors to use features involved. +service. """ diff --git a/openedx/core/djangoapps/credentials/admin.py b/openedx/core/djangoapps/credentials/admin.py index 114ff00bbc..6d94abb06e 100644 --- a/openedx/core/djangoapps/credentials/admin.py +++ b/openedx/core/djangoapps/credentials/admin.py @@ -1,5 +1,5 @@ """ -django admin pages for credentials support models. +Django admin pages for credentials support models. """ from django.contrib import admin diff --git a/openedx/core/djangoapps/credentials/api/__init__.py b/openedx/core/djangoapps/credentials/api/__init__.py deleted file mode 100644 index b0bd9acb8f..0000000000 --- a/openedx/core/djangoapps/credentials/api/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -APIs for the credentials support. -""" diff --git a/openedx/core/djangoapps/credentials/api/urls.py b/openedx/core/djangoapps/credentials/api/urls.py deleted file mode 100644 index 07b6ce31b3..0000000000 --- a/openedx/core/djangoapps/credentials/api/urls.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -URLs for credential support views. -""" diff --git a/openedx/core/djangoapps/credentials/api/v1/__init__.py b/openedx/core/djangoapps/credentials/api/v1/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openedx/core/djangoapps/credentials/api/v1/tests/__init__.py b/openedx/core/djangoapps/credentials/api/v1/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openedx/core/djangoapps/credentials/api/v1/views.py b/openedx/core/djangoapps/credentials/api/v1/views.py deleted file mode 100644 index 2aeb708516..0000000000 --- a/openedx/core/djangoapps/credentials/api/v1/views.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Credentials API views (v1). -""" diff --git a/openedx/core/djangoapps/credentials/migrations/0001_initial.py b/openedx/core/djangoapps/credentials/migrations/0001_initial.py index d249c4f649..02ed45cb05 100644 --- a/openedx/core/djangoapps/credentials/migrations/0001_initial.py +++ b/openedx/core/djangoapps/credentials/migrations/0001_initial.py @@ -23,6 +23,7 @@ class Migration(migrations.Migration): ('public_service_url', models.URLField(verbose_name='Public Service URL')), ('enable_learner_issuance', models.BooleanField(default=False, help_text='Enable issuance of credentials via Credential Service.', verbose_name='Enable Learner Issuance')), ('enable_studio_authoring', models.BooleanField(default=False, help_text='Enable authoring of Credential Service credentials in Studio.', verbose_name='Enable Authoring of Credential in Studio')), + ('cache_ttl', models.PositiveIntegerField(default=0, help_text='Specified in seconds. Enable caching by setting this to a value greater than 0.', verbose_name='Cache Time To Live')), ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), ], options={ diff --git a/openedx/core/djangoapps/credentials/migrations/0002_credentialsapiconfig_cache_ttl.py b/openedx/core/djangoapps/credentials/migrations/0002_credentialsapiconfig_cache_ttl.py deleted file mode 100644 index 42c7ffaba7..0000000000 --- a/openedx/core/djangoapps/credentials/migrations/0002_credentialsapiconfig_cache_ttl.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('credentials', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='credentialsapiconfig', - name='cache_ttl', - field=models.PositiveIntegerField(default=0, help_text='Specified in seconds. Enable caching by setting this to a value greater than 0.', verbose_name='Cache Time To Live'), - ), - ] diff --git a/openedx/core/djangoapps/credentials/tasks.py b/openedx/core/djangoapps/credentials/tasks.py deleted file mode 100644 index ab97fdd25b..0000000000 --- a/openedx/core/djangoapps/credentials/tasks.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Celery tasks for credentials support views. -""" diff --git a/openedx/core/djangoapps/credentials/tests/test_utils.py b/openedx/core/djangoapps/credentials/tests/test_utils.py index 47f7ea4d74..544167100a 100644 --- a/openedx/core/djangoapps/credentials/tests/test_utils.py +++ b/openedx/core/djangoapps/credentials/tests/test_utils.py @@ -89,6 +89,8 @@ class TestCredentialsRetrieval(ProgramsApiConfigMixin, CredentialsApiConfigMixin actual = get_user_program_credentials(self.user) expected = self.PROGRAMS_API_RESPONSE['results'] + expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url'] + expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url'] # checking response from API is as expected self.assertEqual(len(actual), 2) diff --git a/openedx/core/djangoapps/credentials/urls.py b/openedx/core/djangoapps/credentials/urls.py deleted file mode 100644 index f3257a0a55..0000000000 --- a/openedx/core/djangoapps/credentials/urls.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -URLs for the credentials support in LMS and Studio. -""" diff --git a/openedx/core/djangoapps/organization_api/__init__.py b/openedx/core/djangoapps/organization_api/__init__.py deleted file mode 100644 index 5f191e48ff..0000000000 --- a/openedx/core/djangoapps/organization_api/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Organization API. - -WARNING: This API is intended to move into the 'edx-organizations' repo. -https://github.com/edx/edx-organizations -""" diff --git a/openedx/core/djangoapps/organization_api/api/__init__.py b/openedx/core/djangoapps/organization_api/api/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openedx/core/djangoapps/organization_api/api/views.py b/openedx/core/djangoapps/organization_api/api/views.py deleted file mode 100644 index 56759c3386..0000000000 --- a/openedx/core/djangoapps/organization_api/api/views.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Organizations API views. -""" -from rest_framework import permissions -from rest_framework.authentication import SessionAuthentication -from rest_framework.response import Response -from rest_framework.status import HTTP_404_NOT_FOUND -from rest_framework.views import APIView -from rest_framework_oauth.authentication import OAuth2Authentication - -from util.organizations_helpers import get_organization_by_short_name - - -class OrganizationsView(APIView): - """ - View to get organization information. - """ - authentication_classes = (OAuth2Authentication, SessionAuthentication,) - permission_classes = (permissions.IsAuthenticated,) - - def get(self, request, organization_key): - """ - Return organization information related to provided organization - key/short_name. - """ - organization = get_organization_by_short_name(organization_key) - if organization: - logo = organization.get('logo') - organization_data = { - 'name': organization.get('name', ''), - 'short_name': organization.get('short_name', ''), - 'description': organization.get('description', ''), - 'logo': request.build_absolute_uri(logo.url) if logo else '' - } - - return Response(organization_data) - - return Response(status=HTTP_404_NOT_FOUND) diff --git a/openedx/core/djangoapps/organization_api/tests/__init__.py b/openedx/core/djangoapps/organization_api/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openedx/core/djangoapps/organization_api/tests/test_views.py b/openedx/core/djangoapps/organization_api/tests/test_views.py deleted file mode 100644 index aad4e438b9..0000000000 --- a/openedx/core/djangoapps/organization_api/tests/test_views.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Tests for organization API. -""" -import ddt -import json -import unittest - -from django.conf import settings -from django.core.urlresolvers import reverse, NoReverseMatch -from django.test import TestCase -from oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory - -from student.tests.factories import UserFactory -from util import organizations_helpers - - -@ddt.ddt -@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class OrganizationsAPITests(TestCase): - """ - Tests for the organizations API endpoints. - - GET /api/organizations/v1/organization/:org_key/ - """ - - def setUp(self): - """ - Test setup for the organizations API. - """ - super(OrganizationsAPITests, self).setUp() - self.user_password = 'password' - self.user = UserFactory(password=self.user_password) - - self.test_org_key = 'test_organization' - self.test_org_url = self._generate_org_url(self.test_org_key) - - def _create_test_organization(self, org_key=None): - """ - Helper method to create a test organization with the provide 'org_key' and - returns the url to access it. - """ - if org_key is None: - org_key = self.test_org_key - - test_organization_data = { - 'name': 'Test Organization', - 'short_name': org_key, - 'description': 'Test Organization Description', - 'logo': '/test_logo.png/' - } - organizations_helpers.add_organization(organization_data=test_organization_data) - return self._generate_org_url(org_key) - - def _generate_org_url(self, org_key): - """ - Helper method to generate the url to get organization data for a - specific organization key. - """ - return reverse( - 'organization_api:get_organization', kwargs={'organization_key': org_key} - ) - - def test_authentication_required(self): - """ - Verify that the endpoint requires authentication. - """ - response = self.client.get(self.test_org_url) - self.assertEqual(response.status_code, 401) - - def test_session_auth(self): - """ - Verify that the endpoint supports session authentication. - """ - self.client.login(username=self.user.username, password=self.user_password) - response = self.client.get(self.test_org_url) - # verify that the test org does not exist - self.assertEqual(response.status_code, 404) - - # add a test organization - self._create_test_organization() - - # verify that the organization api return data in correct format - response = self.client.get(self.test_org_url) - self.assertEqual(response.status_code, 200) - expected_output = { - 'name': 'Test Organization', - 'short_name': 'test_organization', - 'description': 'Test Organization Description', - 'logo': 'http://testserver/test_logo.png/' - } - self.assertEqual(json.loads(response.content), expected_output) - - def test_oauth(self): - """ - Verify that the organization API supports OAuth. - """ - oauth_client = ClientFactory.create() - access_token = AccessTokenFactory.create(user=self.user, client=oauth_client).token - headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token - } - response = self.client.get(self.test_org_url, **headers) - # verify that the test org does not exist - self.assertEqual(response.status_code, 404) - - # add a test organization - self._create_test_organization() - - # verify that the organization api return data in correct format - response = self.client.get(self.test_org_url, **headers) - self.assertEqual(response.status_code, 200) - expected_output = { - 'name': 'Test Organization', - 'short_name': 'test_organization', - 'description': 'Test Organization Description', - 'logo': 'http://testserver/test_logo.png/' - } - self.assertEqual(json.loads(response.content), expected_output) - - @ddt.data("test_org's", "test_org*", "test(org)", "!test", "test org") - def test_with_invalid_org_key(self, invalid_org_key): - """ - Verify that organization url does not match for invalid org key. - """ - with self.assertRaises(NoReverseMatch): - self._generate_org_url(invalid_org_key) diff --git a/openedx/core/djangoapps/organization_api/urls.py b/openedx/core/djangoapps/organization_api/urls.py deleted file mode 100644 index e5577cf2f9..0000000000 --- a/openedx/core/djangoapps/organization_api/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -URLs for the organization app. -""" -from django.conf.urls import patterns, url - -from openedx.core.djangoapps.organization_api.api import views - - -ORGANIZATION_KEY_PATTERN = r"(?P((?![\^'\!\(\)\*\s]).)*)" - - -urlpatterns = patterns( - '', - url( - r'^v0/organization/{}/$'.format(ORGANIZATION_KEY_PATTERN), - views.OrganizationsView.as_view(), - name='get_organization' - ), -) diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py index 339029eebf..b78862202e 100644 --- a/openedx/core/djangoapps/programs/tests/mixins.py +++ b/openedx/core/djangoapps/programs/tests/mixins.py @@ -57,7 +57,6 @@ class ProgramsDataMixin(object): 'category': 'xseries', 'status': 'unpublished', 'marketing_slug': '', - 'credential_url': 'http://credentials.edx.org/credentials/dummy-uuid-1/', 'organizations': [ { 'display_name': 'Test Organization A', @@ -124,7 +123,6 @@ class ProgramsDataMixin(object): 'category': 'xseries', 'status': 'unpublished', 'marketing_slug': '', - 'credential_url': 'http://credentials.edx.org/credentials/dummy-uuid-2/', 'organizations': [ { 'display_name': 'Test Organization B', diff --git a/openedx/core/djangoapps/programs/tests/test_models.py b/openedx/core/djangoapps/programs/tests/test_models.py index 65beb61b56..5f0c3f7cfe 100644 --- a/openedx/core/djangoapps/programs/tests/test_models.py +++ b/openedx/core/djangoapps/programs/tests/test_models.py @@ -82,11 +82,11 @@ class TestProgramsApiConfig(ProgramsApiConfigMixin, TestCase): Verify that the property controlling certification-related functionality for Programs behaves as expected. """ - programs_config = self.create_config(enabled=False) + programs_config = self.create_programs_config(enabled=False) self.assertFalse(programs_config.is_certification_enabled) - programs_config = self.create_config(enable_certification=False) + programs_config = self.create_programs_config(enable_certification=False) self.assertFalse(programs_config.is_certification_enabled) - programs_config = self.create_config() + programs_config = self.create_programs_config() self.assertTrue(programs_config.is_certification_enabled) diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 898fb0479f..b37d14a6cd 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -141,6 +141,8 @@ class TestProgramRetrieval(ProgramsApiConfigMixin, ProgramsDataMixin, actual = get_programs_for_credentials(self.user, self.PROGRAMS_CREDENTIALS_DATA) expected = self.PROGRAMS_API_RESPONSE['results'] + expected[0]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[0]['certificate_url'] + expected[1]['credential_url'] = self.PROGRAMS_CREDENTIALS_DATA[1]['certificate_url'] self.assertEqual(len(actual), 2) self.assertEqual(actual, expected) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 910831c012..4a52401dce 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -88,12 +88,11 @@ def get_programs_for_credentials(user, programs_credentials): Returns: list, containing programs dictionaries. """ - ProgramsApiConfig.current() certificate_programs = [] programs = get_programs(user) if not programs: - log.debug('No programs found for the user with ID %d.', user.id) + log.debug('No programs for user %d.', user.id) return certificate_programs for program in programs: diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index d6e5d44b15..0df064340a 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -5,10 +5,10 @@ For more information, see: https://openedx.atlassian.net/wiki/display/TNL/User+API """ from django.db import transaction -from rest_framework.views import APIView +from rest_framework import status, permissions +from rest_framework_jwt.authentication import JSONWebTokenAuthentication from rest_framework.response import Response -from rest_framework import status -from rest_framework import permissions +from rest_framework.views import APIView from openedx.core.lib.api.authentication import ( SessionAuthenticationAllowInactiveUser, @@ -137,7 +137,9 @@ class AccountView(APIView): If the update is successful, updated user account data is returned. """ - authentication_classes = (OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser) + authentication_classes = ( + OAuth2AuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser, JSONWebTokenAuthentication + ) permission_classes = (permissions.IsAuthenticated,) parser_classes = (MergePatchParser,) diff --git a/openedx/core/lib/api/authentication.py b/openedx/core/lib/api/authentication.py index c20616aaf9..8e773e2f84 100644 --- a/openedx/core/lib/api/authentication.py +++ b/openedx/core/lib/api/authentication.py @@ -1,14 +1,15 @@ -""" -Common Authentication Handlers used across projects. -""" +""" Common Authentication Handlers used across projects. """ +import logging from rest_framework.authentication import SessionAuthentication from rest_framework import exceptions as drf_exceptions from rest_framework_oauth.authentication import OAuth2Authentication -from .exceptions import AuthenticationFailed from rest_framework_oauth.compat import oauth2_provider, provider_now +from openedx.core.lib.api.exceptions import AuthenticationFailed + + OAUTH2_TOKEN_ERROR = u'token_error' OAUTH2_TOKEN_ERROR_EXPIRED = u'token_expired' OAUTH2_TOKEN_ERROR_MALFORMED = u'token_malformed' @@ -16,6 +17,9 @@ OAUTH2_TOKEN_ERROR_NONEXISTENT = u'token_nonexistent' OAUTH2_TOKEN_ERROR_NOT_PROVIDED = u'token_not_provided' +log = logging.getLogger(__name__) + + class SessionAuthenticationAllowInactiveUser(SessionAuthentication): """Ensure that the user is logged in, but do not require the account to be active. diff --git a/openedx/core/lib/api/jwt_decode_handler.py b/openedx/core/lib/api/jwt_decode_handler.py new file mode 100644 index 0000000000..dac5fce010 --- /dev/null +++ b/openedx/core/lib/api/jwt_decode_handler.py @@ -0,0 +1,43 @@ +""" +Custom JWT decoding function for django_rest_framework jwt package. + +Adds logging to facilitate debugging of InvalidTokenErrors. Also +requires "exp" and "iat" claims to be present - the base package +doesn't expose settings to enforce this. +""" +import logging + +import jwt +from rest_framework_jwt.settings import api_settings + + +log = logging.getLogger(__name__) + + +def decode(token): + """ + Ensure InvalidTokenErrors are logged for diagnostic purposes, before + failing authentication. + """ + + options = { + 'verify_exp': api_settings.JWT_VERIFY_EXPIRATION, + 'require_exp': True, + 'require_iat': True, + } + + try: + return jwt.decode( + token, + api_settings.JWT_SECRET_KEY, + api_settings.JWT_VERIFY, + options=options, + leeway=api_settings.JWT_LEEWAY, + audience=api_settings.JWT_AUDIENCE, + issuer=api_settings.JWT_ISSUER, + algorithms=[api_settings.JWT_ALGORITHM] + ) + except jwt.InvalidTokenError as exc: + exc_type = u'{}.{}'.format(exc.__class__.__module__, exc.__class__.__name__) + log.exception("raised_invalid_token: exc_type=%r, exc_detail=%r", exc_type, exc.message) + raise diff --git a/openedx/core/lib/api/tests/test_authentication.py b/openedx/core/lib/api/tests/test_authentication.py index b02cef4ea6..63a7aedc84 100644 --- a/openedx/core/lib/api/tests/test_authentication.py +++ b/openedx/core/lib/api/tests/test_authentication.py @@ -10,7 +10,6 @@ import itertools import json import ddt - from django.conf.urls import patterns, url, include from django.contrib.auth.models import User from django.http import HttpResponse @@ -26,8 +25,8 @@ from rest_framework.test import APIRequestFactory, APIClient from rest_framework.views import APIView from provider import scope, constants +from openedx.core.lib.api import authentication -from .. import authentication factory = APIRequestFactory() # pylint: disable=invalid-name diff --git a/openedx/core/lib/tests/test_edx_api_utils.py b/openedx/core/lib/tests/test_edx_api_utils.py index 4aed0f5826..4362c7b70b 100644 --- a/openedx/core/lib/tests/test_edx_api_utils.py +++ b/openedx/core/lib/tests/test_edx_api_utils.py @@ -1,10 +1,12 @@ """Tests covering Api utils.""" + from django.core.cache import cache from django.test import TestCase import httpretty import mock from oauth2_provider.tests.factories import ClientFactory from provider.constants import CONFIDENTIAL +from testfixtures import LogCapture from openedx.core.djangoapps.credentials.models import CredentialsApiConfig from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin @@ -14,6 +16,9 @@ from openedx.core.lib.edx_api_utils import get_edx_api_data from student.tests.factories import UserFactory +LOGGER_NAME = 'openedx.core.lib.edx_api_utils' + + class TestApiDataRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ProgramsApiConfigMixin, ProgramsDataMixin, TestCase): """Test utility for API data retrieval.""" @@ -64,22 +69,31 @@ class TestApiDataRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, Prog @mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__') def test_get_edx_api_data_client_initialization_failure(self, mock_init): - """Verify behavior when API client fails to initialize.""" + """Verify no data is retrieved and exception logged when API client + fails to initialize. + """ program_config = self.create_programs_config() mock_init.side_effect = Exception - actual = get_edx_api_data(program_config, self.user, 'programs') - self.assertEqual(actual, []) - self.assertTrue(mock_init.called) + with LogCapture(LOGGER_NAME) as logger: + actual = get_edx_api_data(program_config, self.user, 'programs') + logger.check( + (LOGGER_NAME, 'ERROR', u'Failed to initialize the programs API client.') + ) + self.assertEqual(actual, []) + self.assertTrue(mock_init.called) @httpretty.activate def test_get_edx_api_data_retrieval_failure(self): - """Verify behavior when data can't be retrieved from API.""" + """Verify exception is logged when data can't be retrieved from API.""" program_config = self.create_programs_config() self.mock_programs_api(status_code=500) - - actual = get_edx_api_data(program_config, self.user, 'programs') - self.assertEqual(actual, []) + with LogCapture(LOGGER_NAME) as logger: + actual = get_edx_api_data(program_config, self.user, 'programs') + logger.check( + (LOGGER_NAME, 'ERROR', u'Failed to retrieve data from the programs API.') + ) + self.assertEqual(actual, []) @httpretty.activate def test_get_edx_api_data_multiple_page(self): diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b4a3ae5a55..4ba13655e6 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -32,6 +32,7 @@ django-method-override==0.1.0 #djangorestframework>=3.1,<3.2 git+https://github.com/edx/django-rest-framework.git@3c72cb5ee5baebc4328947371195eae2077197b0#egg=djangorestframework==3.2.3 django==1.8.7 +djangorestframework-jwt==1.7.2 edx-opaque-keys==0.2.1 edx-organizations==0.3.1 edx-rest-api-client==1.2.1 @@ -62,7 +63,7 @@ polib==1.0.3 pycrypto>=2.6 pygments==2.0.1 pygraphviz==1.1 -PyJWT==1.0.1 +PyJWT==1.4.0 pymongo==2.9.1 pyparsing==2.0.1 python-memcached==1.48 @@ -156,6 +157,7 @@ rednose==0.4.3 selenium==2.42.1 splinter==0.5.4 testtools==0.9.34 +testfixtures==4.5.0 # Used for Segment analytics analytics-python==1.1.0