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 example_taxi/settings.py file:

# example_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 example/tests.py file and create a new tests directory inside of example. Within it add a example/tests/test_http.py file and an empty __init__.py file to example/tests.

Your new directory structure should look like this:

├── example
│   ├── __init__.py
│   ...
│   ├── tests
│   │   ├── __init__.py
│   │   └── test_http.py
|   ...
# example/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 example.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 example/serializers.py file and fill in the following code:

# example/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, create a new example/apis.py file and add the following view to it:

# example/apis.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 example_taxi/urls.py file.

# example_taxi/urls.py
from django.contrib import admin
from django.urls import path

from example.apis 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 example.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. For example, what if we create a new user

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 example/tests/test_http.py:

# example/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 Control+c and run the tests to watch them fail:

(env) $ python manage.py test example.tests
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 example/apis.py file.

# example/apis.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 collapsed for clarity.
class SignUpView(generics.CreateAPIView): ...


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 example_taxi/urls.py:

# example_taxi/urls.py
from django.contrib import admin
from django.urls import path

from example.apis 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 example.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.

{
    "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 @- << 'EOF'
{
    "username": "[email protected]",
    "password1": "test",
    "password2": "test",
    "first_name": "michael",
    "last_name": "herman"
}
EOF
{
  "id": 2,
  "username": "[email protected]",
  "first_name": "michael",
  "last_name": "herman"
}

And then attempt to log in with it:

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



Mark as Completed