Introduction
This is the second article in this series which aim to build a full stack #transitionalapp
. This article will be totally based on implementing the endpoints that will be consumed by our SvelteKit app.
Source code
The overall source code for this project can be accessed here:
Sirneij / django_svelte_jwt_auth
A robust and secure Authentication and Authorization System built with Django and SvelteKit
django_svelte_jwt_auth
This is the codebase that follows the series of tutorials on building a FullStack JWT Authentication and Authorization System with Django and SvelteKit.
This project was deployed on heroku (backend) and vercel (frontend) and its live version can be accessed here.
To run this application locally, you need to run both the backend
and frontend
projects. While the latter has some instructions already for spinning it up, the former can be spinned up following the instructions below.
Run locally
To run locally
-
Clone this repo:
git clone https://github.com/Sirneij/django_svelte_jwt_auth.git
-
Change directory into the
backend
folder:cd backend
-
Create a virtual environment:
pipenv shell
You might opt for other dependencies management tools such as
virtualenv
,poetry
, orvenv
. It's up to you. -
Install the dependencies:
pipenv install
-
Make migrations and migrate the database:
python manage.py makemigrations python manage.py migrate
-
Finally, run the application:
python manage.py runserver
Live version
This project was deployed on heroku (backend) and vercel (frontend) and its live version can be accessed here.
Step 1: User serializers
Create a serializers.py
file in the accounts
app and fill it with the following:
# backend -> accounts -> serializers.py
from django.contrib.auth import authenticate
from rest_framework import exceptions, serializers
from rest_framework_simplejwt.tokens import RefreshToken, TokenError
from .models import User
from .utils import validate_email as email_is_valid
class RegistrationSerializer(serializers.ModelSerializer[User]):
"""Serializers registration requests and creates a new user."""
password = serializers.CharField(max_length=128, min_length=8, write_only=True)
class Meta:
model = User
fields = [
'email',
'username',
'password',
'bio',
'full_name',
]
def validate_email(self, value: str) -> str:
"""Normalize and validate email address."""
valid, error_text = email_is_valid(value)
if not valid:
raise serializers.ValidationError(error_text)
try:
email_name, domain_part = value.strip().rsplit('@', 1)
except ValueError:
pass
else:
value = '@'.join([email_name, domain_part.lower()])
return value
def create(self, validated_data): # type: ignore
"""Return user after creation."""
user = User.objects.create_user(
username=validated_data['username'], email=validated_data['email'], password=validated_data['password']
)
user.bio = validated_data.get('bio', '')
user.full_name = validated_data.get('full_name', '')
user.save(update_fields=['bio', 'full_name'])
return user
class LoginSerializer(serializers.ModelSerializer[User]):
email = serializers.CharField(max_length=255)
username = serializers.CharField(max_length=255, read_only=True)
password = serializers.CharField(max_length=128, write_only=True)
is_staff = serializers.BooleanField(read_only=True)
tokens = serializers.SerializerMethodField()
def get_tokens(self, obj): # type: ignore
"""Get user token."""
user = User.objects.get(email=obj.email)
return {'refresh': user.tokens['refresh'], 'access': user.tokens['access']}
class Meta:
model = User
fields = ['email', 'username', 'password', 'tokens', 'is_staff']
def validate(self, data): # type: ignore
"""Validate and return user login."""
email = data.get('email', None)
password = data.get('password', None)
if email is None:
raise serializers.ValidationError('An email address is required to log in.')
if password is None:
raise serializers.ValidationError('A password is required to log in.')
user = authenticate(username=email, password=password)
if user is None:
raise serializers.ValidationError('A user with this email and password was not found.')
if not user.is_active:
raise serializers.ValidationError('This user is not currently activated.')
return user
class UserSerializer(serializers.ModelSerializer[User]):
"""Handle serialization and deserialization of User objects."""
password = serializers.CharField(max_length=128, min_length=8, write_only=True)
class Meta:
model = User
fields = (
'email',
'username',
'password',
'tokens',
'bio',
'full_name',
'birth_date',
'is_staff',
)
read_only_fields = ('tokens', 'is_staff')
def update(self, instance, validated_data): # type: ignore
"""Perform an update on a User."""
password = validated_data.pop('password', None)
for (key, value) in validated_data.items():
setattr(instance, key, value)
if password is not None:
instance.set_password(password)
instance.save()
return instance
class LogoutSerializer(serializers.Serializer[User]):
refresh = serializers.CharField()
def validate(self, attrs): # type: ignore
"""Validate token."""
self.token = attrs['refresh']
return attrs
def save(self, **kwargs): # type: ignore
"""Validate save backlisted token."""
try:
RefreshToken(self.token).blacklist()
except TokenError as ex:
raise exceptions.AuthenticationFailed(ex)
That's a lot of snippets! However, if you are somewhat familiar with Django REST Framework, it shouldn't be hard to decipher. Let's zoom in on each serializer.
-
RegistrationSerializer
: This is the default serializer for user registration. It expectsemail
,username
,password
,bio
, andfull_name
fields to be supplied during registration. As expected,password
was made to be write_only to prevent making it readable to users. It houses a custom validation method,validate_email
, and overrides defaultcreate
method. Thevalidate_email
method ensures the inputted email address is truly an email address by using a method inutils.py
file for proper checking. The content of this file is:
#backend -> accounts -> utils.py
from django.core.exceptions import ValidationError
from django.core.validators import validate_email as django_validate_email
def validate_email(value: str) -> tuple[bool, str]:
"""Validate a single email."""
message_invalid = 'Enter a valid email address.'
if not value:
return False, message_invalid
# Check the regex, using the validate_email from django.
try:
django_validate_email(value)
except ValidationError:
return False, message_invalid
return True, ''
For the create
method, it's pretty straightforward. We simply create a user using create_user
method defined in our custom Manager in the previous article and then use the performant update_fields
argument to save bio
and full_name
.
LoginSerializer
: The app's default login serializer. It usesget_tokens
method to fetch the requesting user's pair of tokens. It also ensures all inputted data are properly validated via thevalidate
method.UserSerializer
will later be used to update the requesting user's data.LogoutSerializer
: This serializer tends to use Simple JWT's blacklist feature to ensure that such token is made invalid and can't be used for future requests. This is the reason we opted for the library. See the documentation for details about this.
Step 2: Views and Endpoints
Now that we have painstakingly defined our serializers, let's forge ahead to create the views that will handle all the requests. Open up accounts/views.py
file and fill the following in:
#backend -> accounts -> views.py
from typing import Any, Optional
from django.conf import settings
from rest_framework import status
from rest_framework.generics import RetrieveUpdateAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import User
from .renderers import UserJSONRenderer
from .serializers import (
LoginSerializer,
LogoutSerializer,
RegistrationSerializer,
UserSerializer,
)
class RegistrationAPIView(APIView):
permission_classes = (AllowAny,)
renderer_classes = (UserJSONRenderer,)
serializer_class = RegistrationSerializer
def post(self, request: Request) -> Response:
"""Return user response after a successful registration."""
user_request = request.data.get('user', {})
serializer = self.serializer_class(data=user_request)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
class LoginAPIView(APIView):
permission_classes = (AllowAny,)
renderer_classes = (UserJSONRenderer,)
serializer_class = LoginSerializer
def post(self, request: Request) -> Response:
"""Return user after login."""
user = request.data.get('user', {})
serializer = self.serializer_class(data=user)
if not serializer.is_valid():
print(serializer.errors)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
return Response(serializer.data, status=status.HTTP_200_OK)
class UserRetrieveUpdateAPIView(RetrieveUpdateAPIView):
permission_classes = (IsAuthenticated,)
renderer_classes = (UserJSONRenderer,)
serializer_class = UserSerializer
def retrieve(self, request: Request, *args: dict[str, Any], **kwargs: dict[str, Any]) -> Response:
"""Return user on GET request."""
serializer = self.serializer_class(request.user, context={'request': request})
return Response(serializer.data, status=status.HTTP_200_OK)
def update(self, request: Request, *args: dict[str, Any], **kwargs: dict[str, Any]) -> Response:
"""Return updated user."""
serializer_data = request.data.get('user', {})
serializer = self.serializer_class(
request.user, data=serializer_data, partial=True, context={'request': request}
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
class LogoutAPIView(APIView):
serializer_class = LogoutSerializer
permission_classes = (IsAuthenticated,)
def post(self, request: Request) -> Response:
"""Validate token and save."""
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(status=status.HTTP_204_NO_CONTENT)
They are simple views that inherit from APIView
and some other generic classes shipped with Django REST Framework. It should be noted that we have a custom UserJSONRenderer
class located in renderers.py
with the following content:
#backend -> accounts -> renderers.py
import json
from typing import Any, Mapping, Optional
from rest_framework.renderers import JSONRenderer
class UserJSONRenderer(JSONRenderer):
"""Custom method."""
charset = 'utf-8'
def render(
self,
data: dict[str, Any],
media_type: Optional[str] = None,
renderer_context: Optional[Mapping[str, Any]] = None,
) -> str:
"""Return a well formatted user jSON."""
errors = data.get('errors', None)
token = data.get('token', None)
if errors is not None:
return super(UserJSONRenderer, self).render(data)
if token is not None and isinstance(token, bytes):
# Also as mentioned above, we will decode `token` if it is of type
# bytes.
data['token'] = token.decode('utf-8')
# Finally, we can render our data under the "user" namespace.
return json.dumps({'user': data})
The whole essence of this class is to give a custom formatting of the request and response data to our endpoints. If it was not defined, our endpoints would expect and respond with data in the following format:
{
"email": "sirneij@xyz.com",
"username": "sirjon",
"password": "somepassword",
"bio": "I am a researcher",
"full_name": "John Owolabi Nelson"
}
But with that renderer in place, the expected data format will be:
{
"user": {
"email": "sirneij@xyz.com",
"username": "sirjon",
"password": "somepassword",
"bio": "I am a researcher",
"full_name": "John Owolabi Nelson"
}
}
This is not required but preferred by me. If you don't want this, you can omit it and also remove this line from all the views:
...
user = request.data.get('user', {})
...
and point the data
attribute of your serializer class to request.data
instead.
Let's make our endpoints now. Create a urls.py
in the accounts
app and make the content look like:
#backend -> accounts -> urls.py
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .views import (
LoginAPIView,
LogoutAPIView,
RegistrationAPIView,
UserRetrieveUpdateAPIView,
)
app_name = 'accounts'
urlpatterns = [
path('register/', RegistrationAPIView.as_view(), name='register_user'),
path('login/', LoginAPIView.as_view(), name='login_user'),
path('logout/', LogoutAPIView.as_view(), name="logout_user"),
path('user/', UserRetrieveUpdateAPIView.as_view(), name='user'),
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
The last path, path('token/refresh/', TokenRefreshView.as_view(),name='token_refresh')
, is important to help recreate access
tokens for existing users who want to login.
Now, let's make django aware of these patterns. Open up backend/urls.py
and make it look like:
# backend -> backend -> urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('accounts.urls', namespace='accounts')),
]
Voilà! Our endpoints are up and running! You can test them using Postman or anything. But I prefer this awesome VS Code extension, Thunder Client. It's nice and sleek. Ensure you send in the proper data to avoid errors. Concerning the error responses, let's make one last customization to our endpoint. Create a new file in the accounts
app, call it whatever you like but I will pick exceptions.py
. Fill it with the following:
#backend -> accounts -> exceptions.py
from typing import Any, Optional
from rest_framework.response import Response
from rest_framework.views import exception_handler
def core_exception_handler(exc: Exception, context: dict[str, Any]) -> Optional[Response]:
"""Error handler for the API."""
response = exception_handler(exc, context)
handlers = {'ValidationError': _handle_generic_error}
exception_class = exc.__class__.__name__
if exception_class in handlers:
return handlers[exception_class](exc, context, response)
return response
def _handle_generic_error(exc: Exception, context: dict[str, Any], response: Optional[Response]) -> Optional[Response]:
if response:
response.data = {'errors': response.data}
return response
return None
Then go to to your settings.py
file and point REST framework to use this custom error format:
#backend -> backend -> settings.py
...
# REST_FRAMEWORK
REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'accounts.exceptions.core_exception_handler',
'NON_FIELD_ERRORS_KEY': 'error',
'DEFAULT_AUTHENTICATION_CLASSES': ('rest_framework_simplejwt.authentication.JWTAuthentication',),
}
...
Now we are sure to have something like:
{
"errors": {
"email": ["Enter a valid email address."]
}
}
In case of field errors!
That's it! We are done with the back-end! Let's move to the front-end stuff where we'll talk about the awesome SvelteKit
! I can't wait!!!
Outro
Enjoyed this article, consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn.
Top comments (2)
Hello John Idogun! I have completed the 2nd tutorial. Now, I'm curious about token saved in the database. So, after I logging in the user, the system generated two outstanding tokens. I just want to confirm this, is it right? Also when I retrieve the user data, it also generates one new outstanding token. Is it also right? I know it from Django admin panel. Thanks in advance. By the way, I really like this kind of tutorial.
Yeah. All tokens are generated, processed, and stored by Simple JWT. At loggin, two tokens,
"access"
and"refresh"
are always generated hence the storage you noticed."access"
token lives for a very short time and needs to be regenerated.