Dev Journal #9 - Email Change API



Email change API is one of the essential authentication features that users will expect to see in most apps. It’s not included in Django’s auth module, so I had to build a custom one to handle the email change requests. The basic steps are as follows:

  1. User must be logged in
  2. User requests email change (must provide credentials and the new email address)
  3. Server verifies credentials and checks that the new email is not taken by another user
  4. Server sends a confirmation email to the new email address
  5. User clicks on the email link to confirm the process

The users will make the API request to the specified email change URL with the request body. The URL is mapped to the EmailChangeAPIView. Django views are a python function or a class that takes a web request and returns a web response. The data in the request body is translated by the serializers. An email with a confirmation link is sent to the new email address once the user data is verified. When the user clicks on this link, another API request is made to a different URL which is mapped to another view function. The function will decrypt the user information from the parameters in the confirmation URL and update the user information accordingly.

Serializers

The fields defined in these serializers are new_email, password and message. The new_email and password are write-only fields, which the user provides in the request body. The read-only message field sends the success message back to the user after the request has been successfully handled.

The serializer validates the requested new_email field to make sure it hasn’t been taken by other users. It also validates the password field to make sure that it matches the user credentials for added security.

The update function is invoked once the fields are validated which then calls the send_confirmation_email function. The implementation of the email token generation is inherited from Django’s PasswordResetTokenGenerator class. The email body is rendered by the confirm_email_change.html template. Encrypted user primary key, encrypted new email address and the token is passed to this email template as parameters.

import datetime

from django.contrib.auth import get_user_model
from django.core.mail import EmailMessage
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from rest_framework import serializers
from rest_framework_jwt.settings import api_settings
from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.utils import six

jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
expire_delta = api_settings.JWT_REFRESH_EXPIRATION_DELTA
User = get_user_model()


class TokenGenerator(PasswordResetTokenGenerator):
    pass


account_activation_token = TokenGenerator()


class EmailChangeSerializer(serializers.ModelSerializer):
    message = serializers.SerializerMethodField(read_only=True)
    new_email = serializers.EmailField(write_only=True)

    class Meta:
        model = User
        fields = [
            'new_email',
            'password',
            'message',
        ]
        extra_kwargs = {'password': {'write_only': True}}

    @staticmethod
    def get_message(obj):
        return 'Email change request sent!'

    @staticmethod
    def validate_new_email(value):
        qs = User.objects.filter(email__iexact=value)
        if qs.exists():
            raise serializers.ValidationError('User with this email already exists')
        return value

    def validate_password(self, value):
        request = self.context.get('request')
        if not request.user.check_password(value):
            raise serializers.ValidationError('Invalid password!')
        return value

    def update(self, instance, validated_data, **kwargs):
        self.send_confirmation_email(self, self.context.get('request').user, validated_data.get('new_email'))
        return instance

    @staticmethod
    def send_confirmation_email(self, user, new_email):
        request = self.context.get('request')
        mail_subject = 'Finding Fitness - Confirm email change.'
        message = render_to_string('confirm_email_change.html', {
            'username': user.username,
            'domain': request.get_host,
            'uid': urlsafe_base64_encode(force_bytes(user.pk)),
            'uid_2': urlsafe_base64_encode(force_bytes(new_email)),
            'token': account_activation_token.make_token(user),
        })
        to_email = new_email
        email = EmailMessage(
            mail_subject, message, to=[to_email]
        )
        email.send()

Email Template

The email template builds the url with the encrypted user primary key, encrypted new email address and the token as the param. The url be in the form of domain/api/auth/email_change_activate/encrypted-user-primary-key/encrypted-new-email-address/token/

{% autoescape off %}
    Hi {{ username }},
    Please click on the link below to confirm your email change.
    http://{{ domain }}{% url 'api-auth:email_change_activate' uidb64=uid uidb64_2=uid_2 token=token %}
{% endautoescape %}

Views

Two views are required in the email change API. The first view is the generics.UpdateAPIView to handle the initial POST request. The permissions.IsAuthenticated means that the user must be already logged in before making the email change request. I also only allow the users to update only their account email. The second view is used to handle the new email activation when the user redirects to the confirmation link. It first decrypts the URL parameters to get back the user’s primary key and their requested email address. I use the check_token function inherited from the PasswordResetTokenGenerator class to verify that the token is correct. The user object is updated with the new email if the token is valid and is returned with the success response.


class EmailChangeAPIView(generics.UpdateAPIView):
    serializer_class = EmailChangeSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_object(self):
        return User.objects.filter(is_active=True).get(id=self.request.user.id)


def email_change_activate(request, uidb64, uidb64_2, token):
    try:
        uid = force_text(urlsafe_base64_decode(uidb64))
        user = User.objects.get(pk=uid)
        user.email = force_text(urlsafe_base64_decode(uidb64_2))
    except(TypeError, ValueError, OverflowError, User.DoesNotExist):
        user = None
    if user is not None and account_activation_token.check_token(user, token):
        user.save()
        return HttpResponse('Thanks for confirming your email! Your email address has been updated.', status=200)
    else:
        return HttpResponse('Activation link is invalid!', status=401)

URLs

The following two paths are registered to each of the views described above.

path('email_change/', EmailChangeAPIView.as_view(), name='email_change'),
path('email_change_activate/<str:uidb64>/<str:uidb64_2>/<str:token>/', email_change_activate, name='email_change_activate'),

Delete Account API

DeleteAccountAPIView works in the exact same logic so I won’t delve into the code. When the user makes a request to delete their account, the server verifies the credentials and fires an email to the user’s current email address. Once the user confirms the action by clicking on the link sent to their email, their account is deleted permanently.

Previous Post Next Post