In this chapter, we’ll dive deeper into Django and its architecture. We’ll be working with models, serializers, and viewsets to create an API that can receive HTTP requests as well as return a response. This will be done by building an authentication and authorization system using JSON Web Tokens (JWTs) to allow users to create an account, log in, and log out.
By the end of this chapter, you’ll be able to create Django models, write Django serializers and validation, write viewsets to handle your API requests, expose your viewsets via the Django REST routers, create an authentication and authorization system based on JWTs, and understand what a JWT is and how it helps with authentication and permissions.
We will be covering the following topics in this chapter:
For this chapter, you’ll need to have Insomnia installed on your machine to make requests to the API we’ll be building.
You can also find the code of this chapter at https://github.com/PacktPublishing/Full-stack-Django-and-React/tree/chap2.
Before writing the authentication feature, let’s explain what a JWT is. As mentioned earlier, JWT stands for JSON Web Token. It’s one of the most used means of authentication in web applications but also helps with authorization and information exchanges.
According to RFC 7519, a JWT is a JSON object defined as a safe way of transmitting information between two parties. Information transmitted by JWT is digitally signed so it can be verified and trusted.
A JWT contains three parts—a header (x), a payload (y), and a signature (z)—that are separated by a dot:
xxxxx.yyyyy.zzzzz
The header of the JWT consists of two parts: the type of token and the signing algorithm being used. The signing algorithm is used to ensure that the message is authentic and not altered.
Here’s an example of a header:
{ "alg": "RSA", "typ": "JWT" }
Signing algorithms are algorithms used to sign tokens issued for your application or API.
The payload is the second part that contains the claims. According to the official JWT documentation (https://jwt.io/introduction), claims are statements about an entity (typically, the user) and additional data.
Here’s an example of a payload:
{ "id": "d1397699-f37b-4de0-8e00-948fa8e9bf2c", "name": "John Doe", "admin": true }
In the preceding example, we have three claims: the ID of the user, the name of the user, and also a Boolean for the type of user.
The signature of a JWT is the encoded header, the encoded payload plus a secret, and an algorithm specified in the header, all of them combined and signed.
For example, it’s possible to create a signature the following way using the RSA algorithm:
RSA( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
The role of the signature is to track whether information has been changed.
But how are JWTs actually used in authentication?
Each time a user successfully logs in, a JWT is created and returned. The JWT will be represented as credentials used to access protected resources. The fact that it’s possible to store data in a JWT makes it vulnerable. That’s why you should specify an expiration time when creating a JWT.
In this book, we’ll be using JWTs in two ways. To make it simple, we’ll have two types of tokens:
But why use two tokens? As we stated earlier, a JWT is generated when users log in. Moreover, JWTs used to access resources should have a short lifespan. This means that after the JWT has expired, the user has to log in again and again – and no user wants the login page to appear every 5 minutes.
That’s where a refresh token is useful. It’ll contain the essential information needed to verify the user and generate a new access token.
Now that we understand the purpose of JWTs, let’s learn more about models in Django while creating the user model.
When working with Django, you’ll have to create many apps to handle different parts of a project. For example, you can have a different application for authentication, and another for payments or articles. To have a clean and well-organized project, we can create a Django application that will contain all the apps we will create for this book.
At the root of the project, run the following command:
django-admin startapp core
A new application will be created. Remove all the files in this app except for the apps.py file and the __init__.py file. Inside apps.py, add the following line:
core/apps.py
from django.apps import AppConfig class CoreConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core' label = 'core'
Register the apps in the setting.py file of the project:
CoreRoot/settings.py
# Application definition INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'core' ]
INSTALLED_APPS is a Django settings configuration, which is a list of Django apps within a project.
We can now create the user application with confidence and write our first model.
Unless you are creating a simple web application, there is little chance of avoiding the necessity to interact with a database, particularly having an account feature that requires users to register or log in to use your web application.
Before talking about the account feature, let’s learn more about Django models and what problems they resolve.
If you need to connect your application to a database, particularly SQL, the first assumption that comes to mind is that you’ll have to work directly with the database via SQL queries – and if that’s true, it can be fun, but it’s not the same for everyone; some developers may find SQL complex. You are no longer focusing on writing the application logic in your own language. Some tasks can become repetitive, such as writing SQL scripts to create tables, getting entries from the database, or inserting or updating data.
As you’ll see, the more the code base evolves, the more difficult it becomes to maintain both simple and complex SQL queries in your code base. This is more of an issue if you are working with multiple databases, which will require you to learn many SQL languages. For example, there are a lot of SQL databases and each one implements SQL in its own way.
Fortunately, in Django, this messy issue is solved by using a Django model to access the database. This doesn’t mean that you don’t have to write SQL queries: it’s just that you don’t have to use SQL at all unless you want to.
Django models provide object-relational mapping (ORM) to the underlying database. ORM is a tool that simplifies database programming by providing a simple mapping between the object and the database. Then, you don’t necessarily need to know the database structure or write complex SQL queries to manipulate or retrieve data from the database.
For example, creating a table in SQL will require writing a long SQL query. Doing this in Python will just require writing a class inheriting from the django.db package (Figure 2.1):
Figure 2.1 – Comparison between the Django ORM and SQL queries
In the preceding figure, you can see the SQL statement, which requires some knowledge of the syntax, as well as the fields and the options. The second code from the Django ORM does exactly the same thing but in a more Pythonic and less verbose manner.
Writing models with Django comes with several advantages:
Notice that you also have access to model managers. Django Manager is a class that behaves as an interface through which Django models interact with databases. Every Django model, by default, inherits the models.Manager class that comes with the necessary methods to make Create, Read, Update and Delete (CRUD) operations on the table in the database.
Now that we have a better understanding of Django models, let’s create the first model in this project, the User model. Working with our first model, we’ll also learn how to use the basic methods of the Django ORM to perform CRUD operations.
In the previous section, we saw how a model is represented as a class and how this can basically be created as a table in the database.
Talking about the User model, Django comes with a pre-built-in User model class that you can use for basic authentication or a session. It actually provides an authentication feature you can use to quickly add authentication and authorization to your projects.
While it’s great for most use cases, it has its limitations. For example, in this book, we are building a social media web application. The user in this application will have some bio or even an avatar. Why not also have a phone number for two-factor authentication (2FA)?
Actually, the User model of Django doesn’t come with these fields. This means we’ll need to extend it and have our own user model. This also means that we will have to add custom methods to the manager for creating a user and a superuser. This will speed up the coding process. In Django, a superuser is a user with administrator permission.
Before creating the model, we actually need an application, and to register it. A Django application is a submodule of a Django project. It’s a Python package structured to work in a Django project and share Django conventions such as containing files or submodules such as models, tests, urls, and views.
To start a new application in this project, run the following command:
cd core && django-admin startapp user
This will create a new package (directory) containing new files. Here’s the structure of the directory:
├── admin.py ├── apps.py ├── __init__.py ├── migrations │ └── __init__.py ├── models.py ├── tests.py └── views.py
We can now confidently start writing the User model. Here is the structure of the User table we want to have in the database:
Figure 2.2 – User table structure
And here’s the code concerning the User table structure:
core/user/models.py
import uuid from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.http import Http404 class User(AbstractBaseUser, PermissionsMixin): public_id = models.UUIDField(db_index=True, unique=True, default=uuid.uuid4, editable=False) username = models.CharField(db_index=True, max_length=255, unique=True) first_name = models.CharField(max_length=255) last_name = models.CharField(max_length=255) email = models.EmailField(db_index=True, unique=True) is_active = models.BooleanField(default=True) is_superuser = models.BooleanField(default=False) created = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now_add=True) USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['username'] objects = UserManager() def __str__(self): return f"{self.email}" @property def name(self): return f"{self.first_name} {self.last_name}"
The models module from Django provides some field utilities that can be used to write fields and add some rules. For example, CharField represents the type of field to create in the User table, similar to BooleanField. EmailField is also CharField but rewritten to validate the email that is passed as a value to this field.
We also set the EMAIL_FIELD as the email, and USERNAME_FIELD as the username. This will help us have two fields for login. The username can be the actual username of a user or just the email address used for registration.
We also have methods such as name, which is basically a model property. Then, it can be accessed anywhere on a User object, such as user.name. We are also rewriting the __str__ method to return a string that can help us quickly identify a User object.
Next, let’s write UserManager so we can have methods to create a user and a superuser:
core/user/models.py
class UserManager(BaseUserManager): def get_object_by_public_id(self, public_id): try: instance = self.get(public_id=public_id) return instance except (ObjectDoesNotExist, ValueError, TypeError): return Http404 def create_user(self, username, email, password=None, **kwargs): """Create and return a `User` with an email, phone number, username and password.""" if username is None: raise TypeError('Users must have a username.') if email is None: raise TypeError('Users must have an email.') if password is None: raise TypeError('User must have an email.') user = self.model(username=username, email=self.normalize_email(email), **kwargs) user.set_password(password) user.save(using=self._db) return user def create_superuser(self, username, email, password, **kwargs): """ Create and return a `User` with superuser (admin) permissions. """ if password is None: raise TypeError('Superusers must have a password.') if email is None: raise TypeError('Superusers must have an email.') if username is None: raise TypeError('Superusers must have an username.') user = self.create_user(username, email, password, **kwargs) user.is_superuser = True user.is_staff = True user.save(using=self._db) return user
For the create_user method, we are basically making sure that fields such as password, email, username, first_name, and last_name are not None. If everything is good, we can confidently call the model, set a password, and save the user in the table.
This is done using the save() method.
create_superuser also behaves in accordance with the create_user method – and it’s quite normal because, after all, a superuser is just a user with admin privileges, and also fields such as is_superuser and is_staff set to True. Once it’s done, we save the new User object in the database and return the user.
See the save method as a way to commit changes made to the User object to the database.
The model is written and now we need to run migrations to create the table in the database.
Before running the migrations, we need to register the user application in INSTALLED_APPS in CoreRoot/settings.py.
First, let’s rewrite the apps.py file of the user. It contains the app configs that Django will use to locate the application. Let’s also add a label for the application:
core/user/apps.py
from django.apps import AppConfig class UserConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core.user' label = 'core_user' Let's register the application now: 'core', 'core.user' ]
Let’s register the application now in the INSTALLED_APPS setting:
CoreRoot/settings.py
... 'core', 'core.user' ]
We also need to tell Django to use this User model for the authentication user model. In the settings.py file, add the following line:
CoreRoot/settings.py
AUTH_USER_MODEL = 'core_user.User'
Great – we can now create the first migration for the user app:
python manage.py makemigrations
You’ll have a similar output:
Migrations for 'core_user': core/user/migrations/0001_initial.py - Create model User
Let’s migrate this modification to the database:
python manage.py migrate
The table is created in the database. Let’s use the Django shell to play with the newly created model a little bit:
python manage.py shell
Let’s import the model and add a dict containing the data needed to create a user:
Python 3.10.1 (main, Dec 21 2021, 17:46:38) [GCC 9.3.0] on linux Type "help", "copyright", "credits" or "license" for more information. (InteractiveConsole) >>> from core.user.models import User >>> data_user = { ... "email": "[email protected]", ... "username": "john-doe", ... "password": "12345", ... "first_name": "John", ... "last_name": "Doe" ... } >>> user = User.objects.create_user(**data_user) The user is created in the database. Let's access some properties of the user object. >>> user.name 'John Doe' >>> user.email '[email protected]' >>> user.password 'pbkdf2_sha256$320000$NxM7JZ0cQ0OtDzCVusgvV7$fM1WZp7QhHC3QEajnb Bjo5rBPKO+Q8ONhDFkCV/gwcI='
Great – we’ve just written the model and created the first user. However, a web browser won’t directly read the user data from our database – and worse, we are working with a Python native object, and a browser or a client reaching our server to make requests mostly supports JSON or XML. One idea would be to use the json library, but we are dealing with a complex data structure; complex data structures can be easily handled with serializers.
Let’s write serializers in the next section.
A serializer allows us to convert complex Django complex data structures such as QuerySet or model instances into Python native objects that can be easily converted to JSON or XML format. However, a serializer also serializes JSON or XML to native Python. Django Rest Framework (DRF) provides a serializers package you can use to write serializers and also validations when API calls are made to an endpoint using this serializer. Let’s install the DRF package and make some configurations first:
pip install djangorestframework django-filter
Don’t forget to add the following to the requirements.txt file:
requirements.txt
Django==4.0.1 psycopg2-binary==2.9.3 djangorestframework==3.13.1 django-filter==21.1
We are also adding django-filter for data filtering support. Let’s add rest_framework to the INSTALLED_APPS setting:
CoreRoot/settings.py
INSTALLED_APPS = [ ... 'rest_framework', ]
In the core/user directory, create a file called serializers.py. This file will contain the UserSerializer class:
core/user/serializers.py
from rest_framework import serializers from core.user.models import User class UserSerializer(serializers.ModelSerializer): id = serializers.UUIDField(source='public_id', read_only=True, format='hex') created = serializers.DateTimeField(read_only=True) updated = serializers.DateTimeField(read_only=True) class Meta: model = User fields = ['id', 'username', 'first_name', 'last_name', 'bio', 'avatar', 'email', 'is_active', 'created', 'updated'] read_only_field = ['is_active']
The UserSerializer class inherits from the serializers.ModelSerialzer class. It’s a class inheriting from the serializers.Serializer class but has deep integrations for supporting a model. It’ll automatically match the field of the model to have the correct validations for each one.
For example, we’ve stated that the email is unique. Then, every time someone registers and enters an email address that already exists in the database, they will receive an error message concerning this.
The fields attribute contains all the fields that can be read or written. Then, we also have the read_only fields. These fields are only readable. This means that they can’t be modified and it’s definitely better like that. Why give the external user the possibility to modify the created, updated, or id fields?
Now that UserSerializer is available, we can now write viewset.
As we know, Django at its core is based on the Model-View-Template (MVT) architecture. The model communicates with the views (or controllers) and the template displays responses or redirects requests to the views.
However, when Django is coupled with DRF, the model can be directly connected to the view. However, as good practice, use a serializer between a model and a viewset. This really helps with validation and also some important checks.
So, what is a viewset then? DRF provides a class named APIView from which a lot of classes from DRF inherit to perform CRUD operations. Therefore, a viewset is simply a class-based view that can handle all the basic HTTP requests—GET, POST, PUT, DELETE, and PATCH—without hardcoding any CRUD logic here.
For the viewset user, we are only allowing the PATCH and GET methods. Here’s what the endpoints will look like:
Method |
URL |
Result |
GET |
/api/user/ |
Lists all the users |
GET |
/api/user/user_pk/ |
Retrieves a specific user |
PATCH |
/api/user/user_pk/ |
Modifies a user |
Table 1.1 – Endpoints
Let’s write the viewset. Inside the user directory, rename the view file viewsets.py and add the following content:
core/user/viewsets.py
from rest_framework.permissions import AllowAny from rest_framework import viewsets from core.user.serializers import UserSerializer from core.user.models import User class UserViewSet(viewsets.ModelViewSet): http_method_names = ('patch', 'get') permission_classes = (AllowAny,) serializer_class = UserSerializer def get_queryset(self): if self.request.user.is_superuser: return User.objects.all() return User.objects.exclude(is_superuser=True) def get_object(self): obj = User.objects.get_object_by_public_id(self.kwargs['pk']) self.check_object_permissions(self.request, obj) return obj
The only methods allowed here are GET and PUT. We also set serializer_class and permission_classes to AllowAny, which means that anybody can access these viewsets. We also rewrite two methods:
There we have the User viewset – but there is no endpoint yet to make it work. Well, let’s add a router now.
Routers allow you to quickly declare all of the common routes for a given controller; the next code snippet shows a viewset to which we will be adding a router.
At the root of the apps project (core), create a file named routers.py.
And let’s add the code:
core/routers.py
from rest_framework import routers from core.user.viewsets import UserViewSet router = routers.SimpleRouter() # ##################################################################### # # ################### USER ###################### # # ##################################################################### # router.register(r'user', UserViewSet, basename='user') urlpatterns = [ *router.urls, ]
To register a route for a viewset, the register() method needs two arguments:
The basename argument is optional but it’s a good practice to use one, as it helps for readability and also helps Django for URL registry purposes.
The router is now added; we can make some requests to the API using Insomnia.
Important note
Insomnia is a REST client tool used to make requests to RESTful API. With Insomnia, you can manage and create your requests elegantly. It offers support for cookie management, environment variables, code generation, and authentication.
Before doing that, make sure to have the server running:
python manage.py runserver
Let’s make a request to http://127.0.0.1:8000/api/user/, a GET request. Look at the following screenshot and make sure to have the same URL – or you can replace 127.0.0.1 with localhost --, next to the Send button.
Figure 2.3 – Listing all users
As you can see, we have a list of users created. Let’s also make a GET request to retrieve the first user using this URL: /api/user/<id>/.
Figure 2.4 – Retrieving a user
We have now a User object. This endpoint also allows PATCH requests. Let’s set the last_name value for this user to Hey. Change the type of request to PATCH and add a JSON body.
Figure 2.5 – Modifying a user without permissions
Although it’s working, it’s actually a very bad scenario. We can’t have users modify other user names or data. A solution is to change the permission on the permission_classes attribute in the UserViewSet class:
core/user/viewsets.py
from rest_framework.permissions import IsAuthenticated ... class UserViewSet(viewsets.ModelViewSet): http_method_names = ('patch', 'get') permission_classes = (IsAuthenticated,) serializer_class = UserSerializer ...
Let’s try the PATCH request again.
Figure 2.6 – Modifying a user without permissions
We normally have a 401 status, an indication of an authentication issue. Basically, it means that an authentication header should be provided. They are more permissions to add concerning interactions with users, but let’s discuss this in later chapters.
Great. Now that we are done with the user application, we can confidently move on to adding a login and registration feature to the project.
Before accessing protected data, the user needs to be authenticated. This comes with the assumption that there is a registration system to create an account and credentials.
To make things simpler, if the registration of a user is successful, we will provide credentials, here JWTs, so the user won’t have to log in again to start a session – a win for user experience.
First, let’s install a package that will handle JWT authentication for us. The djangorestframework-simplejwt package is a JWT authentication plugin for DRF:
pip install djangorestframework-simplejwt
The package covers the most common use case of JWT, and in this case here, it facilitates the creation and management of access tokens, as well as refreshing tokens. Before working with this package, there are some configurations needed in the settings.py file. We need to register the app in INSTALLED_APPS and specify DEFAULT_AUTHENTICATION_CLASSES in the REST_FRAMEWORK dict:
CoreRoot/settings.py
… # external packages apps 'rest_framework', 'rest_framework_simplejwt', 'core', 'core.user' ] ... REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication .JWTAuthentication', ), 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], }
First, we need to write a registration serializer, but before that, let’s create a new application called auth in the core app:
cd core && django-admin startapp auth
It’ll contain all the logic concerning logging in, registration, logging out, and a lot more.
As we did earlier for the user application, let’s rewrite the apps.py file and register the application in the INSTALLED_APPS settings:
core/auth/apps.py
from django.apps import AppConfig class AuthConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'core.auth' label = 'core_auth' And adding the new application to INSTALLED_APPS: ... 'core', 'core.user', 'core.auth' ] ...
Remove the admin.py and models.py files from the auth directory, as we won’t be working with them. For registration and login, we’ll have many serializers and viewsets, so let’s organize the code accordingly. Create a Python package called serializers and another one called viewsets. Make sure that these new directories have an __init__.py file. Here’s how your auth app tree should look:
├── apps.py ├── __init__.py ├── migrations │ ├── __init__.py ├── serializers │ └── __init__.py ├── tests.py ├── viewsets │ └── __init__.py └── views.py
Inside the serializers directory, create a file called register.py. It’ll contain the code for RegisterSerializer, which is the name of the registration serializer class:
core/auth/serializers/register.py
from rest_framework import serializers from core.user.serializers import UserSerializer from core.user.models import User class RegisterSerializer(UserSerializer): """ Registration serializer for requests and user creation """ # Making sure the password is at least 8 characters long, and no longer than 128 and can't be read # by the user password = serializers.CharField(max_length=128, min_length=8, write_only=True, required=True) class Meta: model = User # List of all the fields that can be included in a request or a response fields = ['id', 'bio', 'avatar', 'email', 'username', 'first_name', 'last_name', 'password'] def create(self, validated_data): # Use the `create_user` method we wrote earlier for the UserManager to create a new user. return User.objects.create_user(**validated_data)
As you can see, RegisterSerializer is a subclass of UserSerializer. This is really helpful, as we don’t need to rewrite fields again.
Here, we don’t need to revalidate fields such as email or password. As we declared these fields with some conditions, Django will automatically handle their validation.
Next, we can add the viewset and register it in the register.py file:
core/auth/viewsets/register.py
from rest_framework.response import Response from rest_framework.viewsets import ViewSet from rest_framework.permissions import AllowAny from rest_framework import status from rest_framework_simplejwt.tokens import RefreshToken from core.auth.serializers import RegisterSerializer class RegisterViewSet(ViewSet): serializer_class = RegisterSerializer permission_classes = (AllowAny,) http_method_names = ['post'] def create(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() refresh = RefreshToken.for_user(user) res = { "refresh": str(refresh), "access": str(refresh.access_token), } return Response({ "user": serializer.data, "refresh": res["refresh"], "token": res["access"] }, status=status.HTTP_201_CREATED)
Nothing really new here – we are using attributes from the ViewSet class. We are also rewriting the create method to add access and refresh tokens in the body of the response. The djangorestframework-simplejwt package provides utilities we can use to directly generate tokens. That’s what RefreshToken.for_user(user) does.
And the final step – let’s register the viewset in the routers.py file:
core/routers.py
... # ##################################################################### # # ################### AUTH ###################### # # ##################################################################### # router.register(r'auth/register', RegisterViewSet, basename='auth-register') ...
Great! Let’s test the new endpoint with Insomnia. In the collection of requests for this project, create a new POST request. The URL will be as follows: localhost:8000/api/auth/register/.
As a body for the request, you can pass the following:
{ "username": "mouse21", "first_name": "Mickey", "last_name": "Mouse", "password": "12345678", "email": "[email protected]" }
With that, send the request. You should have a response similar to that shown in Figure 2.6 with a 201 HTTP status:
Figure 2.7 – Registering a user
Let’s see what happens if we try to create a user with the same email and username. Hit the Send button to send the same request again. You should receive a 400 error.
Figure 2.8 – Registering a user with the same email and username
Great. We are now sure that the endpoint behaves as we wish. The next step will be to add the login endpoint following the same process: writing the serializer and the viewset, and then registering the route.
The login feature will require the email or the username with the password. Using the djangorestframework-simplejwt package, which provides a serializer called TokenObtainPairSerializer, we’ll write a serializer to check for user authentication but also return a response containing access and refresh tokens. For this, we will rewrite the validate method from the TokenObtainPairSerializer class. Inside the core/auth/serializers directory, create a new file called login.py (this file will contain LoginSerializer, a subclass of TokenObtainPairSerializer):
core/auth/serializers/login.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.settings import api_settings from django.contrib.auth.models import update_last_login from core.user.serializers import UserSerializer class LoginSerializer(TokenObtainPairSerializer): def validate(self, attrs): data = super().validate(attrs) refresh = self.get_token(self.user) data['user'] = UserSerializer(self.user).data data['refresh'] = str(refresh) data['access'] = str(refresh.access_token) if api_settings.UPDATE_LAST_LOGIN: update_last_login(None, self.user) return data
We are surcharging the validate method from the TokenObtainPairSerializer class to adapt it to our needs. That’s why super is helpful here. It’s a built-in method in Python that returns a temporary object that can be used to access the class methods of the base class.
Then, we use user to retrieve access and refresh tokens. Once the serializer is written, don’t forget to import it to the __init__.py file:
core/auth/serializers/__init__.py
from .register import RegisterSerializer from .login import LoginSerializer
The next step is to add the viewset. We’ll call this viewset LoginViewset. As we are not directly interacting with a model here, we’ll just be using the viewsets.ViewSet class:
core/auth/viewsets/login.py
from rest_framework.response import Response from rest_framework.viewsets import ViewSet from rest_framework.permissions import AllowAny from rest_framework import status from rest_framework_simplejwt.exceptions import TokenError, InvalidToken from core.auth.serializers import LoginSerializer class LoginViewSet(ViewSet): serializer_class = LoginSerializer permission_classes = (AllowAny,) http_method_names = ['post'] def create(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) try: serializer.is_valid(raise_exception=True) except TokenError as e: raise InvalidToken(e.args[0]) return Response(serializer.validated_data, status=status.HTTP_200_OK)
Add the viewset to the __init__.py file of the viewsets directory:
from .register import RegisterViewSet from .login import LoginViewSet
We can now import it and register it in the routers.py file:
core/routers.py
... from core.auth.viewsets import RegisterViewSet, LoginViewSet router = routers.SimpleRouter() # ##################################################################### # # ################### AUTH ###################### # # ##################################################################### # router.register(r'auth/register', RegisterViewSet, basename='auth-register') router.register(r'auth/login', LoginViewSet, basename='auth-login') ...
The endpoint for login will be available at /auth/login/. Let’s try a request with Insomnia.
Here’s the body of the request I’ll use:
{ "password": "12345678", "email": "[email protected]" }
Figure 2.9 – Login with user credentials
The login feature is ready and working like a charm – but we have a little bit of an issue. The access token expires in 5 minutes. Basically, to get a new access token, the user will have to log in again. Let’s see how we can use the refresh token to request a new access token without logging in again.
djangorestframework-simplejwt provides a refresh logic feature. As you’ve noticed, we’ve been generating refresh tokens and returning them as responses every time registration or login is completed. We’ll just inherit the class from TokenRefreshView and transform it into a viewset.
In auth/viewsets, add a new file called refresh.py:
core/auth/viewsets/refresh.py
from rest_framework.response import Response from rest_framework_simplejwt.views import TokenRefreshView from rest_framework.permissions import AllowAny from rest_framework import status from rest_framework import viewsets from rest_framework_simplejwt.exceptions import TokenError, InvalidToken class RefreshViewSet(viewsets.ViewSet, TokenRefreshView): permission_classes = (AllowAny,) http_method_names = ['post'] def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) try: serializer.is_valid(raise_exception=True) except TokenError as e: raise InvalidToken(e.args[0]) return Response(serializer.validated_data, status=status.HTTP_200_OK) Now add the class in the __init__.py file. from .register import RegisterViewSet from .login import LoginViewSet from .refresh import RefreshViewSet
Now add the class in the __init__.py file.
core/auth/viewsets/__init__.py
from .register import RegisterViewSet from .login import LoginViewSet from .refresh import RefreshViewSet
And now register it in the routers.py file:
core/routers.py
from core.auth.viewsets import RegisterViewSet, LoginViewSet, RefreshViewSet ... router.register(r'auth/refresh', RefreshViewSet, basename='auth-refresh') ...
Great – let’s test the new endpoint at /auth/refresh/ to get a new token. It’ll be a POST request with the refresh token in the body of the request, and you will receive a new access token in the response:
Figure 2.10 – Requesting for a new access token
Great – we’ve just learned how to implement refresh token logic in the application.
In this chapter, we learned how to write an authentication system based on JWT for a Django application using DRF and djangorestframework-simplejwt. We also learned how to extend classes and rewrite the functions.
In the next chapter, we’ll add the posts feature. Our users will be able to create a post that can be viewed and liked by other users.