Commit bcb8afb2 by Ahsan Ulhaq

Add jwt auth in LMS

ECOM-3419
parent 8798ee0f
......@@ -726,6 +726,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 ##################
......
......@@ -2003,6 +2003,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 = {}
......
......@@ -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.
......
......@@ -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',
})
......@@ -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"]:
......
......@@ -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.
"""
"""
django admin pages for credentials support models.
Django admin pages for credentials support models.
"""
from django.contrib import admin
......
"""
APIs for the credentials support.
"""
"""
URLs for credential support views.
"""
......@@ -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={
......
# -*- 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'),
),
]
......@@ -9,8 +9,9 @@ from django.contrib.auth.models import User
def add_service_user(apps, schema_editor):
"""Add service user."""
user, created = User.objects.get_or_create(username=settings.CREDENTIALS_SERVICE_USERNAME, is_staff=True)
user, created = User.objects.get_or_create(username=settings.CREDENTIALS_SERVICE_USERNAME)
if created:
user.is_staff = True
user.set_unusable_password()
user.save()
......@@ -26,7 +27,7 @@ def remove_service_user(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('credentials', '0002_credentialsapiconfig_cache_ttl'),
('credentials', '0001_initial'),
]
operations = [
......
"""
Celery tasks for credentials support views.
"""
......@@ -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)
......
"""
URLs for the credentials support in LMS and Studio.
"""
"""
Organization API.
WARNING: This API is intended to move into the 'edx-organizations' repo.
https://github.com/edx/edx-organizations
"""
"""
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)
"""
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)
"""
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<organization_key>((?![\^'\!\(\)\*\s]).)*)"
urlpatterns = patterns(
'',
url(
r'^v0/organization/{}/$'.format(ORGANIZATION_KEY_PATTERN),
views.OrganizationsView.as_view(),
name='get_organization'
),
)
......@@ -56,7 +56,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',
......@@ -123,7 +122,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',
......
......@@ -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)
......
......@@ -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:
......
......@@ -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,
......@@ -17,7 +17,6 @@ from openedx.core.lib.api.authentication import (
from ..errors import UserNotFound, UserNotAuthorized, AccountUpdateError, AccountValidationError
from openedx.core.lib.api.parsers import MergePatchParser
from .api import get_account_settings, update_account_settings
from .serializers import PROFILE_IMAGE_KEY_PREFIX
class AccountView(APIView):
......@@ -138,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,)
......
"""
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.
......
"""
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
......@@ -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
......
"""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,21 +69,30 @@ 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
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)
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
......
......@@ -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-rest-api-client==1.2.1
edx-search==0.1.1
facebook-sdk==0.4.0
......@@ -59,7 +60,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
......@@ -152,6 +153,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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment