Authentication

Part 1, Chapter 4


Authentication is the cornerstone of any app that handles user data. It allows users to maintain privacy within the app, while gaining access to the full set of features afforded with registration.

With Django REST Framework (DRF), we have four authentication classes to choose from:

  1. BasicAuthentication
  2. TokenAuthentication
  3. SessionAuthentication
  4. RemoteUserAuthentication

We can eliminate BasicAuthentication right off the bat because it doesn't offer enough security for production environments. Although it is an attractive option because it supports both desktop and mobile clients, we can rule out TokenAuthentication too. The TokenAuthentication backend works by issuing an authentication token to the user after login. The client must send that token as a header to authorize subsequent requests. Unfortunately, the WebSockets browser APIs do not allow custom headers and neither does Django Channels. Between the remaining two classes, we should use SessionAuthentication because both HTTP and WebSockets requests can use it without any problems. Using the SessionAuthentication backend has the added benefit of offering arbitrary user data storage during the life of the session.

Start by setting up our app to use Django REST Framework's session-based authentication. Add the following at the bottom of the taxi/settings.py file:

# taxi/settings.py

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework.authentication.SessionAuthentication',
    )
}

During the course of this tutorial, we are going to be following test-driven development (TDD) to confirm that our code works. In the next part of the tutorial, we will be adding a user interface so that we can play with the app as an actual user.

Sign up

Let's create a new user account via an API. Users should be able to download our app and immediately sign up for a new account by providing the bare minimum of information—username, password, and their names. The distinction between password1 and password2 correlates to users entering their passwords and then confirming them. Eventually, our app will present users a form with input fields and a submit button.

Remove the existing trips/tests.py file and create a new "tests" directory inside of "trips". Within it add an empty __init__.py file along with a test_http.py file.

Your new directory structure should look like this:

.
└── server
    └── taxi
        ├── db.sqlite3
        ├── manage.py
        ├── taxi
        │   ├── __init__.py
        │   ├── asgi.py
        │   ├── routing.py
        │   ├── settings.py
        │   ├── urls.py
        │   └── wsgi.py
        └── trips
            ├── __init__.py
            ├── admin.py
            ├── apps.py
            ├── migrations
            │   ├── 0001_initial.py
            │   └── __init__.py
            ├── models.py
            ├── tests
            │   ├── __init__.py
            │   └── test_http.py
            └── views.py
# tests/test_http.py

from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.reverse import reverse
from rest_framework.test import APIClient, APITestCase

PASSWORD = 'pAssw0rd!'


class AuthenticationTest(APITestCase):

    def setUp(self):
        self.client = APIClient()

    def test_user_can_sign_up(self):
        response = self.client.post(reverse('sign_up'), data={
            'username': '[email protected]',
            'first_name': 'Test',
            'last_name': 'User',
            'password1': PASSWORD,
            'password2': PASSWORD,
        })
        user = get_user_model().objects.last()
        self.assertEqual(status.HTTP_201_CREATED, response.status_code)
        self.assertEqual(response.data['id'], user.id)
        self.assertEqual(response.data['username'], user.username)
        self.assertEqual(response.data['first_name'], user.first_name)
        self.assertEqual(response.data['last_name'], user.last_name)

A couple things to note:

  1. We expect our API to return a 201 status code when the user account is created.
  2. The response data should be a JSON-serialized representation of our user model.

Run the test now:

(env)$ python manage.py test trips.tests

It should fail:

django.urls.exceptions.NoReverseMatch: Reverse for 'sign_up' not found.
'sign_up' is not a valid view function or pattern name.

Remember: A tenant of TDD is that we should write failing tests (red) before writing the code to get them to pass (green).

We need to create several pieces of code before our tests will pass.

Typically, a data model is the first thing we want to create in a situation like this. We've already created a user model, and since it extends Django's built-in model, it already supports the fields we need.

The next bit of code we need is the user serializer. Create a new trips/serializers.py file and fill in the following code:

# trips/serializers.py

from django.contrib.auth import get_user_model
from rest_framework import serializers


class UserSerializer(serializers.ModelSerializer):
    password1 = serializers.CharField(write_only=True)
    password2 = serializers.CharField(write_only=True)

    def validate(self, data):
        if data['password1'] != data['password2']:
            raise serializers.ValidationError('Passwords must match.')
        return data

    def create(self, validated_data):
        data = {
            key: value for key, value in validated_data.items()
            if key not in ('password1', 'password2')
        }
        data['password'] = validated_data['password1']
        return self.Meta.model.objects.create_user(**data)

    class Meta:
        model = get_user_model()
        fields = (
            'id', 'username', 'password1', 'password2',
            'first_name', 'last_name',
        )
        read_only_fields = ('id',)

Remember: Right now our user data is basic (first name, last name, username, and password), so we only need access to a few fields. We should never need to read the password.

Next, open the trips/views.py file and add the following view to it:

# trips/views.py

from django.contrib.auth import get_user_model
from rest_framework import generics
from rest_framework.response import Response

from .serializers import UserSerializer


class SignUpView(generics.CreateAPIView):
    queryset = get_user_model().objects.all()
    serializer_class = UserSerializer

Here, we created a SignUpView that extends Django REST Framework's CreateAPIView and leverages our UserSerializer to create a new user.

Here's how it works behind the scenes:

  1. Django passes request data to the SignUpView, which in turn attempts to create a new user with the UserSerializer. The serializer checks if the passwords match.
  2. If all of the data is valid, the serializer creates and returns a new user. If it fails, then the serializer returns the errors. Even if the passwords match, validation could fail if the username is already taken or the password isn't strong enough.

Finally, configure a URL to link to our view. We do this by updating the existing taxi/urls.py file.

# taxi/urls.py

from django.contrib import admin
from django.urls import path

from trips.views import SignUpView


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/sign_up/', SignUpView.as_view(), name='sign_up'),
]

Run the tests:

(env)$ python manage.py test trips.tests

They should pass!

Keep in mind that throughout this course we will only be testing the happy path(s). Adding tests for error handling is a separate, highly recommended exercise left to the reader.

To manually test, fire up the server python manage.py runserver and navigate to the Browsable API at http://localhost:8000/api/sign_up/:

drf sign up page

Take note of the following error:

HTTP 405 Method Not Allowed
Allow: POST, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "detail": "Method \"GET\" not allowed."
}

That's expected since we don't have a GET route set up. You can still test out the POST functionality using the HTML form.

drf sign up user

After clicking the "POST" button we will see it was successful.

drf sign up success

Log in and out

Now that we can sign up a new user, the next logical step is to create the functionality to log the user in and out.

Start by adding two new tests to handle the log in and log out behavior to trips/tests/test_http.py:

# tests/test_http.py

from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.reverse import reverse
from rest_framework.test import APIClient, APITestCase

PASSWORD = 'pAssw0rd!'


def create_user(username='[email protected]', password=PASSWORD): # new
    return get_user_model().objects.create_user(
        username=username, password=password)


class AuthenticationTest(APITestCase):

    def setUp(self):
        self.client = APIClient()

    # Function collapsed for clarity.
    def test_user_can_sign_up(self): ...

    def test_user_can_log_in(self): # new
        user = create_user()
        response = self.client.post(reverse('log_in'), data={
            'username': user.username,
            'password': PASSWORD,
        })
        self.assertEqual(status.HTTP_200_OK, response.status_code)
        self.assertEqual(response.data['username'], user.username)

    def test_user_can_log_out(self): # new
        user = create_user()
        self.client.login(username=user.username, password=PASSWORD)
        response = self.client.post(reverse('log_out'))
        self.assertEqual(status.HTTP_204_NO_CONTENT, response.status_code)

Note that we added a create_user() helper function to help keep our code DRY.

The process of logging in is as easy as signing up: The user enters her username and password and submits them to the server. We expect the server to log the user in and then return a success status along with the serialized user data.

Logging out is even simpler—the action returns a successful status code with no content.

Stop the local server and run the tests to ensure they fail:

django.urls.exceptions.NoReverseMatch: Reverse for 'log_in' not found.
'log_in' is not a valid view function or pattern name.

...

django.urls.exceptions.NoReverseMatch: Reverse for 'log_out' not found.
'log_out' is not a valid view function or pattern name.

Now update our trips/views.py file.

# trips/views.py

from django.contrib.auth import get_user_model, login, logout # new
from django.contrib.auth.forms import AuthenticationForm # new
from rest_framework import generics, permissions, status, views # new
from rest_framework.response import Response

from .serializers import UserSerializer


class SignUpView(generics.CreateAPIView):
    queryset = get_user_model().objects.all()
    serializer_class = UserSerializer


class LogInView(views.APIView): # new
    def post(self, request):
        form = AuthenticationForm(data=request.data)
        if form.is_valid():
            user = form.get_user()
            login(request, user=form.get_user())
            return Response(UserSerializer(user).data)
        else:
            return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)


class LogOutView(views.APIView): # new
    permission_classes = (permissions.IsAuthenticated,)

    def post(self, *args, **kwargs):
        logout(self.request)
        return Response(status=status.HTTP_204_NO_CONTENT)

We programmed our log in and log out functions as we planned in the tests. Let's break each view down:

  1. In our LogInView, we leveraged Django's AuthenticationForm, which expects a username and password to be provided. We validated the form to get an existing user and then we logged that user in.
  2. Our LogOutView does the opposite of the LogInView: It logs the user out. We added an IsAuthenticated permission to ensure that only logged-in users can log out.

Link our new views to URLs in the existing configuration in taxi/urls.py:

# taxi/urls.py

from django.contrib import admin
from django.urls import path

from trips.views import SignUpView, LogInView, LogOutView # new


urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/sign_up/', SignUpView.as_view(), name='sign_up'),
    path('api/log_in/', LogInView.as_view(), name='log_in'), # new
    path('api/log_out/', LogOutView.as_view(), name='log_out'), # new
]

Run the authentication tests one last time to make sure they pass:

(env)$ python manage.py test trips.tests

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
----------------------------------------------------------------------
Ran 3 tests in 0.657s

OK
Destroying test database for alias 'default'...

Sanity Check

Our authentication work is done! But, before moving on, ensure you can log in at http://localhost:8000/api/log_in/. Make sure the server is running! Enter in the JSON for your username and password.

For example:

{
    "username": "[email protected]",
    "password": "testpass123"
}

Then click the "POST" button.

drf log in page

You should see the following success screen:

drf log in page

You can also test via cURL in a new Terminal console. Make sure the server is still running!

First sign up for a new account:

$ curl -X POST http://localhost:8000/api/sign_up/ \
-H 'Content-Type: application/json' \
-d '
{
    "username": "[email protected]",
    "password1": "test",
    "password2": "test",
    "first_name": "michael",
    "last_name": "herman"
}
'
{
  "id": 5,
  "username": "[email protected]",
  "first_name": "michael",
  "last_name": "herman"
}

And then attempt to log in with the same credentials:

$ curl -X POST http://localhost:8000/api/log_in/ \
-H 'Content-Type: application/json' \
-d '
{
    "username": "[email protected]",
    "password": "test"
}
'
{
  "id": 5,
  "username": "[email protected]",
  "first_name": "michael",
  "last_name": "herman"
}



Mark as Completed