Commit 3cf140f8 by Clinton Blackburn

Added JWT Authentication

The API now supports authenticating users via JWT.
parent d0951bed
"""
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
import json
import urllib
from time import time
import ddt
import jwt
from django.conf import settings
from rest_framework.reverse import reverse
from rest_framework.test import APITestCase, APIRequestFactory
......@@ -59,6 +62,23 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas
self.course = CourseFactory(id='a/b/c', name='ABC Test Course')
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):
""" Verify authentication is required when creating, updating, or deleting a catalog. """
self.client.logout()
......@@ -77,8 +97,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas
response = getattr(self.client, http_method)(url, {}, format='json')
self.assertEqual(response.status_code, 403)
def test_create(self):
""" Verify the endpoint creates a new catalog. """
def assert_catalog_created(self, **headers):
name = 'The Kitchen Sink'
query = '*.*'
data = {
......@@ -86,7 +105,7 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas
'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)
catalog = Catalog.objects.latest()
......@@ -94,6 +113,15 @@ class CatalogViewSetTests(ElasticsearchTestMixin, SerializationMixin, APITestCas
self.assertEqual(catalog.name, name)
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):
""" Verify the endpoint returns the list of courses contained in the catalog. """
url = reverse('api:v1:catalog-courses', kwargs={'id': self.catalog.id})
......
......@@ -6,6 +6,7 @@ from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import detail_route
from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly, IsAuthenticatedOrReadOnly
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.serializers import CatalogSerializer, CourseSerializer, ContainedCoursesSerializer
......@@ -20,8 +21,7 @@ logger = logging.getLogger(__name__)
class CatalogViewSet(viewsets.ModelViewSet):
""" Catalog resource. """
# TODO Add support for JWT
authentication_classes = (SessionAuthentication,)
authentication_classes = (SessionAuthentication, JSONWebTokenAuthentication,)
permission_classes = (DjangoModelPermissionsOrAnonReadOnly,)
lookup_field = 'id'
queryset = Catalog.objects.all()
......@@ -95,7 +95,7 @@ class CatalogViewSet(viewsets.ModelViewSet):
class CourseViewSet(viewsets.ReadOnlyModelViewSet):
""" Course resource. """
authentication_classes = (SessionAuthentication,)
authentication_classes = (SessionAuthentication, JSONWebTokenAuthentication,)
lookup_field = 'id'
lookup_value_regex = COURSE_ID_REGEX
permission_classes = (IsAuthenticatedOrReadOnly,)
......
......@@ -6,6 +6,7 @@ USER_PASSWORD = 'password'
class UserFactory(factory.DjangoModelFactory):
username = factory.Sequence(lambda n: 'user_%d' % n)
password = factory.PostGenerationMethodCall('set_password', USER_PASSWORD)
is_active = True
is_superuser = False
......
......@@ -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 = {
'api_version': 'v1',
'doc_expansion': 'list',
......
......@@ -55,6 +55,8 @@ SOCIAL_AUTH_EDX_OIDC_ID_TOKEN_DECRYPTION_KEY = SOCIAL_AUTH_EDX_OIDC_SECRET
ENABLE_AUTO_AUTH = True
JWT_AUTH['JWT_SECRET_KEY'] = 'course-discovery-jwt-secret-key'
#####################################################################
# Lastly, see if the developer has any local overrides.
if os.path.isfile(join(dirname(abspath(__file__)), 'private.py')):
......
......@@ -34,3 +34,5 @@ ELASTICSEARCH = {
'host': os.environ.get('TEST_ELASTICSEARCH_HOST', 'localhost'),
'index': 'course_discovery_test',
}
JWT_AUTH['JWT_SECRET_KEY'] = 'course-discovery-jwt-secret-key'
......@@ -2,6 +2,7 @@ django == 1.8.7
django-extensions == 1.5.9
django-waffle == 0.11
djangorestframework == 3.3.1
djangorestframework-jwt==1.7.2
django-rest-swagger[reST]==0.3.4
edx-auth-backends == 0.1.3
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