Django Authentication Part 4: Email Registration and Password Resets

By Adam McQuistan in Python  06/19/2019 Comment

Django Authentication Part 4: Email Registration and Password Resets

Introduction

This is the forth article in a multipart series on implementing the Django authentication system. I will continue to use the same demo survey application, referred to as Django Survey, to aid in the demonstration of authentication features.

As with the earlier tutorials of this series, I will be focusing on a specific use case common to authentication in a web application. This article features the use of email confirmation for user registration and password resets utilizing emails with token embedded reset links.

Series Contents:

The code for this series can be found on GitHub.

Configuring Django to use Gmail

As a means to simplify the demonstration of using emails for registering users and resetting passwords I will be utilizing Google's Gmail because it is freely available and easy to setup. The steps to setup a Gmail account for use from a third party application is as follows.

  1. Create a Gmail account
  2. Go to the Account page
  3. Then go to Security page
  4. In the Security page scroll down to Less secure app access and enable access

Now over in the Django project's .env file introduced in part 3 of this series I add two new variables for the username and password I used for signing into Gmail as shown below.

# .env
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY='196219946669-d3rlmh5677etn756sv7o9o0lvc0t077r.apps.googleusercontent.com'
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET='bMmmnUefr6yRGjtvqXwTNID6'
SOCIAL_AUTH_GITHUB_KEY='my-github-oauth-key'
SOCIAL_AUTH_GITHUB_SECRET='my-github-oath-secret'

EMAIL_HOST_USER='jondoe@gmail.com'
EMAIL_HOST_PASSWORD='password'

Then at the bottom of the django_survey/settings.py module I use those environment variables for the following email settings as well as change the default PASSWORD_RESET_TIMEOUT_DAYS value of 3 to 2.

EMAIL_USE_TLS = True
EMAIL_HOST = 'smtp.gmail.com'
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
EMAIL_PORT = 587
PASSWORD_RESET_TIMEOUT_DAYS = 2

Adding Email Confirmation to Registration

Updating the registration process to include a email confirmation for non-social auth users requires a unique token to be generated and only valid for a short period of time (2 days in this example). To generate a token I provide a custom implementation of the django.contrib.auth.tokens.PasswordResetTokenGenerator class provided by Django. By overriding the _make_hash_value method to include user data along with a 2 day expiry window specified in the PASSWORD_RESET_TIMEOUT_DAYS settings value I can be confident in sending an identifyable token to the user to verify their email with.

Inside a new survey/tokens.py module I place the code for the implementation of PasswordResetTokenGenerator like so.

# tokens.py

from django.contrib.auth.tokens import PasswordResetTokenGenerator
from django.utils import six

class UserTokenGenerator(PasswordResetTokenGenerator):
    def _make_hash_value(self, user, timestamp):
        user_id = six.text_type(user.pk)
        ts = six.text_type(timestamp)
        is_active = six.text_type(user.is_active)
        return f"{user_id}{ts}{is_active}"

user_tokenizer = UserTokenGenerator()

Since I am now validating the authenticity of the newly registering user's email address I need to start collecting it in the registration page and it's form. Doing this is as simple as extending the Django UserCreationForm to include a email field which I place in a new survey/forms.py module.

# forms.py

from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User

class RegistrationForm(UserCreationForm):
    email = forms.EmailField(max_length=150)

    class Meta:
        model = User
        fields = ('username', 'email', 'password1', 'password2')

Then I update the RegisterView view to use this newly created RegistrationForm for collecting user data complete with their email. The post method now generates a token used to confirm the authenticity of the provided email address plus I also invalidate the newly created user by setting the is_valid field to False. By setting the is_valid value to False if a user tries to login the system will raise a Validation error due to the use of the AuthenticationForm and it's clean method during the login processes.

# survey/views.py

import json
from django.conf import settings
from django.contrib.auth import authenticate, login
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.models import User, Group, Permission
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage
from django.template.loader import get_template
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponse
from django.utils.encoding import force_bytes, force_text
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.shortcuts import render, redirect, reverse, get_object_or_404

from django.views import View

from guardian.conf import settings as guardian_settings
from guardian.mixins import PermissionRequiredMixin
from guardian.shortcuts import assign_perm, get_objects_for_user

from .models import Survey, Question, Choice, SurveyAssignment, SurveyResponse
from .tokens import user_tokenizer

class RegisterView(View):
    def get(self, request):
        return render(request, 'survey/register.html', { 'form': RegistrationForm() })

    def post(self, request):
        form = RegistrationForm(request.POST)
        if form.is_valid():
            user = form.save(commit=False)
            user.is_valid = False
            user.save()
            token = user_tokenizer.make_token(user)
            user_id = urlsafe_base64_encode(force_bytes(user.id))
            url = 'http://localhost:8000' + reverse('confirm_email', kwargs={'user_id': user_id, 'token': token})
            message = get_template('survey/register_email.html').render({
              'confirm_url': url
            })
            mail = EmailMessage('Django Survey Email Confirmation', message, to=[user.email], from_email=settings.EMAIL_HOST_USER)
            mail.content_subtype = 'html'
            mail.send()

            return render(request, 'survey/login.html', {
              'form': AuthenticationForm(),
              'message': f'A confirmation email has been sent to {user.email}. Please confirm to finish registering'
            })

        return render(request, 'survey/register.html', { 'form': form })

# ... omitting everything below for brevity

As seen above I now generate a token with the previously described UserTokenGenerator class, convert the user's id to a base64 encoded string representation and, build a confirmation link which gets embedded in a email which is sent to the given email address.

In order to save the email address of newly registering users I need to add the email field to the register.html template which I show in its updated form below.

<!-- register.html -->
{% extends 'survey/base.html' %}
{% load widget_tweaks %}

{% block content %}

<section class="hero is-success is-fullheight">
  <div class="hero-body">
    <div class="container">
      <h1 class="title has-text-centered">
        Django Survey
      </h1>

      <div class="columns">
        <div class="column is-offset-2 is-8">
          <h2 class="subtitle">
            Register
          </h2>

          <form action="{% url 'register' %}" method="POST" autocomplete="off">
            {% csrf_token %}
    
            <div class="field">
              <label for="{{ form.username.id_for_label }}" class="label">
                Username
              </label>
              <div class="control">
                {{ form.username|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.username.errors }}</p>
            </div>

            <div class="field">
              <label for="{{ form.email.id_for_label }}" class="label">
                Email
              </label>
              <div class="control">
                {{ form.email|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.email.errors }}</p>
            </div>

            <div class="field">
              <label for="{{ form.password1.id_for_label }}" class="label">
                Password
              </label>
              <div class="control">
                {{ form.password1|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.password1.errors }}</p>
            </div>
    
            <div class="field">
              <label for="{{ form.password2.id_for_label }}" class="label">
                Password Check
              </label>
              <div class="control">
                {{ form.password2|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.password2.errors }}</p>
            </div>
    
            <div class="field">
              <div class="control">
                <button class="button is-link">Submit</button>
              </div>
            </div>
          </form>
        </div>
      </div>

    </div>
  </div>
</section>

{% endblock %}

register with email

Below you can see the register_email.html template which bears the confirmation url.

<!-- register_email.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Registration Confirmation</title>
</head>
<body>
  <h1>Welcome to Django Survey</h1>
  <p>Please click this link to confirm your email and complete registering.</p>
  <p><a href="{{confirm_url}}">{{ confirm_url }}</a></p>
</body>
</html>

Next I create a class based view to handle the GET request of the link embedded in the confirmation email. I named this class ConfirmRegistrationView and it takes two url parameters, one for the string encoded user id and another for the token.

This view will decode the user id, look up the user, and validate the token. If validated the user's is_valid is flipped to true otherwise it will display a message that the token is invalid and they should reset their password to generate another confirmation token.

Below is the new ConfirmRegistrationView class.

# survey/views.py

... skipping down to just the new class view

class ConfirmRegistrationView(View):
    def get(self, request, user_id, token):
        user_id = force_text(urlsafe_base64_decode(user_id))
        
        user = User.objects.get(pk=user_id)

        context = {
          'form': AuthenticationForm(),
          'message': 'Registration confirmation error . Please click the reset password to generate a new confirmation email.'
        }
        if user and user_tokenizer.check_token(user, token):
            user.is_valid = True
            user.save()
            context['message'] = 'Registration complete. Please login'

        return render(request, 'survey/login.html', context)

I now need to add the confirmation url path and map it to this new view class in survey/urls.py like so.

# survey/urls.py

from django.contrib.auth import views as auth_views
from django.urls import path

from . import views

urlpatterns = [
  path('register/', views.RegisterView.as_view(), name='register'),
  path('login/', auth_views.LoginView.as_view(template_name='survey/login.html'), name='login'),
  path('profile/', views.ProfileView.as_view(), name='profile'),
  path('logout/', auth_views.LogoutView.as_view(), name='logout'),
  path('surveys/create/', views.SurveyCreateView.as_view(), name='survey_create'),
  path('survey-assginment/<int:assignment_id>/', views.SurveyAssignmentView.as_view(), name='survey_assignment'),
  path('survey-management/<int:survey_id>/', views.SurveyManagerView.as_view(), name='survey_management'),
  path('survey-results/<int:survey_id>/', views.SurveyResultsView.as_view(), name='survey_results'),
  path('confirm-email/<str:user_id>/<str:token>/', views.ConfirmRegistrationView.as_view(), name='confirm_email')
]

Django Password Reset Emails with Token Embedded Reset Links

In this section I will demonstrate how to implement a password reset feature that uses an email confirmation token containing link. Luckily Django has provided class based views and forms to make this a super simple process. For example, to implement the password reset view which is shown when a user clicks a forgot password link is as easy as providing a url path to django.contrib.auth.views.PasswordResetView and specifying a view template, a email template, a redirect url, and an instance of the token generator used earlier which I've shown below.

from django.conf import settings
from django.contrib.auth import views as auth_views
from django.urls import path

from . import views
from .tokens import user_tokenizer

urlpatterns = [
  path('register/', views.RegisterView.as_view(), name='register'),
  path('login/', auth_views.LoginView.as_view(template_name='survey/login.html'), name='login'),
  path('profile/', views.ProfileView.as_view(), name='profile'),
  path('logout/', auth_views.LogoutView.as_view(), name='logout'),
  path('surveys/create/', views.SurveyCreateView.as_view(), name='survey_create'),
  path('survey-assginment/<int:assignment_id>/', views.SurveyAssignmentView.as_view(), name='survey_assignment'),
  path('survey-management/<int:survey_id>/', views.SurveyManagerView.as_view(), name='survey_management'),
  path('survey-results/<int:survey_id>/', views.SurveyResultsView.as_view(), name='survey_results'),
  path('confirm-email/<str:user_id>/<str:token>/', views.ConfirmRegistrationView.as_view(), name='confirm_email'),
  path(
    'reset-password/',
    auth_views.PasswordResetView.as_view(
      template_name='survey/reset_password.html',
      html_email_template_name='survey/reset_password_email.html',
      success_url=settings.LOGIN_URL,
      token_generator=user_tokenizer),
    name='reset_password'
  ),
]

The next step is to add a simple reset password link to the login template which I've again shown below.

<!-- login.html -->
{% extends 'survey/base.html' %}
{% load widget_tweaks %}

{% block content %}

<section class="hero is-success is-fullheight">
  <div class="hero-body">
    <div class="container">
      <h1 class="title has-text-centered">
        Django Survey
      </h1>

      <div class="columns">
        <div class="column is-offset-2 is-8">
          <h2 class="subtitle">
            Login
          </h2>
          <p class='has-text-centered'>{{ message }}</p>

          <form action="{% url 'login' %}" method="POST">
            {% csrf_token %}
    
            <div class="field">
              <label for="{{ form.username.id_for_label }}" class="label">
                Username
              </label>
              <div class="control">
                {{ form.username|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.username.errors }}</p>
            </div>
    
            <div class="field">
              <label for="{{ form.password.id_for_label }}" class="label">
                Password
              </label>
              <div class="control">
                {{ form.password|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.password.errors }}</p>
            </div>
    
            <div class="field is-grouped">
              <div class="control">
                <button class="button is-link">Submit</button>
              </div>
              <div class="control">
                <a href="{% url 'reset_password' %}">Forgot password?</a>
              </div>
            </div>
          </form>
          <hr>
          <label for="" class="label">Or login with Google</label>
          <div class="field">
            <div class="control">
              <a href="{% url 'social:begin' 'google-oauth2' %}" class="button">
                <span class="icon">
                  <i class="fab fa-google-plus-g"></i>
                </span>
                <span>Google</span>
              </a>
            </div>
          </div>
        </div>
      </div>

    </div>
  </div>
</section>

{% endblock %}

login view with password reset link

Of course, now I need to provide an implementation inside the survey/reset_password.html to display an email input for the reset link. The template utilizes the django.contrib.auth.forms.PasswordResetForm form class which is the default behavior for posts back to the PasswordResetView view.

<!-- reset_password.html -->
{% extends 'survey/base.html' %}
{% load widget_tweaks %}

{% block content %}

<section class="hero is-success is-fullheight">
  <div class="hero-body">
    <div class="container">
      <h1 class="title has-text-centered">
        Django Survey
      </h1>

      <div class="columns">
        <div class="column is-offset-2 is-8">
          <h2 class="subtitle">
            Password Reset
          </h2>

          <form action="{% url 'reset_password' %}" method="POST">
            {% csrf_token %}
    
            <div class="field">
              <label for="{{ form.email.id_for_label }}" class="label">
                Email
              </label>
              <div class="control">
                {{ form.email|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.email.errors }}</p>
            </div>
    
            <div class="field">
              <div class="control">
                <button class="button is-link">Submit</button>
              </div>
            </div>
          </form>
        </div>
      </div>

    </div>
  </div>
</section>

{% endblock %}

reset password view

The PasswordResetView class generates a token and utilize the specified email template named reset_password_email.html (shown below) to email the user providing the reset link.

<!-- reset_password_email.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Django Survey Password Reset</title>
</head>
<body>
  <h1>Django Survey Password Reset</h1>
  <p>Please click the link below to result your password.</p>
  <p><a href="{{ protocol}}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}">Reset password</a></p>
</body>
</html>

reset password email

The url referenced by the view named 'password_reset_confirm' in the above email template can now be added to the survey/urls.py module. To accomplish this I again use another default Django view class of django.contrib.auth.views.PasswordResetConfirmView and override some of the defaults as shown below.

# survey/urls.py

from django.conf import settings
from django.contrib.auth import views as auth_views
from django.urls import path

from . import views
from .tokens import user_tokenizer

urlpatterns = [
  path('register/', views.RegisterView.as_view(), name='register'),
  path('login/', auth_views.LoginView.as_view(template_name='survey/login.html'), name='login'),
  path('profile/', views.ProfileView.as_view(), name='profile'),
  path('logout/', auth_views.LogoutView.as_view(), name='logout'),
  path('surveys/create/', views.SurveyCreateView.as_view(), name='survey_create'),
  path('survey-assginment/<int:assignment_id>/', views.SurveyAssignmentView.as_view(), name='survey_assignment'),
  path('survey-management/<int:survey_id>/', views.SurveyManagerView.as_view(), name='survey_management'),
  path('survey-results/<int:survey_id>/', views.SurveyResultsView.as_view(), name='survey_results'),
  path('confirm-email/<str:user_id>/<str:token>/', views.ConfirmRegistrationView.as_view(), name='confirm_email'),
  path(
    'reset-password/',
    auth_views.PasswordResetView.as_view(
      template_name='survey/reset_password.html',
      html_email_template_name='survey/reset_password_email.html',
      success_url=settings.LOGIN_URL,
      token_generator=user_tokenizer),
    name='reset_password'
  ),
  path(
    'reset-password-confirmation/<str:uidb64>/<str:token>/',
    auth_views.PasswordResetConfirmView.as_view(
      template_name='survey/reset_password_update.html', 
      post_reset_login=True,
      post_reset_login_backend='django.contrib.auth.backends.ModelBackend',
      token_generator=user_tokenizer,
      success_url=settings.LOGIN_REDIRECT_URL),
    name='password_reset_confirm'
  ),
]

As you can see the PasswordResetConfirmView has been assigned the reset_password_update.html template which displays a set of new password fields similar to the registration template along with the cusom implementation of the tokenizer class defined earlier. Additionally, I have specified that the user should be logged in once the password is successfully updated as well as where to redirect them to based off the LOGIN_REDIRECT_URL specified in the settings.py module. Since this project has multiple authentication backends I must explicitly tell it to use the standard ModelBackend for authentication.

The completed html template for reset_password_update.html is shown below.

<!-- login.html -->
{% extends 'survey/base.html' %}
{% load widget_tweaks %}

{% block content %}

<section class="hero is-success is-fullheight">
  <div class="hero-body">
    <div class="container">
      <h1 class="title has-text-centered">
        Django Survey
      </h1>

      <div class="columns">
        <div class="column is-offset-2 is-8">
          <h2 class="subtitle">
            Password Reset
          </h2>

          <form method="POST">
            {% csrf_token %}
    
            <div class="field">
              <label for="{{ form.new_password1.id_for_label }}" class="label">
                New Password
              </label>
              <div class="control">
                {{ form.new_password1|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.new_password1.errors }}</p>
            </div>
    
            <div class="field">
              <label for="{{ form.new_password2.id_for_label }}" class="label">
                New Password2
              </label>
              <div class="control">
                {{ form.new_password2|add_class:"input" }}
              </div>
              <p class="help is-danger">{{ form.new_password2.errors }}</p>
            </div>

            <div class="field">
              <div class="control">
                <button class="button is-link">Submit</button>
              </div>
            </div>
          </form>
        </div>
      </div>

    </div>
  </div>
</section>

{% endblock %}

new password view

Want to Learn More About Python and Django?

Conclusion

In this tutorial I have demonstrated how to implement a registration and password reset workflow that utilizes emails with token embedded confirmation links.  All of these implementations utilize pure Django functionality with the exception of a custom implementation of the PasswordResetTokenGenerator.

Thanks for joining along on this tour of some of the awesome authentication features that can be implemented in the Django web framework using Python.  As always, don't be shy about commenting or critiquing below.

 

 

Share with friends and colleagues

[[ likes ]] likes

Community favorites for Python

theCodingInterface