Commit aeb4265f by Clinton Blackburn

Merge pull request #17 from edx/clintonb/jwt

Added JWT Authentication
parents d0951bed 3cf140f8
"""
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
logger = logging.getLogger(__name__)
def decode(token):
"""
Ensure InvalidTokenErrors are logged for diagnostic purposes, before
failing authentication.
Args:
token (str): JSON web token (JWT) to be decoded.
"""
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:
logger.exception('JWT decode failed!')
raise
# pylint: disable=redefined-builtin # pylint: disable=redefined-builtin
import json import json
import urllib import urllib
from time import time
import ddt import ddt
import jwt
from django.conf import settings
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.test import APITestCase, APIRequestFactory from rest_framework.test import APITestCase, APIRequestFactory
...@@ -59,6 +62,23 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas ...@@ -59,6 +62,23 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas
self.course = CourseFactory(id='a/b/c', name='ABC Test Course') self.course = CourseFactory(id='a/b/c', name='ABC Test Course')
self.refresh_index() self.refresh_index()
def generate_jwt_token_header(self, user):
"""Generate a valid JWT token header for authenticated requests."""
now = int(time())
ttl = 5
payload = {
'iss': settings.JWT_AUTH['JWT_ISSUER'],
'aud': settings.JWT_AUTH['JWT_AUDIENCE'],
'username': user.username,
'email': user.email,
'iat': now,
'exp': now + ttl
}
token = jwt.encode(payload, settings.JWT_AUTH['JWT_SECRET_KEY']).decode('utf-8')
return 'JWT {token}'.format(token=token)
def test_create_without_authentication(self): def test_create_without_authentication(self):
""" Verify authentication is required when creating, updating, or deleting a catalog. """ """ Verify authentication is required when creating, updating, or deleting a catalog. """
self.client.logout() self.client.logout()
...@@ -77,8 +97,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas ...@@ -77,8 +97,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas
response = getattr(self.client, http_method)(url, {}, format='json') response = getattr(self.client, http_method)(url, {}, format='json')
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_create(self): def assert_catalog_created(self, **headers):
""" Verify the endpoint creates a new catalog. """
name = 'The Kitchen Sink' name = 'The Kitchen Sink'
query = '*.*' query = '*.*'
data = { data = {
...@@ -86,7 +105,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas ...@@ -86,7 +105,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas
'query': query 'query': query
} }
response = self.client.post(reverse('api:v1:catalog-list'), data, format='json') response = self.client.post(reverse('api:v1:catalog-list'), data, format='json', **headers)
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
catalog = Catalog.objects.latest() catalog = Catalog.objects.latest()
...@@ -94,6 +113,15 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas ...@@ -94,6 +113,15 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas
self.assertEqual(catalog.name, name) self.assertEqual(catalog.name, name)
self.assertEqual(catalog.query, query) self.assertEqual(catalog.query, query)
def test_create_with_session_authentication(self):
""" Verify the endpoint creates a new catalog when the client is authenticated via session authentication. """
self.assert_catalog_created()
def test_create_with_jwt_authentication(self):
""" Verify the endpoint creates a new catalog when the client is authenticated via JWT authentication. """
self.client.logout()
self.assert_catalog_created(HTTP_AUTHORIZATION=self.generate_jwt_token_header(self.user))
def test_courses(self): def test_courses(self):
""" Verify the endpoint returns the list of courses contained in the catalog. """ """ Verify the endpoint returns the list of courses contained in the catalog. """
url = reverse('api:v1:catalog-courses', kwargs={'id': self.catalog.id}) url = reverse('api:v1:catalog-courses', kwargs={'id': self.catalog.id})
......
...@@ -6,6 +6,7 @@ from rest_framework.authentication import SessionAuthentication ...@@ -6,6 +6,7 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import detail_route from rest_framework.decorators import detail_route
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly, IsAuthenticatedOrReadOnly from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly, IsAuthenticatedOrReadOnly
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework_jwt.authentication import JSONWebTokenAuthentication
from course_discovery.apps.api.pagination import ElasticsearchLimitOffsetPagination from course_discovery.apps.api.pagination import ElasticsearchLimitOffsetPagination
from course_discovery.apps.api.serializers import CatalogSerializer, CourseSerializer, ContainedCoursesSerializer from course_discovery.apps.api.serializers import CatalogSerializer, CourseSerializer, ContainedCoursesSerializer
...@@ -20,8 +21,7 @@ logger = logging.getLogger(__name__) ...@@ -20,8 +21,7 @@ logger = logging.getLogger(__name__)
class CatalogViewSet(viewsets.ModelViewSet): class CatalogViewSet(viewsets.ModelViewSet):
""" Catalog resource. """ """ Catalog resource. """
# TODO Add support for JWT authentication_classes = (SessionAuthentication, JSONWebTokenAuthentication,)
authentication_classes = (SessionAuthentication,)
permission_classes = (DjangoModelPermissionsOrAnonReadOnly,) permission_classes = (DjangoModelPermissionsOrAnonReadOnly,)
lookup_field = 'id' lookup_field = 'id'
queryset = Catalog.objects.all() queryset = Catalog.objects.all()
...@@ -95,7 +95,7 @@ class CatalogViewSet(viewsets.ModelViewSet): ...@@ -95,7 +95,7 @@ class CatalogViewSet(viewsets.ModelViewSet):
class CourseViewSet(viewsets.ReadOnlyModelViewSet): class CourseViewSet(viewsets.ReadOnlyModelViewSet):
""" Course resource. """ """ Course resource. """
authentication_classes = (SessionAuthentication,) authentication_classes = (SessionAuthentication, JSONWebTokenAuthentication,)
lookup_field = 'id' lookup_field = 'id'
lookup_value_regex = COURSE_ID_REGEX lookup_value_regex = COURSE_ID_REGEX
permission_classes = (IsAuthenticatedOrReadOnly,) permission_classes = (IsAuthenticatedOrReadOnly,)
......
...@@ -6,6 +6,7 @@ USER_PASSWORD = 'password' ...@@ -6,6 +6,7 @@ USER_PASSWORD = 'password'
class UserFactory(factory.DjangoModelFactory): class UserFactory(factory.DjangoModelFactory):
username = factory.Sequence(lambda n: 'user_%d' % n)
password = factory.PostGenerationMethodCall('set_password', USER_PASSWORD) password = factory.PostGenerationMethodCall('set_password', USER_PASSWORD)
is_active = True is_active = True
is_superuser = False is_superuser = False
......
...@@ -243,6 +243,15 @@ REST_FRAMEWORK = { ...@@ -243,6 +243,15 @@ REST_FRAMEWORK = {
) )
} }
# NOTE (CCB): JWT_SECRET_KEY is intentionally not set here to avoid production releases with a public value.
# Set a value in a downstream settings file.
JWT_AUTH = {
'JWT_ALGORITHM': 'HS256',
'JWT_AUDIENCE': 'course-discovery',
'JWT_ISSUER': 'course-discovery',
'JWT_DECODE_HANDLER': 'course_discovery.apps.api.jwt_decode_handler.decode',
}
SWAGGER_SETTINGS = { SWAGGER_SETTINGS = {
'api_version': 'v1', 'api_version': 'v1',
'doc_expansion': 'list', 'doc_expansion': 'list',
......
...@@ -55,6 +55,8 @@ SOCIAL_AUTH_EDX_OIDC_ID_TOKEN_DECRYPTION_KEY = SOCIAL_AUTH_EDX_OIDC_SECRET ...@@ -55,6 +55,8 @@ SOCIAL_AUTH_EDX_OIDC_ID_TOKEN_DECRYPTION_KEY = SOCIAL_AUTH_EDX_OIDC_SECRET
ENABLE_AUTO_AUTH = True ENABLE_AUTO_AUTH = True
JWT_AUTH['JWT_SECRET_KEY'] = 'course-discovery-jwt-secret-key'
##################################################################### #####################################################################
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')): if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')):
......
...@@ -34,3 +34,5 @@ ELASTICSEARCH = { ...@@ -34,3 +34,5 @@ ELASTICSEARCH = {
'host': os.environ.get('TEST_ELASTICSEARCH_HOST', 'localhost'), 'host': os.environ.get('TEST_ELASTICSEARCH_HOST', 'localhost'),
'index': 'course_discovery_test', 'index': 'course_discovery_test',
} }
JWT_AUTH['JWT_SECRET_KEY'] = 'course-discovery-jwt-secret-key'
...@@ -2,6 +2,7 @@ django == 1.8.7 ...@@ -2,6 +2,7 @@ django == 1.8.7
django-extensions == 1.5.9 django-extensions == 1.5.9
django-waffle == 0.11 django-waffle == 0.11
djangorestframework == 3.3.1 djangorestframework == 3.3.1
djangorestframework-jwt==1.7.2
django-rest-swagger[reST]==0.3.4 django-rest-swagger[reST]==0.3.4
edx-auth-backends == 0.1.3 edx-auth-backends == 0.1.3
edx-rest-api-client==1.2.1 edx-rest-api-client==1.2.1
......
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