Customizing Django Authentication using AbstractBaseUser

joshwizzy

Joshua Masiko

Posted on March 20, 2020

Customizing Django Authentication using AbstractBaseUser

Introduction

Django has a builtin app that provides much of the required authentication machinery out of the box.
It also allows you to customize authentication if the defaults don't match your requirements.
There are multiple approaches you can take including:

  1. Using a Proxy Model based on User.
  2. Adding a OneToOneField that points to User on a Profile model.
  3. Extending AbstractUser and adding fields for additional profile information.
  4. Extending AbstractBaseUser and implementing the required functionality.

You can choose among the first 3 options if you wan't to add minimal customization to the Django defaults.
If, however, you wan't to use an email address instead of a username as the user identifier, you have to extend AbstractBaseUser.
This article will explain how to do this using a bare-bones listings application as an example.

Prerequisites

You will need to have working knowledge of Python and the Django Framework.
The Django version used in this tutorial is 3.04.
A recent version of Python 3.
The instructions in this tutorial were performed on Ubuntu 18.04 LTS with Python 3.6.

High Level Description of the App

Unauthenticated users can view all listings.
Users have to register to post listings.
The registration information includes:

  • Mandatory fields: email, password, phone.
  • Optional fields: date_of_birth, photo.

Superusers can create staff users using the Django admin interface.
Staff can flag listings using the Django admin interface.

The most recent 30 unflagged listings are displayed on the home page.
Users can click on a listing to view its details.

Getting Started

On Ubuntu 18.04 you might need to add the universe repository and install python3-venv.

$ sudo add-apt-repository universe
$ sudo apt install -y python3-venv
Enter fullscreen mode Exit fullscreen mode

Project Setup

Create and activate the virtual environment.

$ mkdir ~/.virtualenvs
$ python3 -m venv ~/.virtualenvs/listings
$ source ~/.virtualenvs/listings/bin/activate
Enter fullscreen mode Exit fullscreen mode

Then install Django and create a project using the django-admin command.

(listings) $ pip install django==3.0.4
(listings) $ django-admin startproject listings
(listings) $ cd listings
Enter fullscreen mode Exit fullscreen mode

You can now run the dev server and navigate to http://127.0.0.1:8000 in your browser.
You should see the Django Welcome Page.

(listings) $ python manage.py runserver
Enter fullscreen mode Exit fullscreen mode

welcome.PNG

Ignore the warning about unapplied migrations in the console.
The recommended procedure is to apply migrations after setting up the custom user model.

The accounts app

Let's add an app to host our authentiation customizations.

(listings) $ python manage.py startapp accounts
Enter fullscreen mode Exit fullscreen mode

The model and Manager

# accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.contrib.auth import get_user_model
from django.utils import timezone


class AccountManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, name, phone, password, **extra_fields):
        values = [email, name, phone]
        field_value_map = dict(zip(self.model.REQUIRED_FIELDS, values))
        for field_name, value in field_value_map.items():
            if not value:
                raise ValueError('The {} value must be set'.format(field_name))

        email = self.normalize_email(email)
        user = self.model(
            email=email,
            name=name,
            phone=phone,
            **extra_fields
        )
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, name, phone, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(email, name, phone, password, **extra_fields)

    def create_superuser(self, email, name, phone, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)

        if extra_fields.get('is_staff') is not True:
            raise ValueError('Superuser must have is_staff=True.')
        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Superuser must have is_superuser=True.')

        return self._create_user(email, name, phone, password, **extra_fields)


class Account(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    name = models.CharField(max_length=150)
    phone = models.CharField(max_length=50)
    date_of_birth = models.DateField(blank=True, null=True)
    picture = models.ImageField(blank=True, null=True)
    is_staff = models.BooleanField(default=False)
    is_active = models.BooleanField(default=True)
    date_joined = models.DateTimeField(default=timezone.now)
    last_login = models.DateTimeField(null=True)

    objects = AccountManager()

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['name', 'phone']

    def get_full_name(self):
        return self.name

    def get_short_name(self):
        return self.name.split()[0]

Enter fullscreen mode Exit fullscreen mode

USERNAME_FIELD is the name of the field on the user model that is used as the unique identifier.
REQUIRED_FIELDS are the mandatory fields other than the unique identifier

The create_user and create_superuser functions should accept the username field, plus all required fields as positional arguments.
Django’s provides a PermissionsMixin which we can include in the class hierarchy for our user model to support Django’s permissions.

Add the accounts app to the list of installed apps in settings.py

# listings/settings.py
...
INSTALLED_APPS = [
    ...
    'accounts.apps.AccountsConfig',
]
Enter fullscreen mode Exit fullscreen mode

Specify the custom model as the default user model using the AUTH_USER_MODEL setting in settings.py

# listings/settings.py
...
AUTH_USER_MODEL = 'accounts.Account'
Enter fullscreen mode Exit fullscreen mode

Install Pillow which is required by Django's ImageField

(listings) $ pip install pillow
Enter fullscreen mode Exit fullscreen mode

Generate migrations for the model now that the model is setup.

(listings) $ python manage.py makemigrations accounts
Enter fullscreen mode Exit fullscreen mode

And create the database.

(listings) $ python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

The forms

Subclassing AbstractBaseUser enables us to re-use the Django auth app built-in forms and views.

The following forms are compatible with any subclass of AbstractBaseUser:
AuthenticationForm: Uses the username field specified by USERNAME_FIELD.
SetPasswordForm: Allows users to change their password without entering the old password.
PasswordChangeForm: Allows users to change their password by entering the old password and a new password.
AdminPasswordChangeForm: Allows users to change their password from the Django admin.
PasswordResetForm: Assumes users to reset their passwords using a reset link sent to the email address.
These forms are by the built-in auth views to which we delegate authentication and password management.

UserCreationForm and UserChangeForm: Can be used to create users and change user details.
These forms are tied to default Django User model so we will provide our own implementations.
We will also add a registration form for signing up users.

# accounts/forms.py
from django import forms
from django.contrib.auth.models import Group
from django.contrib.auth.forms import ReadOnlyPasswordHashField

from .models import Account


class RegistrationForm(forms.ModelForm):
    password = forms.CharField(label='Password', widget=forms.PasswordInput)
    class Meta:
        model = Account
        fields = ('email', 'name', 'phone', 'date_of_birth', 'picture', 'password')

    def save(self, commit=True):
        # Save the provided password in hashed format
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password"])
        if commit:
            user.save()
        return user


class UserCreationForm(forms.ModelForm):
    password1 = forms.CharField(label='Password', widget=forms.PasswordInput)
    password2 = forms.CharField(label='Password confirmation', widget=forms.PasswordInput)

    class Meta:
        model = Account
        fields = ('email', 'name', 'phone', 'date_of_birth', 'picture', 'is_staff', 'is_superuser')

    def clean_password2(self):
        # Check that the two password entries match
        password1 = self.cleaned_data.get("password1")
        password2 = self.cleaned_data.get("password2")
        if password1 and password2 and password1 != password2:
            raise forms.ValidationError("Passwords don't match")
        return password2

    def save(self, commit=True):
        # Save the provided password in hashed format
        user = super().save(commit=False)
        user.set_password(self.cleaned_data["password1"])
        if commit:
            user.save()
        return user


class UserChangeForm(forms.ModelForm):
    password = ReadOnlyPasswordHashField()

    class Meta:
        model = Account
        fields = ('email', 'name', 'phone', 'date_of_birth', 'picture', 'password', 'is_active', 'is_superuser')

    def clean_password(self):
        # Regardless of what the user provides, return the initial value.
        # This is done here, rather than on the field, because the
        # field does not have access to the initial value
        return self.initial["password"]

Enter fullscreen mode Exit fullscreen mode

The views

We implement views for user registration and profile editing.

from django.shortcuts import render

from django.shortcuts import render, redirect
from django.contrib.auth import authenticate, login as auth_login, logout
from django.contrib import messages
from django.contrib.auth.forms import AuthenticationForm
from django.shortcuts import render

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse

from .models import Account
from .forms import RegistrationForm


class RegistrationView(CreateView):
    template_name = 'registration/register.html'
    form_class = RegistrationForm

    def get_context_data(self, *args, **kwargs):
        context = super(RegistrationView, self).get_context_data(*args, **kwargs)
        context['next'] = self.request.GET.get('next')
        return context

    def get_success_url(self):
        next_url = self.request.POST.get('next')
        success_url = reverse('login')
        if next_url:
            success_url += '?next={}'.format(next_url)

        return success_url


class ProfileView(UpdateView):
    model = Account
    fields = ['name', 'phone', 'date_of_birth', 'picture']
    template_name = 'registration/profile.html'

    def get_success_url(self):
        return reverse('index')

    def get_object(self):
        return self.request.user
Enter fullscreen mode Exit fullscreen mode

The admin

Now we register the custom user model with Django’s admin.

# accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from .models import Account
from .forms import UserCreationForm, UserChangeForm


class AccountAdmin(BaseUserAdmin):
    form = UserChangeForm
    add_form = UserCreationForm

    list_display = ('email', 'name', 'phone', 'date_of_birth', 'is_staff',  'is_superuser')
    list_filter = ('is_superuser',)

    fieldsets = (
        (None, {'fields': ('email', 'is_staff', 'is_superuser', 'password')}),
        ('Personal info', {'fields': ('name', 'phone', 'date_of_birth', 'picture')}),
        ('Groups', {'fields': ('groups',)}),
        ('Permissions', {'fields': ('user_permissions',)}),
    )
    add_fieldsets = (
        (None, {'fields': ('email', 'is_staff', 'is_superuser', 'password1', 'password2')}),
        ('Personal info', {'fields': ('name', 'phone', 'date_of_birth', 'picture')}),
        ('Groups', {'fields': ('groups',)}),
        ('Permissions', {'fields': ('user_permissions',)}),
    )

    search_fields = ('email', 'name', 'phone')
    ordering = ('email',)
    filter_horizontal = ()


admin.site.register(Account, AccountAdmin)

Enter fullscreen mode Exit fullscreen mode

Testing the app so far

We should now able to create users and edit their details.

Let's create a superuser.

(listings) $ python manage.py createsuperuser
Email: admin@example.com
Name: Admin User
Phone: 123456
Password:
Password (again):
Superuser created successfully.
Enter fullscreen mode Exit fullscreen mode

Run the Django dev server then navigate to http://127.0.0.1:8000/admin in your browser and login with the credentials you specified.

(listings) $ python manage runserver
Enter fullscreen mode Exit fullscreen mode

admin.PNG
You should be able to add and edit users.

The core app

Now let's implement the core app functionality which includes:

  • Anyone can view listings.
  • Users can register to create accounts.
  • Registered users can post listings
  • Logged in users can change their passwords.
  • Logged in users can edit their profiles.
  • Staff users can flag listings.

We will use Bootstrap 4 and django-crispy-forms for styling the application interface and forms.

Install django-crispy-forms

Install django-crispy-form using pip

(listings) $ pip install django-crispy-forms
Enter fullscreen mode Exit fullscreen mode

Then add crispy_forms the the installed apps list in your settings file and set the default styling to bootstrap4:

# listings/settings.py
...
INSTALLED_APPS = [
    ...
    'crispy_forms',
]

CRISPY_TEMPLATE_PACK = 'bootstrap4'
Enter fullscreen mode Exit fullscreen mode

Create the app

(listings) $ python manage.py startapp core
Enter fullscreen mode Exit fullscreen mode

Then add it to the installed apps list in settings.py

# listings/settings.py
INSTALLED_APPS = [
...
    'core.apps.CoreConfig',
]
Enter fullscreen mode Exit fullscreen mode

Since the user model has a picture field let's set up MEDIA_URL and MEDIA_ROOT to tell Django where to save uploaded images.

# listings/settings.py
...
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
Enter fullscreen mode Exit fullscreen mode

The auth app password reset view sends an email to the user containing instructions and a URL.
We will use the console email backend for testing purposes.

# listings/settings.py
...
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
Enter fullscreen mode Exit fullscreen mode

We also want to redirect to the home view on login and logout.

LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'home'
Enter fullscreen mode Exit fullscreen mode

The models

Each listing is associated with the user and category.
We also track the date and time that the listing was posted.
Listings may be flagged. We record the timestamp and user who flagged the listing.
By default listings will be sorted in reverse chronological order.

# core/models.py
from django.db import models
from django.conf import settings
from django.utils import timezone
from django.urls import reverse
from django.template.defaultfilters import slugify


class Category(models.Model):
    name = models.CharField(max_length=150)

    def __str__(self):
        return self.name

    class Meta:
        verbose_name_plural = "Categories"


class Listing(models.Model):
    title = models.CharField(max_length=150)
    content = models.TextField()
    category = models.ForeignKey(Category, on_delete=models.PROTECT)
    expiry_date = models.DateField(null=True, blank=True)
    location = models.CharField(max_length=150, null=True, blank=True)
    slug = models.SlugField()

    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        related_name='listings'
    )
    created_at = models.DateTimeField(default=timezone.now)

    flagged = models.BooleanField(default=False)
    flagged_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
        null=True,
        blank=True
    )
    flagged_at = models.DateTimeField(null=True, blank=True)

    def __str__(self):
        return self.title

    class Meta:
        ordering = ['-created_at']

    def save(self, *args, **kwargs):
        if not self.id:
            self.slug = slugify(self.title)

        return super(Listing, self).save(*args, **kwargs)

    def get_absolute_url(self):
        return reverse('listing',
                       args=[self.slug])
Enter fullscreen mode Exit fullscreen mode

We can now create migrations for the app and sync the database with the models.

(listings) $ python manage.py makemigrations core
(listings) $ python manage.py migrate core
Enter fullscreen mode Exit fullscreen mode

The admin

We register the Category and Listing models with the Django admin.

# core/admin.py
from django.contrib import admin
from django.utils import timezone

from .models import Category, Listing


class ListingAdmin(admin.ModelAdmin):
    list_display = ('category', 'title', 'content', 'created_by', 'flagged', 'flagged_by', 'flagged_at')
    list_filter = ('flagged',)
    exclude = ['slug', 'created_by', 'created_at', 'flagged_at', 'flagged_by']

    def save_model(self, request, obj, form, change):
        if not obj.pk:
            obj.created_by = request.user
        if obj.flagged:
            obj.flagged_by = request.user
            obj.flagged_at = timezone.now()
        else:
            obj.flagged_by = obj.flagged_at = None

        super().save_model(request, obj, form, change)


admin.site.register(Category)
admin.site.register(Listing, ListingAdmin)
Enter fullscreen mode Exit fullscreen mode

Navigate to http://127.0.0.1:8000/admin.
Login as the superuser and you will see new entries for Categories and Listings displayed.
coreadmin.PNG

Clicking the Add button and create some test categories.
Categories.PNG

The views

The application functionality consists of 3 views:

  • The default view displays the most recent 30 listings.
  • A view to post a listing, restricted to logged-in users.
  • A view to display a listings details.
# core/views.py
from django.shortcuts import render

from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.views.generic import DetailView
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView
from django.urls import reverse

from .models import Listing


class HomeView(ListView):
    template_name = 'home.html'
    queryset = Listing.objects.filter(flagged=False)
    context_object_name = 'listings'
    paginate_by = 30


class CreateListing(LoginRequiredMixin, CreateView):
    model = Listing
    fields = ['title', 'content', 'category', 'expiry_date', 'location']
    template_name = 'add_listing.html'

    def get_success_url(self):
        return reverse('home')

    def form_valid(self, form):
        form.instance.created_by = self.request.user
        return super().form_valid(form)


class ListingView(DetailView):
    template_name = 'listing.html'
    model = Listing

    def get_object(self):
        obj = super(ListingView, self).get_object()
        if obj.flagged:
            raise Http404()
        return obj

Enter fullscreen mode Exit fullscreen mode

The URLs

Let's start with th project-level URLs. We will re-use the auth app views for Login, Logout, Password Change & Password Reset.

# listings/urls.py
from django.contrib import admin
from django.urls import path, include
from django.contrib.auth import views as auth_views

from accounts import views
from accounts.views import RegistrationView, ProfileView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('core.urls')),

    path('register/', RegistrationView.as_view(), name='register'),
    path('profile/', ProfileView.as_view(), name='profile'),
    path('accounts/login/', auth_views.LoginView.as_view(), name='login'),
    path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),

    path('password_change/', auth_views.PasswordChangeView.as_view(), name='password_change'),
    path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),

    path('password_reset/', auth_views.PasswordResetView.as_view(), name='password_reset'),
    path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
    path('reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
    path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
]
Enter fullscreen mode Exit fullscreen mode

Then the core app URLs

# core/urls.py
from django.urls import path, include

from .views import HomeView, CreateListing, ListingView

urlpatterns = [
    path('', HomeView.as_view(), name='home'),
    path('add-listing', CreateListing.as_view(), name='add_listing'),
    path('listings/<slug:slug>/', ListingView.as_view(), name='listing'),
]

Enter fullscreen mode Exit fullscreen mode

The auth views render templates from a registration folder.
We will create a project level templates folder for all our templates and a registration subfolder.

(listings) $ mkdir -p templates/registration
Enter fullscreen mode Exit fullscreen mode

Then add the templates folder to the DIRS setting in settings.py

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

Enter fullscreen mode Exit fullscreen mode

The templates

auth templates

These templates will be used by the auth app views.
Auth templates will exend a base template.

<!--templates/registration/auth_base.html-->
<!doctype  html>
<html  lang="en">
<head>
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
<title>Listings</title>
</head>
<body>
  <div  class="container">
    <div  class="row justify-content-center">
      <div  class="col-4">
      <h1  class="text-center">Listings</h1>
      {% block content %}

      {% endblock %}
      </div>
    </div>
  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Login template used by LoginView.

<!--templates/registration/login.html-->
{% extends "registration/auth_base.html" %}
{% load crispy_forms_tags %}

{% block content %}

<div class="card">
  <div class="card-body">
    <h4 class="card-title">Log in to your account</h4>
    <form method="post">
      {% csrf_token %}
      <input type="hidden" name="next" value="{{ next }}">{{ form|crispy }}<button type="submit"
        class="btn btn-primary btn-block">Log in</button>
    </form>
  </div>
  <div class="card-footer">
    Forgot your password? <a href="{% url "password_reset" %}">click here</a><br>
    New user? <a href="{% url "register" %}?next={{ next }}">create new account</a>
  </div>
</div>

{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Register template used by RegistrationView.

<!--templates/registration/register.html-->
{% extends "registration/auth_base.html" %}
{% load crispy_forms_tags %}

{% block content %}

<div class="card">
    <div class="card-body">
        <h4 class="card-title">Signup for an account</h4>
        <form method="post" enctype="multipart/form-data">
            {% csrf_token %}
            {{form|crispy}}
            <input type="hidden" name="next" value="{{ next }}">
            <button type="submit" class="btn btn-primary btn-block">Register</button>

        </form>
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Change password template used by PasswordChangeView.

<!--templates/registration/password_change_form.html-->
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}

<div class="row justify-content-center">
    <div class="col-4">
        <div class="card">
            <div class="card-body">
                <h4 class="card-title">Change your password</h4>
                <form method="POST" enctype="multipart/form-data">
                    {% csrf_token %}
                    {{form|crispy}}
                    <button type="submit" class="btn btn-primary btn-block">Change Password</button>

                </form>
            </div>
        </div>
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Password change done template used by PasswordChangeDoneView.

<!--templates/registration/password_change_done.html-->
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}

<div class="row justify-content-center">
    <div class="col-8">
        <div class="card">
            <div class="card-body">
                <h2>Your password has changed</h2>
            </div>
        </div>
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Password Reset template used by PasswordResetView.

<!--templates/registration/password_reset_form.html-->
{% extends "registration/auth_base.html" %}
{% load crispy_forms_tags %}

{% block content %}

<div class="card">
    <div class="card-body">
        <h4 class="card-title">Reset your password</h4>
        <form method="post">
            {% csrf_token %}
            {{form|crispy}}
            <button type="submit" class="btn btn-primary btn-block">Reset</button>

        </form>
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Password reset email template used by PasswordResetView.

<!--templates/registration/password_reset_email.html-->
{% autoescape off %}
You're receiving this email because you requested a password reset for your user account at {{ site_name }}.

Please go to the following page and choose a new password:
{% block reset_link %}
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
{% endblock %}
Your username, in case you’ve forgotten: {{ user.get_username }}

Thanks for using our site!

The {{ site_name }} team

{% endautoescape %}
Enter fullscreen mode Exit fullscreen mode

Password reset done template used by PasswordResetDoneView.

<!--registration/templates/password_reset_done.html-->
{% extends "registration/auth_base.html" %}

{% block content %}

<div class="card">
    <div class="card-body">


        <p>We’ve emailed you instructions for setting your password, if an account exists with the email you entered.
            You should receive them shortly.</p>

        <p>If you don’t receive an email, please make sure you’ve entered the address you registered with, and check
            your spam folder.</p>
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Password reset confirm template used by PasswordResetConfirmView.

<!--templates/registration/password_reset_confirm.html-->
{% extends "registration/auth_base.html" %}
{% load crispy_forms_tags %}

{% block content %}

<div class="card">
    <div class="card-body">

        {% if validlink %}

        <p>Please enter your new password twice so we can verify you typed it in correctly.</p>

        <form method="post">{% csrf_token %}
            {{form|crispy}}
            <button type="submit" class="btn btn-primary btn-block">Change my password</button>

        </form>

        {% else %}

        <p>The password reset link was invalid, possibly because it has already been used. Please request a new password
            reset.</p>

        {% endif %}
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Password reset complete template used by PasswordResetCompleteView.

<!--templates/registration/password_reset_complete.html-->
{% extends "registration/auth_base.html" %}
{% block content %}

<div class="card">
    <div class="card-body">

        <p>Your password has been set. You may go ahead and log in now.</p>

        <p><a href="{{ login_url }}">Log in</a></p>
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Edit Profile template used by ProfileView.

<!--templates/registration/profile.html-->
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}

<div class="row justify-content-center">
    <div class="col-4">
        <div class="card">
            <div class="card-body">
                <h4 class="card-title">Update your profile</h4>
                <form method="POST" enctype="multipart/form-data">
                    {% csrf_token %}
                    {{form|crispy}}
                    <button type="submit" class="btn btn-primary btn-block">Update Profile</button>

                </form>
            </div>
        </div>
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

core templates

These templates will be used by the core app and should be saved in the project level templates folder.

The Base Template

<!--templates/base.html-->
<!doctype html>
<html lang="en">

<head>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">

    <title>Listings</title>
    <style>
        body {
            padding-top: 5rem;
        }
    </style>
</head>

<body>

    <nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
        <a href="{% url "home" %}" class="navbar-brand">Listings</a>
        <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbarCollapse">
            <span class="navbar-toggler-icon"></span>
        </button>
        {% if user.is_authenticated %}
        <div id="navbarCollapse" class="collapse navbar-collapse">

            <ul class="nav navbar-nav ml-auto">
                <li class="nav-item dropdown">
                    <a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown">{{user.name}}</a>
                    <div class="dropdown-menu dropdown-menu-right">
                        <a href="{% url "profile" %}" class="dropdown-item">Profile</a>
                        <a href="{% url "password_change" %}" class="dropdown-item">Change Password</a>
                        <div class="dropdown-divider"></div>
                        <a href="{% url "logout" %}" class="dropdown-item">Logout</a>
                    </div>
                </li>
            </ul>
        </div>
        {% endif %}
    </nav>

    <main role="main" class="container">
        {% block content %}

        {% endblock %}

    </main>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
</body>
Enter fullscreen mode Exit fullscreen mode

The Home template

<!--templates/home.html-->
{% extends 'base.html' %}
{% block content %}

<a href="{% url "add_listing" %}">Add Listing</a>
<h3 class="mt-2">Latest Listings</h3>
<div class="col-md-8 mt-3">    
    {% for listing in listings %}
    <div class="card">   
      <div class="card-body">


      <blockquote class="blockquote mb-0">
        <p>{{listing.title}}<small><a href="{{listing.get_absolute_url}}" >&nbsp;View Details &rarr;</a></small></p>
        <footer class="blockquote-footer"><small>Posted on {{listing.created_at|date:"M d, Y"}} by
            <a href="#">{{listing.created_by.name}}</a> in <a href="">{{listing.category}}</a></small></footer>
      </blockquote>
    </div>
    {% endfor %}
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Listing detail template

<!--templates/listing.html-->
{% extends 'base.html' %}
{% block content %}

<div class="card mb-3">
    <div class="card-body">
      <h5 class="card-title">{{listing.title}}</h5>
      <p class="card-text">{{listing.content}}</p>
      <p class="card-text"><small class="text-muted">Posted by {{listing.created_by.name}} on {{listing.created_at}}</small></p>
    </div>
  </div>

{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Add Listing template

<!--templates/add_listing.html-->
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="row">
<form method="POST" enctype="multipart/form-data">
    {% csrf_token %}
    {{form |crispy}}
    <button type="submit" class="btn btn-primary btn-block">Add Listing</button>

</form>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Testing the app

Navigate to http://127.0.0.1:8000 in your browser.
home.PNG

Clicking Add Listing should take you to the login form.
login.PNG

Click create new account and signup for an accountsignup.PNG

Upon creation of the account you will be redirected to the login form.
Login using the credentials you specified and you will be taken to the Listing creation form.
add_listing.PNG

Fill in some details and creating a listing and the listing. You will be redirected to the home page.
listings.PNG

You can test password change and profile edit by clicking using the dropdown menu in the upper right corner
menu.PNG

To test password reset, logout then click the forgot password link.
Enter your email and check the console for the email containing the reset link.
reset.PNG

email.PNG

Paste the reset URL in your browser and you will be prompted to enter your new password.
Enter your new password and a confirmation is displayed upon successful change.
confirm_reset.PNG
resetdone.PNG
You can then click the Log in link and enter your new password to login.

Conclusion

This article has shown how to customize Django authentication to implement the following features:

  • Using an email address instead of a username as the identification token.
  • Django admin integration for the custom user model.
  • User signup, login.
  • Password change and reset.

The Source code for this article can be found here.

💖 💪 🙅 🚩
joshwizzy
Joshua Masiko

Posted on March 20, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Choosing the Right Relational Database
undefined Choosing the Right Relational Database

November 29, 2024

This Week In Python
python This Week In Python

November 29, 2024

Django project - Part 2 Postgres
undefined Django project - Part 2 Postgres

November 29, 2024