FullStack JWT Auth: User serializers, Views, and Endpoints

sirneij

John Owolabi Idogun

Posted on February 8, 2022

FullStack JWT Auth: User serializers, Views, and Endpoints

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:

GitHub logo 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, or venv. 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)

Enter fullscreen mode Exit fullscreen mode

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 expects email, username, password,bio, and full_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 default create method. The validate_email method ensures the inputted email address is truly an email address by using a method in utils.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, ''

Enter fullscreen mode Exit fullscreen mode

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 uses get_tokens method to fetch the requesting user's pair of tokens. It also ensures all inputted data are properly validated via the validate 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)

Enter fullscreen mode Exit fullscreen mode

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})

Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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', {})
...
Enter fullscreen mode Exit fullscreen mode

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'),
]
Enter fullscreen mode Exit fullscreen mode

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')),
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',),
}
...
Enter fullscreen mode Exit fullscreen mode

Now we are sure to have something like:

{
  "errors": {
    "email": ["Enter a valid email address."]
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
sirneij
John Owolabi Idogun

Posted on February 8, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related