Integrating One-time Password In Django Rest Framework

Integrating One-time Password In Django Rest Framework

INTRODUCTION

One-Time Password or One-Time Pin (OTP) is a security feature used to provide an extra layer of security for authenticating a user. A one-time password (OTP) as the name implies is a password that is used to validate one login session. This means that a user cannot log in more than once with it. Oftentimes, a One-time password expires after a short period. It is an automatically generated string of characters or integers. There are several reasons developers integrate One-time passwords in their applications. These include:

  • Email validation

  • Enhanced security for user authentication

  • Protection against password theft

  • Improved user experience with two-factor authentication (2FA)

There are various effective ways to deliver One-Time Passwords (OTPs). Some of these include:

  • Emails

  • Verified phone numbers

  • Simple Mail Transfer Protocol(SMTP) services. Like

  • Gmail SMTP Server
  • Yahoo SMTP Server
  • Outlook SMTP Server
  • Amazon SES SMTP Server
  • Mailgun SMTP Server
  • Sendgrid SMTP Server
  • Zoho SMTP Server
  • AOL SMTP Server
  • Verizon SMTP Server
  • AT&T SMTP Server

In this article, OTP will be implemented using a Zoho SMTP server.

PREREQUISITES

Python

Django Rest Framework (DRF)

SET UP THE PROJECT ENVIRONMENT

There are some basic setups you need before starting a Python project. Create and activate a virtual environment. Follow these steps to create a virtual environment with the code below.

  1. Create a virtual environment
python -m venv venv

For versions of Python below 3.12.1, use the code below

python3 -m venv {venv name}

The number ‘3’ is the major difference between the commands. Henceforth, always add ‘3’ to each Python command you run if you use a lower version of Python as this article will be using python3.12.1. You can choose to name your virtual environment with whatever name you like but for simplicity's sake, venv is used.

  1. Activate the virtual environment
venv/Scripts/activate
  1. Install Django and Django Rest Framework (DRF) within the virtual environment. Install django with this command below
pip install django
  1. And Django Rest Framework (DRF) with the command below
pip install djangorestframework
  1. Install rest framework jwt
    pip install djangorestframework-simplejwt
    

BUILD A SAMPLE PROJECT

  1. Create a sample project to test the OTP implementation.
django-admin startproject sampleproject

This will create a folder called sampleproject that contains the basic structure of a django project in it.

  1. Optionally, rename the nested sampleproject to core to simplify the project structure.
Rename-Item -Path "C:\Users\Desktop\otp-implementation\sampleproject\sampleproject" -NewName "core"
  1. Navigate to manage.py file and configure your project to point to the core.
def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') #change this to point core
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()

Save and exit

  1. Navigate to settings.py file and update the ROOT_URLCONF
    ROOT_URLCONF = 'core.urls'
    
  2. Update WSGI_APPLICATION to point to core in settings.py
    WSGI_APPLICATION = 'core.wsgi.application'
    
    Save and exit
  3. Navigate to settings.py in core
    cd  core
    
  4. Configure DRF to use simple jwt in settings.py
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
}

Save and exit.

  1. Open urls.py file in core and add SimpleJWT URLs.
from django.urls import path
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    ...
    path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    ...
]

Save and exit

  1. Navigate into your project directory
cd sampleproject
  1. Create an accounts app within your project directory.
django-admin startapp accounts

This will create a folder called accounts with the basic structure of an app in it.

  1. Navigate to the core directory
cd core
  1. Include accounts to the list of INSTALLED_APPS in settings.py
# ... other configurations
INSTALLED_APPS = [
    'daphne',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'accounts',                   #new
    'rest_framework',             #new
    'rest_framework_simplejwt',   #new
]

Save and exit

  1. Navigate to the accounts directory
cd ../accounts
  1. Create a sample model for accounts in models.py file
class User(AbstractUser):
    """ Database model for users in the system """
    username = None
    email = models.EmailField(max_length=255, unique=True)
    full_name = models.CharField(max_length=255)
    created_at = models.DateTimeField(auto_now=True)
    updated_at =models.DateTimeField(auto_now_add=True)


    objects = UserProfileManager()


    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    def __str__(self):
        """ Return string representation of our user """
        return self.email

Save and exit

This is a sample user model that inherits from the Abstract model and creates a column in the database column for username, email and password. The database can be structured to the desired fields of your choice

  1. Run migrations to create a migration file, which represent the changes made to the database model
python manage.py makemigrations
  1. Apply these changes to the database
python manage.py migrate

The command above will apply all changes to the database

  1. Create a serialializer.py file within your app directory.
from django.contrib.auth import authenticate
from rest_framework import serializers
from django.contrib.auth import get_user_model

User = get_user_model()


class RegisterUserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['pk', 'full_name', 'email', 'password']
        extra_kwargs = {
            'password': {'write_only': True},
            'pk': {'read_only': True},
 }

    def create(self, validated_data):
        password = validated_data.pop('password', None)
        instance = self.Meta.model(**validated_data)
        if password is not None:
            instance.set_password(password)
            instance.save()
            return instance

The serializer class inherits from the base Modelserializer. It serializes the User model that was created in the model.py and for the fields, serializes pk, full_name, email, password. The extra_kwargs ensures that the password is write_only and the pk is read_only.

Save and exit

  1. Open the views.py file within the accounts directory, to create a register user logic.
from rest_framework.response import Response
from rest_framework import generics, status
from rest_framework.exceptions import ValidationError
from rest_framework_simplejwt.tokens import RefreshToken
from .serializers import RegisterUserSerializer
from django.contrib.auth import get_user_model


User = get_user_model()

class UserCreateAPIView(generics.CreateAPIView):
    queryset = User.objects.all()
    serializer_class = RegisterUserSerializer

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        try:
            serializer.is_valid(raise_exception=True)
            user = serializer.save()
            refresh = RefreshToken.for_user(user)

            user.save()

            return Response({
                'user': {
                    'id': user.id,
                    'email': user.email,
                    'full_name': user.full_name,
                },
                'token': {
                    'refresh': str(refresh),
                    'access': str(refresh.access_token),
                },
            }, status=status.HTTP_201_CREATED)
        except ValidationError as e:
            if 'email' in str(e) and 'unique' in str(e):
                return Response({'error': 'User with this email already exists.'}, status=status.HTTP_400_BAD_REQUEST)
            else:
                return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)

user_create = UserCreateAPIView.as_view()

This is a sample logic to register a new user, It inherits from the generics.CreateAPIView, fetches all User from the database then uses the RegisterUserSerializer that was created in the serializers.py file to serialize and deserialize all incoming and outgoing data. There is a try and except block that handles errors more gracefully. Within the try block, the logic for creating a user is implemented, also a simple_jwt token is used for the token-based authentication. The except block catches possible errors and failures, It handles errors when the email is not unique and other possible server errors. It is best practice to handle errors in your code and one way this can be done in Python is to use the try and except block

  1. Navigate to your settings.py file within core
cd ../core
  1. Open the settings.py file and configure your accounts user
AUTH_USER_MODEL = 'accounts.User'

This allows django to use the new custom user class which is accounts.User

Save and exit

  1. Include accounts' url to the sampleproject urls.py in the accounts

from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/accounts/', include('accounts.urls')),    #new
    path('api/accounts/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/accounts/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]

Save and exit

  1. Run your server to make sure everything is working fine with the command below
python manage.py runserver

Navigate to your browser to see if your server is running as it should.

Implementing OTP In Django Rest Framework

Do the basic Zoho setup to integrate OTP on your django rest framework.

Set up Zoho

Go to the official Zoho site OFFICIAL WEBSITE and create an account if you do not have one. Configure your Zoho server with your preferred name and get the credentials needed for SMTP configuration. This includes port, host, password etc. This is sensitive information that you should not expose to the general public. Using a .env file to store sensitive information like these is best practice.

Set UP Environment Variables

python-dotenv is a Python third-party package that can be used to load files directly from your local machine. It reads key-value pairs from a .env file Install python-dotenv using pip

pip install python-dotenv

Create a .env file in your project directory and add your Zoho credentials to it. This .env file should not be committed to your version control system (git) for security reasons. The content of your .env file should look like the code block below

EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.zoho.eu
EMAIL_HOST_PASSWORD=yourpassword
EMAIL_HOST_USER=yourmail@zohomail.eu
EMAIL_PORT=465
EMAIL_USE_TLS=1
sender=sampleproject@zohomail.eu

This is a sample configuration for the zoho mail server. You should replace the placeholders with your actual Zoho mail server credentials.

Since your configuration is obtained from environment variables, you have to set those environment variables yourself. add Python-dotenv to your application to make it load the configuration from a .env in your settings.py within your project directory with the code below:

from dotenv import load_dotenv

load_dotenv()

The code above imported the load_dotenv from dotenv and then loaded the dotenv with load_dotenv(). The load_dotenv() takes environment variables from the .env file and loads them into the environment. This is done at the beginning of your settings.py file to ensure that the environment variables are loaded before the rest of the settings are configured.

Proceed to load the environment variables in your settings.py file. This can be done close to where the database configuration is. The configuration can be done with the code block below

import os
# ... other configurations
# Email configuration
EMAIL_BACKEND = os.getenv('EMAIL_BACKEND')
EMAIL_USE_TLS = bool(os.getenv('EMAIL_USE_TLS'))
EMAIL_HOST = os.getenv('EMAIL_HOST')
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
EMAIL_PORT = os.getenv('EMAIL_PORT')
sender = os.getenv('sender')
auth = os.getenv('auth')
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL')

In the code block above, os. getenv is used to load the environment variables as it came from the actual environment

Save and exit.

IMPLEMENT OTP IN AUTH

Create A Sample Function To Generate and Send OTP

Python has a built-in library used in sending emails via a Simple Mail Transfer Protocol (SMTP) called smtplib. It provides classes and methods to create email messages, connect to an SMTP server, and send these messages Install this package with the code below:

pip install smtplib

Navigate to your accounts.py directory

cd accounts

Update the models.py within your accounts directory to include the OTP field.

from django.db import models


class User(AbstractUser):
    # other fields

 otp = models.CharField(max_length=6, blank=True, null=True)
 otp_verified = models.BooleanField(default=False)

Save and exit

Create a new file named utils.py and add the following code to it:

import random
import string


def generate_otp(length=6):
    characters = string.digits
    otp = ''.join(random.choice(characters) for _ in range(length))
    return otp

The function above generates a random One-Time Password (OTP) consisting of the length of six(6) numeric digits. It uses the random module to select random digits from the string.digits collection and joins them into a string. The function then returns this string as the OTP.

Within your utils.py create another function that handles sending of emails using Zoho serve with the code below

from django.core.mail import EmailMultiAlternatives
from django.utils.html import strip_tags
import smtplib
from core.settings import auth, sender,EMAIL_HOST, EMAIL_PORT
from email.mime.text import MIMEText


def Send_email_with_zoho_server(to_email, message):
     print('hello you!')
     msg = MIMEText(message)
     msg['Subject'] = "OTP from Sample Projects"
     msg['From'] = sender
     to=[to_email],  

     server = smtplib.SMTP_SSL(EMAIL_HOST, EMAIL_PORT)
     server.login(sender, auth)
     server.sendmail(sender, to,  msg.as_string())

     server.quit()

In the code block above, the built-in Python SMTP library was imported. the email credentials that were configured in the settings.py file were imported. A sample function that sends emails was created. At the end of the function, a connection with the email host and port was created, when the process is done running, the server gets quit with the server.quit() method.

Save and exit.

In UserCreateAPIView in the views.py file, call the functions created in your utils.py. This will send an OTP to a user's email when an account is created. Navigate to your views.py within the accounts app and consume the generate_otp and Send_email_with_zoho_server created in the utils.py file

# ... other importations
from .utils import generate_otp, Send_email_with_zoho_server

User = get_user_model()

class UserCreateAPIView(generics.CreateAPIView):
    queryset = User.objects.all()
    serializer_class = RegisterUserSerializer

    def post(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        try:
            serializer.is_valid(raise_exception=True)
            user = serializer.save()
            refresh = RefreshToken.for_user(user)
            otp = generate_otp()    #new
            user.otp = otp    #new
            Send_email_with_zoho_server(    #new
                to_email=user.email,
                message=f'Your OTP for account verification is: {otp}'    #new
                )
            user.save()

            return Response({
                'user': {
                    'id': user.id,
                    'email': user.email,
                    'full_name': user.full_name,
                },
                'token': {
                    'refresh': str(refresh),
                    'access': str(refresh.access_token),
                },
            }, status=status.HTTP_201_CREATED)
        except ValidationError as e:
            if 'email' in str(e) and 'unique' in str(e):
                return Response({'error': 'User with this email already exists.'}, status=status.HTTP_400_BAD_REQUEST)
            else:
                return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST)

user_create = UserCreateAPIView.as_view()

In the above code, we have added the generate_otp and Send_email_with_zoho_server which handle the OTP generation and sending the OTP to the user's email respectively. The OTP is generated and saved in the user model. The email is sent to the user with the OTP using the Send_email_with_zoho_server The OTP is sent in the email body.

Validate OTP

Create a function that would be used to validate OTP in accounts views.py. With the code below

# ...Other importations
from django.utils import timezone


class ValidateOTP(APIView):
    def post(self, request):
        email = request.data.get('email', '')
        otp = request.data.get('otp', '')
        try:
            user = User.objects.get(email=email)
        except User.DoesNotExist:
            return Response({'error': 'User with this email does not exist.'}, status=status.HTTP_404_NOT_FOUND)

        print(user.otp)
        if user.otp == otp:
            # check if token has expired
            time_difference = max(user.created_at, user.updated_at)
            mins_difference = (
                timezone.now() - time_difference
            ).total_seconds() / 60
            if mins_difference > 2:
                response = {
                    "response_status": "error",
                    "status_code": status.HTTP_400_BAD_REQUEST,
                    "message": "OTP token expired. Try again.",
                }
                return Response(response, status=status.HTTP_400_BAD_REQUEST)

            user.otp_verified = True  # Mark OTP as verified
            user.otp = None
            user.save()

            # Authenticate the user and create or get an authentication token
            # token, _ = Token.objects.get_or_create(user=user)

            return Response({'message': 'Account verified successfully'}, status=status.HTTP_200_OK)
        else:
            return Response({'error': 'Invalid OTP.'}, status=status.HTTP_400_BAD_REQUEST)

The ValidateOTP function handles OTP validation. It checks if the user's OTP matches the OTP sent, the function also handles OTP expiration sets the lifespan to 60 seconds and marks the OTP as verified.

Resend OTP

Since the ValidateOTP function handles a very short lifespan of the OTP, create a function that would be used to resend OTP in accounts views.py. With the code block below:

class ResendOtpView(APIView):
      def patch(self, request):
            """Resends a new OTP to the registered Email ID
                Payload:
                    {
                        "email": "user@example.com"
                    }
            """

            email = request.data.get('email')
            try:
                  user = User.objects.get(email=email)

            except User.DoesNotExist:
                  response = {'response_status':'error',
                              'status_code':status.HTTP_404_NOT_FOUND,
                              'message':'User does not exist'}
                  return Response(response, status=status.HTTP_404_NOT_FOUND)
            if user.otp is None:
                  response = {
                        'message': "Your account already verified"
                  }
                  return Response(response, status=status.HTTP_400_BAD_REQUEST)
            otp = generate_otp()
            user.otp=otp
            user.save()
            Send_email_with_zoho_server(message=otp, to_email=email)


            response ={
                  'message': 'new otp has been sent to your email!',
                  'status': status.HTTP_200_OK
            }
            return Response(response, status=status.HTTP_200_OK)

The ResendOtpView basically handles the logic to resend OTP. A User might need to be sent another OTP when the initial OTP expires. This function is called upon to handle resend.

Save and exit.

Update urls.py file.

from django.urls import path
from . import views
from search.views import SearchView
from .views import ValidateOTP, ResendOtpView, UserLoginAPIView, Testemail, PasswordResetAPIView, validate_password_reset_otp
# from search import SearchView



urlpatterns = [
    path('create/', views.user_create, name='user_create'),
    path('validate-otp/', ValidateOTP.as_view(), name='validate-otp'),
    path('resend-otp/', ResendOtpView.as_view(), name='resend-otp'),
]

The urls.py file imported the path from django.urls and from views, the functions we created earlier ValidateOTP, ResendOtpView, ResendOtpView were imported. The urlpatterns is a list of routes mapped to the logic.

Save and exit. Run your server

python manage.py runserver

Now you can test your API using Postman or any other API testing tool.

#IMAGE

Conclusion

There are several reasons OTP is integrated into an application.

  1. Security: OTP adds an extra layer of security to the application. It ensures that only authorized users can access the application.

  2. Verification: OTP helps to verify the user's email address or phone number. This ensures that the user's contact information is valid and can be used for future communication.

  3. Prevents Automated Attacks: OTP prevents automated attacks like brute force attacks. It ensures that only a human can access the application.

This article works you through a step-by-step guide on how to implement OTP in your DRF project. The richness of the dotenv was explored. For more information about the dotenv, visit the official website