How to build a REST API with Django and JWT Auth

Joey Masip Romeu
8 min readSep 2, 2018

--

It’s been a while since I wrote anything in the blog.

Today on this short tutorial I’ll explain the steps on how to build an api app with Django, using the JWT (JSON Web Token) as a way to identify users.

Libraries we’ll use are:

Docker
Django 1.11
Python 3
Django’s REST Framework
Django’s JWT

So let’s start by creating a Django project with Docker. If you still don’t know how to do that, more info on how to do create a django project with docker here.

Our requirements.txt would something like this:

django==1.11
mysqlclient
djangorestframework
djangorestframework-jwt
requests

*Note: The requests library is just to create some requests (GET, POST…) inside our python code. For this example, I’ve named the project ‘apiskeleton’ and the app ‘apiapp’.

Step1: Django’s REST Framework

Once we have created the project and app, let’s register our app and the rest framework app also in our settings.

#settings.py

INSTALLED_APPS = [
# ...
'apiapp',
'rest_framework',
# ...
]

Great, now we’re ready to create some logic in our app.

First off, if you haven’t done already, create a separate User model. This is best practices for Django.

#models/user.py
from django.db import models
from django.contrib.auth.models import AbstractUser


class User(AbstractUser):
pass

Update the settings.py

#settings.py

# Custom User model
AUTH_USER_MODEL = 'apiapp.User'

Now, let’s create a serializer for our User model. The serializer will allow us to convert a model instance or queryset to a JSON content type. More info on serializers here.

Our serializers.py would look something like this:

#serializers/user.py
from rest_framework import serializers
from appapi.models import User

class UserSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
username = serializers.CharField(required=True, allow_blank=False, max_length=100)
password = serializers.CharField(required=True, write_only=True)
email = serializers.CharField(required=True, allow_blank=True, max_length=100)
is_staff = serializers.BooleanField(required=False, default=False)
is_superuser = serializers.BooleanField(required=False, default=False)

def create(self, data):
"""
Create and return a new `Snippet` instance, given the validated data.
"""
instance = User.objects.create(
username=data.get('username'),
email=data.get('email'),
is_staff=data.get('is_staff'),
is_superuser=data.get('is_superuser'),
)
instance.set_password(data.get('password'))
instance.save()
return instance

def update(self, instance, data):
"""
Update and return an existing `Snippet` instance, given the validated data.
"""
instance.username = data.get('username', instance.username)
instance.email = data.get('email', instance.email)
instance.is_staff = data.get('is_staff', instance.is_staff)
instance.is_superuser = data.get('is_superuser', instance.is_staff)
instance.set_password(data.get('password'))
instance.save()
return instance

It’s very simple, I know, but I like the readability here! There are probably better ways to make your serializers, for instance, by extending serializers.ModelSerializer, more info on this here, but it’s not the aim of this tutorial right now. Trying to make things as easy and readable as possible.

Now let’s add a GET API route to list all users, something on the lines of /user/. Since it’s listing all users, it should only be allowed for an admin user to use it right? For now, I’ll just call it api_admin_user_index, so I know this is for admins only. Later on we’ll secure these.

#urls.py
urlpatterns = [
url(r'^user/$', user.api_admin_user_index, name='api_admin_user_index'),
]

Now, let’s create the view for this url:

#views/user.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
from apiapp.models import User
from apiapp.serializers.user import UserSerializer

@api_view(['GET'])
def api_admin_user_index(request):
"""
get:
List all users.
"""
serializer = UserSerializer(User.objects.all(), many=True)
return Response(serializer.data)

This first parameter in the serializer is the queryset, and the second parameter is to let the serializer know this is a list. More info on this on the BaseSerializer class from Django’s Rest Framework.

Now, if we open a browser and type this route, we should see a list of 0 users, but the route works right? Success!

Now let’s create some users. We should add the POST verb in our view to do that, so it would look something like this:

#views/user.py
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from apiapp.models import User
from apiapp.serializers.user import UserSerializer
from apiapp.utils.jsonreader import JsonReader


@api_view(['GET', 'POST'])
def api_admin_user_index(request):
"""
get:
List all users.
post:
Create new user.
"""
if request.method == 'GET':
serializer = UserSerializer(User.objects.all(), many=True)
return Response(serializer.data)

elif request.method == 'POST':
data = JsonReader.read_body(request)
serializer = UserSerializer(data=data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

The POST will create a user through the serializer.

In the code, you can see that I added a JsonReader class. This is a util class I use in most of my projects, it just makes reading the body easier, here’s the code:

#utils/jsonreader.py
import json


class JsonReader:

@staticmethod
def read_body(request):
'''returns a dict from the body of request'''

data = dict()

try:
body = request.body
data = JsonReader.bytes_to_dict(body)
except Exception as exc:
if isinstance(request.data, dict):
data = request.data
elif isinstance(request.data, str):
data = JsonReader.str_to_dict(request.data)

return data

@staticmethod
def str_to_dict(s: str):
dict = json.loads(s)
return dict

@staticmethod
def dict_to_str(d: dict):
str = json.dumps(d)
return str

@staticmethod
def bytes_to_dict(b: bytes):
str = b.decode("utf-8")
dict = JsonReader.str_to_dict(str)
return dict

Now we can create some users through our POST method, just sending some JSON with the keys username, password and email, which are the required ones in our serializer. Simple right?

Now it’s time for the rest of the usual verbs, PUT and DELETE. Also, we need a GET for a single user.

The routes would look something like this:

#urls.py
urlpatterns = [
url(r'^user/$', user.api_admin_user_index, name='api_admin_user_index'),
url(r'^user/(?P[0-9]+)/$', user.api_admin_user_detail, name='api_admin_user_detail'),
]

And now let’s update the view

#views/user.py
@api_view(['GET', 'PUT', 'DELETE'])
def api_admin_user_detail(request, pk):
"""
get:
Detail one user.
put:
Update one user.
delete:
Delete one user.
"""

try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response({'error': "User " + pk + " does not exist"}, status=status.HTTP_404_NOT_FOUND)

if request.method == 'GET':
serializer = UserSerializer(user)
return Response(serializer.data)

elif request.method == 'PUT':
data = JsonReader.read_body(request)
serializer = UserSerializer(user, data=data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

elif request.method == 'DELETE':
user.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

All of this is great, but now let’s secure these admin calls right? Here’s where JWT comes into play.

Step2: Django’s JWT

JWT is a stateless authentication mechanism as the user state is never saved in the server memory. Django will check for a valid JWT in the Authorization header, and if it is there, the user will be allowed. As JWTs are self-contained, all the necessary information is there, reducing the need to go back and forth to the database.

This allows the user to fully rely on data APIs that are stateless. It doesn’t matter which domains are serving your APIs, as Cross-Origin Resource Sharing (CORS) won’t be an issue since it doesn’t use cookies.

With that being said, let’s start by updating our settings.py with the JWT configuration

#settings.py

# Configure the authentication in Django Rest Framework to be JWT
# http://www.django-rest-framework.org/
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
),
}

# Configure the JWTs to expire after 1 hour, and allow users to refresh near-expiration tokens
# https://getblimp.github.io/django-rest-framework-jwt/
JWT_AUTH = {
# If the secret is wrong, it will raise a jwt.DecodeError telling you as such. You can still get at the payload by setting the JWT_VERIFY to False.
'JWT_VERIFY': True,

# You can turn off expiration time verification by setting JWT_VERIFY_EXPIRATION to False.
# If set to False, JWTs will last forever meaning a leaked token could be used by an attacker indefinitely.
'JWT_VERIFY_EXPIRATION': True,

# This is an instance of Python's datetime.timedelta. This will be added to datetime.utcnow() to set the expiration time.
# Default is datetime.timedelta(seconds=300)(5 minutes).
'JWT_EXPIRATION_DELTA': datetime.timedelta(hours=1),

'JWT_ALLOW_REFRESH': True,
'JWT_AUTH_HEADER_PREFIX': 'JWT',
}

The config is pretty self-explanatory, but there’s more documentation on JWT settings here.

Once this is out of the way, let’s add some JWT route patterns so we can get that JWT token.

#urls.py
urlpatterns = [
# ...
url(r'^jwt/refresh-token/', refresh_jwt_token, name='refresh_jwt_token'),
url(r'^jwt/api-token-verify/', verify_jwt_token, name='verify_jwt_token'),
url(r'^jwt/api-token-auth/', obtain_jwt_token, name='obtain_jwt_token'),
]

Also, this is not necessary, but instead of exposing the JWT routes, let’s add a login API route that will wrap the obtaining of the JWT token.

#urls.py
urlpatterns = [
# ...
url(r'^login/', registration.api_login, name='api_login'),
]

And in our registration view

#views/registration.py
@api_view(['POST'])
def api_login(request):
"""
post:
This view is called through API POST with a json body like so:

{
"username": "admin",
"password": "admin"
}

:param request:
:return:
"""
data = JsonReader.read_body(request)

response_login = requests.post(
request.build_absolute_uri(reverse('obtain_jwt_token')),
data=data
)
response_login_dict = json.loads(response_login.content)
return Response(response_login_dict, response_login.status_code)

Notice that this time I didn’t use the prefix ‘admin’. This is because this will be authorized to anonymous users, unlike the ones we created previously.

So as you can see, it’s pretty straightforward. This view is a wrapper of the JWT route. This way, we can add information to the response if we need to, for instance, the user id, user profile, etc. For now, let’s leave it this way.

If we now test it with a username and password, we should get the token. If you haven’t created any users yet, you could use the POST call we created previously. Otherwise, just add some superusers in the python console or create some fixtures, up to you.

If you’re lazy, here are my fixtures, and the location is apiapp/fixtures/users.json

[
{
"model": "apiapp.user",
"pk": 1,
"fields": {
"password": "pbkdf2_sha256$36000$SoF7ueJzOE1I$244qaRI1dReT4DxZKXJi2sRmuKdqPcjeOiUaPdH2UV0=",
"last_login": null,
"is_superuser": true,
"username": "admin",
"first_name": "",
"last_name": "",
"email": "admin@slowcode.io",
"is_staff": true,
"is_active": true,
"date_joined": "2018-08-29T10:09:27.191Z",
"groups": [],
"user_permissions": []
}
},
{
"model": "apiapp.user",
"pk": 2,
"fields": {
"password": "pbkdf2_sha256$36000$bkA3NXYqXZ4S$zuH97poSj3trZoNRFeROw3PgutbGOHlZunliI8/1jbg=",
"last_login": null,
"is_superuser": false,
"username": "user1",
"first_name": "",
"last_name": "",
"email": "user1@slowcode.io",
"is_staff": true,
"is_active": true,
"date_joined": "2018-08-29T10:10:56.873Z",
"groups": [],
"user_permissions": []
}
},
{
"model": "apiapp.user",
"pk": 3,
"fields": {
"password": "pbkdf2_sha256$36000$KKujn28LvdGX$OxtfRmIUWNPwbBPsz2iwKwff8klJ5PiPXj3P9N70Hto=",
"last_login": null,
"is_superuser": false,
"username": "user2",
"first_name": "",
"last_name": "",
"email": "user2@slowcode.io",
"is_staff": false,
"is_active": true,
"date_joined": "2018-08-29T10:11:19.576Z",
"groups": [],
"user_permissions": []
}
}
]

To import them, use the command

python manage.py loaddata users

Great, by now you should have some users in your database and you should be able to use our login API we just created. But what do we do with the token we get back?

Well, as mentioned before, this token should be added as a Authorization header in our calls to identify such user.

With JWT we will be able to authorize or prohibit certain calls for certain users, and or we will send different type of info, depending on what user is sending the call.

All of this decision making will be done by a different layer, which I call ‘security voters’ (hereditary from Symfony)

Here’s an example of what a voter would look like:

#security/voters.py
from apiapp.models import User


class AbstractVoter:

request = None

def __init__(self, request):
self.request = request

def is_logged_in(self):
if isinstance(self.request.user, User):
return True

return False

def is_superuser(self):
if self.is_logged_in():
return self.request.user.is_superuser

return False


class UserVoter(AbstractVoter):

def user_can_manage_me(self, user_inst: User):
if self.is_logged_in():
if self.is_superuser():
return True
if self.request.user == user_inst:
return True

return False

Now we can call this voter in our APIs.

#views/user.py
@api_view(['GET', 'POST'])
def api_admin_user_index(request):
"""
get:
List all users.
post:
Create new user.
"""
voter = UserVoter(request)
if not voter.is_superuser():
return Response({'error': "User API is not allowed by non admin user"}, status=status.HTTP_403_FORBIDDEN)

#...


@api_view(['GET', 'PUT', 'DELETE'])
def api_admin_user_detail(request, pk):
"""
get:
Detail one user.
put:
Update one user.
delete:
Delete one user.
"""
voter = UserVoter(request)
if not voter.is_superuser():
return Response({'error': "User API is not allowed by non admin user"}, status=status.HTTP_403_FORBIDDEN)

#...

It can also be useful for API calls that are not for superusers. Let’s create the route for a user detail.

#urls.py
urlpatterns = [
#...
url(r'^(?P[0-9]+)/$', user.api_user_detail, name='api_user_detail'),
]

And now in the view.
In the GET verb, Unless the user wants to see himself, it should be denied.

#views/user.py
@api_view(['GET', 'PUT'])
def api_user_detail(request, pk):
"""
get:
Detail one user.
put:
Update one user.
"""
try:
user_inst = User.objects.get(pk=pk)
except User.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)

voter = UserVoter(request)
if not voter.user_can_manage_me(user_inst):
return Response({'error': "User API is not allowed"}, status=status.HTTP_403_FORBIDDEN)

if request.method == 'GET':
serializer = UserSerializer(user_inst)
return Response(serializer.data)

elif request.method == 'PUT':
data = JsonReader.read_body(request)
if 'is_staff' in data:
if not voter.is_superuser():
return Response({'error': "Non admin cannot update admin attributes"}, status=status.HTTP_403_FORBIDDEN)
serializer = UserSerializer(user_inst, data=data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Conclusion

On this post we’ve seen how to implement the Django’s REST Framework together with Django’s JWT library.

We’ve seen that JWT is a powerful and easy way to identify users that use our API.

If you have any questions or something is confusing, let me know and I’ll do my best to clarify.

I’ve also uploaded the code on github so you can clone the project and try it out for yourself.

git clone https://github.com/joeymasip/django-apiskeleton.git

I use it as a base for API projects, as I usually always need some User API to start with.

Happy coding :)

Originally published at Joey’s blog.

--

--

Joey Masip Romeu
Joey Masip Romeu

Written by Joey Masip Romeu

Coder, Entrepreneur, Co-founder at SlowCode

No responses yet