SMS Phone Verification in Django - Part 3

We arrive at the final and longest snippet of our code that will connect all dots and make the previous steps more relevant to us. We partition all code contents from views.py into 3 parts.

SMS Phone Verification in Django - Part 3

NOTE: This is the final part of this multi-part series.

Part 3 is the end.

Final Step 5 - Writing final views.py

We arrive at the final and longest snippet of our code that will connect all dots and make the previous steps more relevant to us. We partition all code contents from views.py into 3 parts. Specifically, the first part will detail the class ValidatePhoneSendOTP, the second part on the two very important functions generate_otp and push_to_sparrow and the third part will complete this tutorial with ValidateOTP.

import json
import random
import requests

from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated

from .models import AccountUser, PhoneOtp


class ValidatePhoneSendOTP(APIView):
    permission_classes = [IsAuthenticated, ]
    allowed_methods = ('POST', 'OPTIONS', 'HEAD')

    def post(self, request, *args, **kwargs):
        # assumes cleaned data is received here.. 10 digits ntc ncell
        phone_number = request.data.get('phone')
        if phone_number:
            user_with_phone = AccountUser.objects.filter(phone_number__iexact=phone_number)
            phone_in_temp_db = PhoneOtp.objects.filter(phone_number__iexact=phone_number)
            if user_with_phone.exists():
                verified_qs = user_with_phone.values('phone_verified')[0].values()
                verified_status = list(verified_qs)[0]
                username = user_with_phone.values('username')
                phone_in_db = user_with_phone.values('phone_number')
                print('====check===================', user_with_phone.exists(), verified_status, username, phone_in_db)
                if verified_status is True:
                    return Response({
                        'status': 403,
                        'details': 'Phone number already verified.'
                    }, status=status.HTTP_403_FORBIDDEN)
            elif phone_in_temp_db.exists():
                key = generate_otp(phone_number)
                if key:
                    old = PhoneOtp.objects.filter(phone_number__iexact=phone_number)
                    print('====================old user with verified False================', old)
                    if old.exists():
                        old = old.first()
                        count = old.otp_count
                        if count > 1:
                            return Response({
                                'status': 429,
                                'details': 'Too much OTP sent. Limit exceeded. Contact Support'
                            }, status=status.HTTP_429_TOO_MANY_REQUESTS)
                        old.otp_count = count + 1
                        old.otp_code = key
                        old.save()
                        status_code, response, response_json = push_to_sparrow(otp=key, phone_number=phone_number)
                        return Response({
                            'status': status_code,
                            'details': response_json
                        }, status=status_code)
            elif not user_with_phone.exists() and not phone_in_temp_db.exists():
                key = generate_otp(phone_number)
                print('======new number=================', phone_number)
                status_code, response, response_json = push_to_sparrow(otp=key, phone_number=phone_number)
                PhoneOtp.objects.create(phone_number=phone_number, otp_code=key, otp_count=1)
                return Response({
                    'status': status_code,
                    'details': response_json
                }, status=status_code)
            else:
                return Response({
                    'status': 403,
                    'details': 'Failed to send OTP. Try again'
                }, status=status.HTTP_403_FORBIDDEN)
        else:
            return Response({
                'status': 400,
                'details': 'Bad input for phone number in request.'
            }, status=status.HTTP_400_BAD_REQUEST)
views.py

Starting with imports from django and django rest framework, the main focus is on the class ValidatePhoneSendOTP that inherits the APIView of rest_framework.views.

The main objectives of this class are to:

  1. check user input against the database for an already existing phone with verified or unverified status, count of OTP requests to sparrow SMS API and a completely new phone entry to register in the database.
  2. only allow authenticated users to access the API endpoint with allowed methods POST, OPTIONS and HEAD.
  3. make changes to PhoneOtp of models.py and its objects like phone_number, otp_code and otp_count.

Here we import two necessary models AccountUser and PhoneOtp from models.py on line 10. Given the UI validation plus API restrictions work valid, the def post method expects a clean input. Then it checks against the AccountUser and PhoneOtp database models to check if the phone already exists, verified status and its username attached.

Fetching the contents from the database, we run the if else elif and not conditions check all over the class to perform our required actions step by step. On line 35, we take the param phone_number and pass it to generate_otp function to be saved in a key variable.

So when a user has their phone already entered in the database plus the verified status is False then the number of otp_count is checked for too many requests. If the count has not reached the max limit which you can set in line 42 then we use the parameters of key as otp and phone_number as phone_number to pass it to push_to_sparrow function that will call the Sparrow SMS API to send a generated OTP to that phone number in line 50.

From line 55 we start the same checks that we processed above but for a totally new number. Firstly calling generate_otp function and then calling push_to_sparrow in lines 56, 58.

However, a new code appears in line 59, PhoneOtp.objects.create adds a new entry into the PhoneOtp model with its required objects phone_number, otp_code and otp_count; completing the objective of this class to work with new numbers.


def generate_otp(phone_number):
    if phone_number:
        key = random.randint(99999, 999999)
        return key
    else:
        return False


def push_to_sparrow(otp, phone_number):
    with open('/etc/config.json') as config_file:
        config = json.load(config_file)
    if config:
        msg = " is your verification code for example.com. Thank you for joining us. Download our app at https://example.com/"
        r = requests.post(
            "http://api.sparrowsms.com/v2/sms/",
            data={'token': config['SPARROW_TOKEN'],
                  'from': config['SPARROW_IDENTITY'],
                  'to': phone_number,
                  'text': "<#> \n" + str(otp) + msg + " \nwu+aGJn10+0"
                  })
        print("<#> \n" + str(otp) + msg + " \nwu+aGJn10+0")

        status_code = r.status_code
        response = r.text
        response_json = r.json()
    else:
        return Response({
            'status': False,
            'details': 'Could not parse configurations.'
        })
    return status_code, response, response_json
generate_otp() and push_to_sparrow()

Next up we create two functions generate_otp and push_to_sparrow to do exactly verbatim in its naming.

The generate_otp function requires a parameter phone_number. Given the input, a key variable stores a randomly generated integer from (start, end). In the code below, we're trying to generate a 6 digit only number so if you want shorter or longer otp digits then use the (start, end) accordingly.

The push_to_sparrow function is completely dedicated to Sparrow SMS API calls. For better security practices, we save all the secret keys and tokens in a config_file in JSON format. Since this config file contains all crucial keys, we put the only copy into a file directory that is inaccessible from the public web in a Linux environment. Hence, the /etc/config.json file path.

Next, we use very popular python library requests to send HTTP requests to our SMS API endpoint. The URL enclosed within the quotes is provided by the Sparrow SMS Gateway to the developers. So using other gateway services like Twilio would provide you with a completely different URL.

SPARROW_IDENTITY param works as an identifier between you and the SMS gateway whereas the SPARROW_TOKEN is a key to allow access to use their gateway. Both params are provided by the Sparrow SMS to their developers from their web dashboard.

r = requests.post() sends the request to the API endpoint. And we python print the otp, msg and a unique code as a final SMS to be received by the user's phone.

As you'll notice at the tail of the print, we've set a unique code. This is Google's SMS Retriever API code. "With the SMS Retriever API, you can perform SMS-based user verification in your Android app automatically, without requiring the user to manually type verification codes, and without requiring any extra app permissions." - Google Identity


class ValidateOTP(APIView):
    permission_classes = [IsAuthenticated, ]

    def post(self, request, *args, **kwargs):
        phone_number_sent_by_user = request.data.get('phone', False)
        otp_sent_by_user = request.data.get('otp', False)
        username_sent = request.data.get('username', False)

        if otp_sent_by_user and phone_number_sent_by_user and username_sent:
            username_queryset = AccountUser.objects.filter(username__iexact=username_sent)
            phone_queryset = PhoneOtp.objects.filter(phone_number__iexact=phone_number_sent_by_user)
            if username_queryset.exists() and phone_queryset.exists():
                verified_qs = username_queryset.values('phone_verified')[0].values()
                verified_status = list(verified_qs)[0]
                if verified_status is True:
                    return Response({
                        'status': 403,
                        'details': 'Either phone or user is already verified.'
                    }, status=status.HTTP_403_FORBIDDEN)
                user_qs = username_queryset.first()
                qs = phone_queryset.first()
                otp_in_db = qs.otp_code

                if str(otp_sent_by_user) == otp_in_db:
                    qs.validated = True
                    user_qs.phone_verified = True
                    user_qs.phone_number = phone_number_sent_by_user
                    qs.save()
                    user_qs.save()
                    return Response({
                        'status': 200,
                        'details': 'Success! You have verified your phone number.'
                    }, status=status.HTTP_200_OK)
                else:
                    return Response({
                        'status': 400,
                        'details': 'Incorrect OTP. Try again'
                    }, status=status.HTTP_400_BAD_REQUEST)
            else:
                return Response({
                    'status': 400,
                    'details': 'Phone number or username is mistaken. Please check.'
                }, status=status.HTTP_400_BAD_REQUEST)
        else:
            return Response({
                'status': 400,
                'details': 'Invalid phone, username or otp provided.'
            }, status=status.HTTP_400_BAD_REQUEST)

Finally, we create an APIView class ValidateOTP which has the sole purpose of validating three entities involved in our phone verification system:

  1. the authenticity of the OTP code sent from the gateway service to the actual receiver,
  2. an exact match of the OTP code input from the user's phone and stored in the database,
  3. and the integrity of the phone number in the database and the one sent by the user.

Given these data points are validated, our code above checks five different cases to perform five different actions.

  1. given username and phone querysets return True; check the verified_status; if True then respond with 403 error that the phone is already verified. Hence skip all other actions.
  2. check if otp_sent_by_user against otp_in_db; respond with 200 OK that the phone is now verified for the first time.
  3. else there's a mismatch in codes and hence respond with error 400 incorrect requests.
  4. one step out of the if scope above, the code jumps back to (1.) when the given username and phone querysets returns False; hence respond with 400 error that the input is mistaken or wrong on the user's end.
  5. a step more out of the scope and we respond with a 400 error that the inputs passed from class ValidatePhoneSendOTP are invalid in this validation step.

about TODOS in the code examples

# todo1 new username with phone: null trying to push matching phone of the existing user could raise a unique error;
# todo2 handle this error in a user-friendly way from within API level
# todo3 a loophole in case of leaked OTP, a hacker could POST unverified 'username' & leaked 'otp' to set the phone numbers to other accounts.

I edit and update this article timely for corrections and improvements. Thank you

Subscribe to verbose tethics

Don’t miss out on the latest articles. Sign up now to get access to the library of members-only articles.
jamie@example.com
Subscribe