Skip to content

Commit

Permalink
feat(authentication): implement JWT authentication
Browse files Browse the repository at this point in the history
- Users get JWT token on successful registration
- Users get JWT token on successful login
- Users need JWT token to access endpoints requiring authentication

[finishes #164046245]
  • Loading branch information
MandelaK committed Mar 6, 2019
1 parent 04974e5 commit d203702
Show file tree
Hide file tree
Showing 9 changed files with 245 additions and 22 deletions.
89 changes: 82 additions & 7 deletions authors/apps/authentication/backends.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,84 @@
# import jwt
#
# from django.conf import settings
#
# from rest_framework import authentication, exceptions
#
# from .models import User
import jwt

from django.conf import settings

from rest_framework import authentication, exceptions

from .models import User


"""Configure JWT Here"""


class JWTAuthentication(authentication.BaseAuthentication):
auth_header_prefix = 'Bearer'.lower()

def authenticate(self, request):
"""
This method will be called everytime an endpoint is accessed.
This method can return 'None' when we want authentication to
fail, for example when no authentication credentials are provided.
It can also return `(user, token)` when authentication is successful.
If we encounter an error, we raise an `AuthenticationFailed` error.
"""
request.user = None

# we need the auth_header, which should be a list containing two
# elements: 1) the name of the authentication header ('Bearer' in our
# case) and 2) the JWT token.
auth_header = authentication.get_authorization_header(request).split()

if not auth_header:
# if we get no authentication header, we do not attempt to
# authenticate
return None

if len(auth_header) == 1:
# We expect the length to be 2, so this is an invalid header. Do
# not attempt to authenticate.
return None

elif len(auth_header) > 2:
# Invalid token header. The length must be 2. Do not attempt
# to authenticate
return None

# We have to decode both the prefix and token because they are in bytes,
# and the JWT library we use can't handly bytes.
prefix = auth_header[0].decode('utf-8')
token = auth_header[1].decode('utf-8')

if prefix.lower() != self.auth_header_prefix:
# The auth header prefix should only be 'Bearer'. If otherwise,
# don't attempt to authenticate
return None

# We can now attempt to authenticate after performing the above checks.
return self._authenticate_credentials(request, token)

def _authenticate_credentials(self, request, token):
"""
We will try to authenticate the token. If authentication is successful
we return (user, token), otherwise we return an `AuthenticationFailed`
error.
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY)
except Exception:
msg = 'Ivalid token provided. Authentication failure.'
raise exceptions.AuthenticationFailed(msg)

try:
user = User.objects.get(pk=payload['id'])
except User.DoesNotExist:
msg = 'User matching this token was not found.'
raise exceptions.AuthenticationFailed(msg)

if not user.is_active:
msg = 'Forbidden! This user has been deactivated.'
raise exceptions.AuthenticationFailed(msg)

return (user, token)
30 changes: 30 additions & 0 deletions authors/apps/authentication/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
from datetime import datetime, timedelta

import jwt

from django.conf import settings
from django.contrib.auth.models import (AbstractBaseUser, BaseUserManager,
PermissionsMixin)
from django.db import models
Expand Down Expand Up @@ -111,3 +116,28 @@ def get_short_name(self):
the user's real name, we return their username instead.
"""
return self.username

@property
def token(self):
"""
We need to make the method for creating our token private. At the
same time, it's more convenient for us to access our token with
`user.token` and so we make the token a dynamic property by wrapping
in in the `@property` decorator.
"""
return self._generate_jwt_token()

def _generate_jwt_token(self):
"""
We generate JWT token and add the user id, username and expiration
as an integer.
"""
token_expiry = datetime.now() + timedelta(hours=2)

token = jwt.encode({
'id': self.pk,
'username': self.get_full_name,
'exp': int(token_expiry.strftime('%s'))
}, settings.SECRET_KEY, algorithm='HS256')

return token.decode('utf-8')
10 changes: 5 additions & 5 deletions authors/apps/authentication/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ class RegistrationSerializer(serializers.ModelSerializer):
}
)

# The client should not be able to send a token along with a registration
# request. Making `token` read-only handles that for us.

class Meta:
model = User
# List all of the fields that could possibly be included in a request
Expand Down Expand Up @@ -71,6 +68,7 @@ class LoginSerializer(serializers.Serializer):
email = serializers.CharField(max_length=255)
username = serializers.CharField(max_length=255, read_only=True)
password = serializers.CharField(max_length=128, write_only=True)
token = serializers.CharField(max_length=255, read_only=True)

def validate(self, data):
# The `validate` method is where we make sure that the current
Expand Down Expand Up @@ -123,7 +121,7 @@ def validate(self, data):
return {
'email': user.email,
'username': user.username,

'token': user.token
}


Expand All @@ -142,7 +140,7 @@ class UserSerializer(serializers.ModelSerializer):

class Meta:
model = User
fields = ('email', 'username', 'password')
fields = ('email', 'username', 'password', 'token',)

# The `read_only_fields` option is an alternative for explicitly
# specifying the field with `read_only=True` like we did for password
Expand All @@ -152,6 +150,8 @@ class Meta:
# `max_length` properties too, but that isn't the case for the token
# field.

read_only_fields = ('token',)

def update(self, instance, validated_data):
"""Performs an update on a User."""

Expand Down
113 changes: 113 additions & 0 deletions authors/apps/authentication/tests/test_jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from django.test import TestCase
from django.urls import reverse

from rest_framework import status
from rest_framework.test import APIClient, APIRequestFactory

from authors.apps.authentication.models import User
from authors.apps.authentication.backends import JWTAuthentication


class JWTAuthenticationTest(TestCase):
"""Test the JWT Authentication implementation"""
def setUp(self):
self.user = User.objects.create(username='user1', email='user1@mail.com', password='password')
self.login_data = {'user': {
'email': 'user2@mail.com',
'password': 'password'
}}
self.user_token = self.user.token
self.user.save()
self.client = APIClient()

def test_user_gets_a_token_when_they_log_in(self):
"""Users should get a token when they successfully log in"""
self.client.post(reverse('authentication:register'), {'user': {
'email': 'user2@mail.com',
'username': 'user2',
'password': 'password'
}}, format='json')
res = self.client.post(reverse('authentication:login'), self.login_data, format='json')
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertIn('token', res.data)

def test_if_user_passes_valid_token_to_access_secured_endpoint(self):
"""Test if a user can access a secured endpoint after providing a valid token"""
headers = {'HTTP_AUTHORIZATION': f'Bearer {self.user_token}'}
res = self.client.get(reverse('authentication:get users'), **headers)
self.assertEqual(res.status_code, status.HTTP_200_OK)

def test_failure_if_user_passes_no_token(self):
"""Test if a user can access a secured endpoint without providing a token"""
res = self.client.get(reverse('authentication:get users'))
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(res.data['detail'], 'Authentication credentials were not provided.')

def test_failure_if_user_provides_invalid_token(self):
"""Test if an invalid token can be decoded"""
fake_token = self.user_token + 'ivalid'
headers = {'HTTP_AUTHORIZATION': f'Bearer {fake_token}'}
res = self.client.get(reverse('authentication:get users'), **headers)
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(res.data['detail'], 'Ivalid token provided. Authentication failure.')

def test_failure_if_user_does_not_exist(self):
"""We register a user to get the token, then delete the user from the database. When a user tries to pass the token to access the endpoint, they should be forbidden from proceeding."""
test_user = User.objects.create(username='test_user', email='test_user@mail.com', password='password')
test_token = test_user.token
test_user.delete()
client = APIClient()
headers = {'HTTP_AUTHORIZATION': f'Bearer {test_token}'}
res = client.get(reverse('authentication:get users'), **headers)
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(res.data['detail'], 'User matching this token was not found.')

def test_failure_because_user_is_inactive(self):
"""Test if an inactive user can be authenticated"""
inactive_user = User.objects.create(username='inactive_one', email='inactive@mail.com', password='password')
inactive_user.is_active = False
inactive_user.save()
headers = {'HTTP_AUTHORIZATION': f'Bearer {inactive_user.token}'}
res = self.client.get(reverse('authentication:get users'), **headers)
self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(res.data['detail'], 'Forbidden! This user has been deactivated.')

def test_authentication_failure_because_header_is_None(self):
"""Test if authentication fails when a request has authorization
headers with a length of 0"""
jwt_auth = JWTAuthentication()
factory = APIRequestFactory()
request = factory.get(reverse('authentication:get users'))
request.META['HTTP_AUTHORIZATION'] = ''
res = jwt_auth.authenticate(request)
self.assertEqual(res, None)

def test_authentication_failure_because_header_length_is_1(self):
"""Test if authentication fails when a request has authorization
headers with a length of 1"""
jwt_auth = JWTAuthentication()
factory = APIRequestFactory()
request = factory.get(reverse('authentication:get users'))
request.META['HTTP_AUTHORIZATION'] = 'length'
res = jwt_auth.authenticate(request)
self.assertEqual(res, None)

def test_authentication_failure_if_header_length_is_greater_than_2(self):
"""Test if authentication fails when a request has authorization
headers with a length greater than 2"""
jwt_auth = JWTAuthentication()
factory = APIRequestFactory()
request = factory.get(reverse('authentication:get users'))
request.META['HTTP_AUTHORIZATION'] = b'length is greater than 2'
res = jwt_auth.authenticate(request)
self.assertEqual(res, None)

def test_authentication_failure_if_prefixes_mismatch(self):
"""We unit test our authentication method to see if the method
returns `None` when the prefixes mismatch"""
jwt_auth = JWTAuthentication()
factory = APIRequestFactory()
request = factory.get(reverse('authentication:get users'))
request.META['HTTP_AUTHORIZATION'] = 'Token, {}'.format(self.user_token)
res = jwt_auth.authenticate(request)
self.assertEqual(res, None)
4 changes: 2 additions & 2 deletions authors/apps/authentication/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ def test_creating_validating_correct_email_and_password(self):
returned_user_data = serializer.validate(login_data)
self.assertEqual({
"email": "bob@email.com",
"username": "bob"},
"username": "bob",
'token': user.token},
returned_user_data
)

Expand Down Expand Up @@ -102,4 +103,3 @@ def test_updating_user_information_excluding_password(self):
self.assertEqual(updated_user.username, "robert")
self.assertEqual(updated_user.email, "robert@email.com")
self.assertEqual(updated_user.password, user_password)

4 changes: 2 additions & 2 deletions authors/apps/authentication/tests/test_user_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ def test_user_registration(self):
"password": "Mary1234"
}
}

response = self.client.post(self.url, signup_data, format='json')
signup_data_response = {
"email": "mary@gmail.com", "username": "Mary"
}

response = self.client.post(self.url, signup_data, format='json')
self.assertEqual(response.data, signup_data_response)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)

Expand Down
12 changes: 8 additions & 4 deletions authors/apps/authentication/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ def test_if_we_can_retrieve_user_list(self):
}
client = APIClient()
client.post(reverse('authentication:register'), user1, format='json')
client.login(email="user1@mail.com", password="password")
response = client.get(reverse('authentication:get users'))
login_data = client.post(reverse('authentication:login'), {'user':{"email": "user1@mail.com", "password":"password"}}, format='json')
token = login_data.data['token']
headers = {'HTTP_AUTHORIZATION': f'Bearer {token}'}
response = client.get(reverse('authentication:get users'), **headers)
self.assertEqual(response.status_code, status.HTTP_200_OK)

def test_if_we_can_update_user_data(self):
Expand All @@ -69,6 +71,8 @@ def test_if_we_can_update_user_data(self):
}}
client = APIClient()
client.post(reverse('authentication:register'), user1, format='json')
client.login(email="user1@mail.com", password="password")
response = client.put(reverse('authentication:get users'), update_info, format='json')
login_data = client.post(reverse('authentication:login'), {'user':{"email": "user1@mail.com", "password":"password"}}, format='json')
token = login_data.data['token']
headers = {'HTTP_AUTHORIZATION': f'Bearer {token}'}
response = client.put(reverse('authentication:get users'), **headers)
self.assertEqual(response.status_code, status.HTTP_200_OK)
1 change: 0 additions & 1 deletion authors/apps/authentication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,3 @@ def update(self, request, *args, **kwargs):
serializer.save()

return Response(serializer.data, status=status.HTTP_200_OK)

4 changes: 3 additions & 1 deletion authors/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'
TIME_ZONE = 'Africa/Nairobi'

USE_I18N = True

Expand Down Expand Up @@ -133,4 +133,6 @@
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'authors.apps.core.exceptions.core_exception_handler',
'NON_FIELD_ERRORS_KEY': 'error',
'DEFAULT_AUTHENTICATION_CLASSES': (
'authors.apps.authentication.backends.JWTAuthentication',),
}

0 comments on commit d203702

Please sign in to comment.