Access Control

In this chapter, we will add access control to our APIs, and add APIs to create and authenticate users.

Right now our APIs are completely permissive. Anyone can create, access and delete anything. We want to add these access controls.

  • A user must be authenticated to access a poll or the list of polls.
  • Only an authenticated users can create a poll.
  • Only an authenticated user can create a choice.
  • Authenticated users can create choices only for polls they have created.
  • Authenticated users can delete only polls they have created.
  • Only an authenticated user can vote. Users can vote for other people’s polls.

To enable the access control, we need to add two more APIs

  • API to create a user, we will call this endpoint /users/
  • API to verify a user and get a token to identify them, we will call this endpoint /login/

Creating a user

We will add an user serializer, which will allow creating. Add the following code to serializers.py.

# ...
from django.contrib.auth.models import User

# ...
class UserSerializer(serializers.ModelSerializer):

    class Meta:
        model = User
        fields = ('username', 'email', 'password')
        extra_kwargs = {'password': {'write_only': True}}

    def create(self, validated_data):
        user = User(
            email=validated_data['email'],
            username=validated_data['username']
        )
        user.set_password(validated_data['password'])
        user.save()
        return user

We have overriden the ModelSerializer method’s create() to save the User instances. We ensure that we set the password correctly using user.set_password, rather than setting the raw password as the hash. We also don’t want to get back the password in response which we ensure using extra_kwargs = {'password': {'write_only': True}}.

Let us also add views to the User Serializer for creating the user and connect it to the urls.py

# in apiviews.py
# ...
from .serializers import PollSerializer, ChoiceSerializer, VoteSerializer, UserSerializer

# ...
class UserCreate(generics.CreateAPIView):
    serializer_class = UserSerializer

# in urls.py
# ...
from .apiviews import PollViewSet, ChoiceList, CreateVote, UserCreate


urlpatterns = [
    # ...
    path("users/", UserCreate.as_view(), name="user_create"),
]

We can test this api by posting to /users/ with this json.

{
    "username": "nate.silver",
    "email": "nate.silver@example.com",
    "password": "FiveThirtyEight"
}

Which give back this response.

{
    "username": "nate.silver",
    "email": "nate.silver@example.com"
}

Try posting the same json, and you will get a error response (HTTP status code 400)

{
    "username": [
        "A user with that username already exists."
    ]
}

Authentication scheme setup

With Django Rest Framework, we can set up a default authentication scheme which is applied to all views using DEFAULT_AUTHENTICATION_CLASSES. We will use the token authentication in this tutorial. In your settings.py, add this.

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

You also need to enable rest_framework.authtoken app, so update INSTALLED_APPS in your settings.py.

INSTALLED_APPS = (
    ...
    'rest_framework.authtoken'
)

Run python manage.py migrate to create the new tables.

REST_FRAMEWORK = {
    # ...
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    )
}

Also, dont forget to give exemption to UserCreate view for authentication by overriding the global setting. The UserCreate in polls/apiviews.py should look as follows.

class UserCreate(generics.CreateAPIView):
    authentication_classes = ()
    permission_classes = ()
    serializer_class = UserSerializer

Note the authentication_classes = () and permission_classes = () to exempt UserCreate from global authentication scheme.

We want to ensure that tokens are created when user is created in UserCreate view, so we update the UserSerializer. Change your serializers.py like this

from rest_framework.authtoken.models import Token

class UserSerializer(serializers.ModelSerializer):

    class Meta:
        model = User
        fields = ('username', 'email', 'password')
        extra_kwargs = {'password': {'write_only': True}}

    def create(self, validated_data):
        user = User(
            email=validated_data['email'],
            username=validated_data['username']
        )
        user.set_password(validated_data['password'])
        user.save()
        Token.objects.create(user=user)
        return user

The login API

Since we have added rest_framework.authentication.TokenAuthentication, we will need to set a header like this Authorization: Token c2a84953f47288ac1943a3f389a6034e395ad940 to authenticate. We need an API where a user can give their username and password, and get a token back.

We will not be adding a serializer, because we never save a token using this API.

Add a view and connect it to urls.

# in apiviews.py
# ...
from django.contrib.auth import authenticate

class LoginView(APIView):
    permission_classes = ()

    def post(self, request,):
        username = request.data.get("username")
        password = request.data.get("password")
        user = authenticate(username=username, password=password)
        if user:
            return Response({"token": user.auth_token.key})
        else:
            return Response({"error": "Wrong Credentials"}, status=status.HTTP_400_BAD_REQUEST)


# in urls.py
# ...

from .apiviews import PollViewSet, ChoiceList, CreateVote, UserCreate, LoginView



urlpatterns = [
    path("login/", LoginView.as_view(), name="login"),
    # ...
]

WARNING: You have to create a user using the /user/ endpoint before logging in using the /login/ endpoint. Using a previously existing user will result in a “User has no auth_token” error because we have not created a token for them. You can create tokens for them manually by using the django shell $ python manage.py shell.

>>> from django.contrib.auth.models import User
>>> from rest_framework.authtoken.models import Token
>>> user = User.objects.get(pk=pk_of_user_without_token)
>>> Token.objects.create(user=user)
<Token: e2b9fa2d4ae27fe1fdcf17b6e37711334d07e167>

Do a POST with a correct username and password, and you will get a response like this.

{
    "token": "c300998d0e2d1b8b4ed9215589df4497de12000c"
}

POST with a incorrect username and password, and you will get a response like this, with a HTTP status of 400.

{
    "error": "Wrong Credentials"
}

Another way to create this login endpoint is using obtain_auth_token method provide by DRF

# in urls.py
# ...
from rest_framework.authtoken import views

urlpatterns = [
    path("login/", views.obtain_auth_token, name="login"),
    # ...
]

Fine grained access control

Try accessing the /polls/ API without any header. You will get an error with a http status code of HTTP 401 Unauthorized like this.

{
    "detail": "Authentication credentials were not provided."
}

Add an authorization header Authorization: Token <your token>, and you can access the API.

From now onwards we will use a HTTP header like this, Authorization: Token <your token> in all further requests.

We have two remaining things we need to enforce.

  • Authenticated users can create choices only for polls they have created.
  • Authenticated users can delete only polls they have created.

We will do that by overriding PollViewSet.destroy and ChoiceList.post.

# ...
from rest_framework.exceptions import PermissionDenied


class PollViewSet(viewsets.ModelViewSet):
    # ...

    def destroy(self, request, *args, **kwargs):
        poll = Poll.objects.get(pk=self.kwargs["pk"])
        if not request.user == poll.created_by:
            raise PermissionDenied("You can not delete this poll.")
        return super().destroy(request, *args, **kwargs)


class ChoiceList(generics.ListCreateAPIView):
    # ...

    def post(self, request, *args, **kwargs):
        poll = Poll.objects.get(pk=self.kwargs["pk"])
        if not request.user == poll.created_by:
            raise PermissionDenied("You can not create choice for this poll.")
        return super().post(request, *args, **kwargs)

In both cases, we are checking request.user against the expected user, and raising a PermissionDenied error if it does not match.

You can check this by doing a DELETE on someone elses Poll. You will get an error with HTTP 403 Forbidden and response.

{
    "detail": "You can not delete this poll."
}

Similarly, trying to create choice for someone else’s Poll will get an error with HTTP 403 Forbidden and response

{
    "detail": "You can not create choice for this poll."
}

Next steps:

In the next chapter we will look at adding tests for our API and serializers. We will also look at how to use flake8 and run our tests in a CI environment.