Django config for implementing a password policy

Intro

I needed to added some password checks from the security department for the website I develop using Django 3.0 created with django-cookiecutter.

The authentication set up in such a way that there is no signup for admin account, all accounts are created manually by the superuser. The user password activation and reset email is already handled by django-allauth.

The most of the password compliance rules are already built-in and enabled by default in Django, but there are several that are not, two in particular are covered in this article:

  • Password complexity enforcement.
  • Forcing the user with admin access to reset password, if the password has been set by site admin (superuser) when the user account was created.

As convention, I will use <project> as a placeholder for project name, that would also serve as a root folder for the file paths.

Password Complexity enforcement

To enable default password validation from the relevant documentation [1] the following was added to the <project>/config/settings/base.py

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
        'OPTIONS': {
            'min_length': 8,
        }
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    }
}

The requirements, however went a little bit further and outlined the need to confirm that the password contain an upper case, lower case and a number. The working implementation was the following:

from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _


class DigitLowerUpperValidator:
    password_requirements = _(
        "The password must to contain at least one digit, lower case and "
        "upper case letter"
    )

    def validate(self, password, user=None):
        # number in string
        if not any(c.isdigit() for c in password):
            raise ValidationError(self.password_requirements)
        # lower in string
        if not any(c.islower() for c in password):
            raise ValidationError(self.password_requirements)
        # upper in stirng
        if not any(c.isupper() for c in password):
            raise ValidationError(self.password_requirements)

    def get_help_text(self):
        return _(
            "The password must contain at least one upper case, "
            "lower case letter and at least a digit")

I prefer placing this code under project site directory as it relates to user, authentication and the site settings <project>/users/password_validation.py. I added the full class path as a password validator in AUTH_PASSWORD_VALIDATORS

 AUTH_PASSWORD_VALIDATORS = [
     {
         'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
     },
     {
         'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
         'OPTIONS': {
             'min_length': 8,
         }
     },
     {
         'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
     },
     {
         'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
     },
     {
         'NAME': '<project>.users.password_validation.DigitLowerUpperValidator',
     },
 }

Forcing password reset

This project already has custom user fields through AbstractUser, so I added a new user flag and overrode set_password logic by adding onto this existing customization.

It is much easier to set up AbstractUser at the beginning of the project [2], if you already using something else, switching to using AbstractUser implementation is somewhat involved [3]

Adding fields & logic to AbstractUser implementation

The existing implementation is located directly in the site namespace <project>/users/models.py. Base implementation would be similar to this:

from django.contrib.auth.models import AbstractUser

class User(AbstractUser):

    def get_absolute_url(self):
        return reverse("users:detail", kwargs={"username": self.username})

I need to add a property for keeping track of the state if the user has set the password themself, it could be as simple as adding boolean variable for the model, but if I ever get a requirement to use expiration date, everything would be much easier if I set this field as a date, allowing it to be nullable, as this is an optional feature.

Then whenever the password is set by the user, the expiration field should be set to None [4]

from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):

    expiration = models.DateTimeField(null=True)

    def get_absolute_url(self):
        return reverse("users:detail", kwargs={"username": self.username})

    def set_password(self, raw_password):
        self.expiration = None
        self.save()
        super().set_password(raw_password)

Then I added auto-generated migration and applied them.

$ python manage.py makemigrations
$ python manage.py migrate

Catching User creation events with Signals

The only way I could catch user creation event by the superuser, was to implement a receiver signal from User class creation event. [5] This is where the flag is set to redirect the user upon login. In this case the expiration date is now.

This code is relevant to the user management and authentication, so I placed it in <project>/users/signals.py

import pytz

from datetime import datetime

from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import User

current_tz = pytz.timezone(settings.TIME_ZONE)

@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        instance.expiration = datetime.now(current_tz)
        instance.save()

Verifying password change attribute with a middleware class

Finally a middleware class is added to verify that the user gets redirected to password change anytime they successfully logged in and try to access any of the admin pages. [6] The middleware is placed directly into site namespace <project>/middleware.py and checks expiration model property.

import pytz

from datetime import datetime
from django.http.response import HttpResponseRedirect
from django.conf import settings

from <project>.users.models import User


current_tz = pytz.timezone(settings.TIME_ZONE)


class AuthMiddleware:

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        if not hasattr(response, 'url'):
            return response
        if type(request.user) is not User:
            return response
        if '/admin/' in response.url:
            if request.user.expiration is None:
                return response
            if request.user.expiration.astimezone(current_tz) < datetime.now(current_tz):
                return HttpResponseRedirect(redirect_to='/admin/password_change')
        return response

Then I added the middleware class to the MIDDLEWARE list in base.py

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.common.BrokenLinkEmailsMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "<project>.middleware.AuthMiddleware"
]

Conclusion

I'm still looking for a more efficient way of implementing user expiration checking, that doesn't execute this middleware class on every request, something that could call only after user has been authenticated.

In the end, forcing the user to change the password turned out to be surprisingly complicated and there might be an easier way of making this work.

Password change dialog where the user needs to retype the password.

One caveat, is that the user is presented with the dialog, where they need to enter password again, which is somewhat annoying.