narancs's blog

User registration API with email verification

Introduction

This is the third part of my JWT authentication with Django and React series. In the previous part we have created token obtain and token refresh APIs. In this part we are going to create the registration API, so users can actually register to our application. We are going to implement it with email verification, so new users will only be able to login once they verified that the email address they used for registration is really owned by them.

Let’s get started!

Setup Mailtrap to be able to send emails

Getting an API Token for Mailtrap

As I mentioned, we want to send an email verification emails to the newly registered users. Therefore we need the ability to send emails from Django. I will use Mailtrap for this, as it is free to use (with limitations) and easy to setup. If you already know how to send emails from Django and have a working solution already, then feel free to skip to Part 3 (Adding Registration API to Django project).

First, we need to register at their website: https://mailtrap.io. Once we are logged in, we need to go to the API Tokens page. By default we can only send emails from demomailtrap.com address, so we need to use our demomailtrap.com token.

We do not want to store the API token in the source code, so we will save it in the .env file.
				
					MAILTRAP_TOKEN=<YOUR TOKEN HERE>
				
			

Then we add it to settings.py:

				
					MAILTRAP_TOKEN = get_env_variable("MAILTRAP_TOKEN")
				
			
The get_env_variable function was introduced in the first part of this blog series, so you should already have it in the source code. If you do not have it, then check out the post here.

Make sure to add .env to .gitignore so you never commit the file containing secrets it into a repository.

I also like to create a .env.sample file to commit into the repository, so it is easy to see what variables we need to set.

				
					MAILTRAP_TOKEN=DUMMY_MAILTRAP_TOKEN
				
			

When pulling the repo for fresh deployment, we can rename it to .env, fill the required values and we are good to go.

Installing Mailtrap's Python package

After we have our API token added in settings.py, we can install Mailtrap’s Python package. (Make sure you are in the right virtual environment if you have used one)

				
					pip install mailtrap
				
			

Now we can open a Django shell and send a test email to verify that email sending is working properly.

				
					python manage.py shell
				
			

Then replace your email address and API token in the following script and run it in the shell.

				
					import mailtrap as mt
# create mail object
mail = mt.Mail(
    sender=mt.Address(email="YOUR_EMAIL_ADDRESS_HERE", name="Mailtrap Test"),
    to=[mt.Address(email="YOUR_EMAIL_ADDRESS_HERE")],
    subject="You are awesome!",
    text="Congrats for sending test email with Mailtrap!",
)
client = mt.MailtrapClient(token="YOUR_TOKEN_HERE")
client.send(mail)
				
			

If everything went fine you should receive an email like this:

Received test email

Keep in mind that the demo domain allows you to send emails only to the email address of the account owner.

To send emails to your recipients, you need to own a domain and/or have admin access to a domain and its DNS records. Each domain needs to be authenticated and verified using the DNS records Mailtrap provides.

For further details, please refer to the FAQ on Mailtrap site.

Adding Registration API to Django project

We can now send emails from Django using Mailtrap. Lets create our registration API. First, we are going to add a new field to our User model called one_time_password. It will contain a randomly generated 64 character string that will be used to verify the email address of the user after registration.

Extending the User model with one_time_password_field

The User model after adding the new field:

				
					
class User(AbstractUser):
    username = None
    email = models.EmailField(
        verbose_name='email address',
        max_length=255,
        unique=True,
    )
    one_time_password = models.CharField(max_length=64, default="")
    objects = UserManager()
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []
    def __str__(self):
        return f'{self.email} (id={self.id})'
				
			
We also need to update the create_user method of our UserManager class to take care of extra keyword arguments. (one_time_password will be such an argument, so otherwise we would get an error while trying to create a user)
				
					
class UserManager(BaseUserManager):
    
    def create_user(self, email, password=None, **extra_fields):
        return self._create_user(email, password, **extra_fields)
				
			

Let’s create the migration for the change and apply it.

				
					python manage.py makemigrations
python manage.py migrate
				
			

Creating UserSerializer

Now let’s create the UserSerializer that will be used to create the users during registration.

  • It will take an extra parameter called confirm_password. This is not a field on our model, so we just use it’s value to make sure the user entered the same password twice correctly.
  • We will also require an email address from the users.
  • Before running the create_user method with the validated data, we will need to pop the confirm_password field because it does not exist on the model. It would cause an error otherwise.

The changes in serializers.py:

				
					from rest_framework import serializers
from django.contrib.auth import get_user_model
User = get_user_model()
...
class UserSerializer(serializers.ModelSerializer):
    confirm_password = serializers.CharField(max_length=128, write_only=True)
    class Meta:
        model = User
        fields = ['email', 'password', 'confirm_password']
        extra_kwargs = {
            'password': {'write_only': True},
            'email': {'required': True}
        }
    def validate(self, data):
        if data.get('password') != data.get('confirm_password'):
            raise serializers.ValidationError("Passwords do not match.")
        return data
    def create(self, validated_data):
        validated_data.pop('confirm_password')
        user = User.objects.create_user(**validated_data, is_active=False)
        return user
				
			

Create function to send email verification mails

Before we start writing our API View that will handle user registration we need to implement the email sending part. I put this code in a separate file called helpers.py:
				
					import mailtrap as mt
from django.conf import settings

def send_verify_signup_email(recipient_address, user_id, otp):
    url = f"{settings.DOMAIN}/api/v1/verify-email/?uid={user_id}&otp={otp}"
    mail = mt.Mail(
        sender=mt.Address(
            email="django-tutorial@demomailtrap.com", name="Mailtrap Test"
        ),
        to=[mt.Address(email=recipient_address)],
        subject="Active your account!",
        html=f"""
            Hello,<br>
            <br>
            thank you for signing up to django-tutorial!<br>
            <br>
            Please activate your account by clicking the following link:<br>
            <br>
            <a href="{url}" target="_blank">Activate account</a><br>
            <br>
            If the link doesn't work, please visit the following URL:<br>
            <br>
            {url}<br>
            <br>
            Best regards,<br>
            Django tutorial team
        """,
    )
    client = mt.MailtrapClient(token=settings.MAILTRAP_TOKEN)
    client.send(mail)

				
			

This function takes 3 parameters:

  • recipient_address: this is where the email will be sent
  • user_id and otp: these parameters are required to form the email verification URL. This is the URL that the user will have to click. The link will be included in the email body. Once the user opens the link, it will be handled by an API (that we will write next) to activate the user if the user ID and OTP in the URL matches and valid.

The URL will look something like this:

				
					https://localhost:8000/api/v1/verify-email/?uid=5&otp=QQCPUPuR8wt3WI3IqT4m96R9q7hLGGj7k0AJs7xqN1iYZiFvDObdosxPRgagn3uN
				
			

To make the domain part flexible I added it as an env variable similarly to the MAILTRAP_TOKEN. So while we test it locally we can use localhost domain with port 8000. But when deployed in production we can simply update the DOMAIN variable to make the URLs point to our production domain.

Adding the registration API

We have everything ready to finally add the actual API that will handle user registration. Edit views.py as follows:

				
					import random
import string
from rest_framework.generics import CreateAPIView
from .helpers import send_verify_signup_email
from .serializers import UserSerializer
...
class CreateUserView(CreateAPIView):
    serializer_class = UserSerializer
    def create(self, request):
        serializer = self.get_serializer(data=request.data)
        if serializer.is_valid():
            email = serializer.validated_data['email']
            one_time_password = ''.join(random.choices(
                string.ascii_letters + string.digits, k=64))
            serializer.validated_data['one_time_password'] = one_time_password
            self.perform_create(serializer)
            user_id = serializer.instance.id
            send_verify_signup_email(email, user_id, one_time_password)
            headers = self.get_success_headers(serializer.data)
            return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

				
			

And add it to api/urls.py:

				
					path('signup/', users_views.CreateUserView.as_view(), name='signup'),
				
			

Testing the registration API

If everything was done correctly, we should be able to register as a new user and receive the verification email. Visit http://localhost:8000/api/v1/signup/ and fill the form. You should receive a response with “HTTP 201 Created” status. You should also receive an email similar to this:

We receive a link in the email that the user has to click in order to active his account. However, we did not implement that URL yet. If we try to login to our account now, then we should receive “HTTP 401 Unauthorized” response:

				
					{
    "detail": "No active account found with the given credentials"
}
				
			

This is what we expected. Now let’s implement the verify-email endpoint.

Adding the email verification API

We want to write an API that will handle a GET request to a URL like this:

				
					 https://localhost:8000/api/v1/verify-email/?uid=5&otp=QQCPUPuR8wt3WI3IqT4m96R9q7hLGGj7k0AJs7xqN1iYZiFvDObdosxPRgagn3uN
				
			

It takes 2 query parameters from the URL called uid and otp. We want to check that the user with id as uid has a one_time_password set that matches the otp value from the URL. If that is the case, we will set the one_time_password to empty string again and activate the account by setting the is_active attribute to True. In other cases, we will return a meaningful message in the response and a proper status code. The APIView:

				
					class VerifyEmailView(APIView):
    def get(self, request):
        user_id = request.GET.get("uid")
        one_time_password = request.GET.get("otp")
        if not user_id or not one_time_password:
            return Response("Invalid request", status=status.HTTP_400_BAD_REQUEST)
        try:
            user = User.objects.get(id=user_id)
            if user.one_time_password == one_time_password:
                user.one_time_password = ""
                user.is_active = True
                user.save()
                return Response(
                    {
                        "detail": "The email of the user was verified successfully",
                    },
                    status=status.HTTP_200_OK,
                )
        except User.DoesNotExist:
            return Response(
                {
                    "error": "Object not found",
                    "detail": "The requested object does not exist.",
                },
                status=status.HTTP_404_NOT_FOUND,
            )
        except Exception:
            return Response(
                {
                    "error": "Unknown Error",
                    "detail": "Something went wrong:",
                },
                status=status.HTTP_500_INTERNAL_SERVER_ERROR,
            )
				
			

Then we add the URL in /api/urls.py:

				
					    path("verify-email/", users_views.VerifyEmailView.as_view(), name="verify-email"),

				
			

Now we should be able to open the link from the email and verify our email to active the account.

We can now try and login with our credentials. We will receive a successful response containing our access token.

				
					
HTTP 200 OK
Allow: POST, OPTIONS
Content-Type: application/json
Vary: Accept
{
    "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<***HIDDEN***>.lqHxAPAsCO3zz7D6HVcGO5k1_0wRtMuhHE2e1eMDmqo"
}

				
			

For a detailed view of the changes during this post, check out the pull request: https://github.com/narancs93/django-jwt-auth/pull/7

Table of Contents

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments

Related posts

0
Would love your thoughts, please comment.x
()
x