Implement login counter in django

In this article, you will learn how to implement a login counter in Django and lock out a user after x number of failed login attempts.

Prerequisites.
1. A basic understanding of Python and Django.

1.0 Application set up.

Navigate to your preferred folder and create a Django project named django-login-counter.

cd Desktop 
django-admin startproject django_login_counter

Once created, navigate into the Django project and create and activate the virtual environment.

python3 -m virtualenv venv 
source venv/bin/activate

With the virtual environment up and running, create a file at the base of the project named requirements.txt and paste in the following

Django==4.2.7
djangorestframework
django-environ 
django-filter 
django-autoslug 
django-countries 
Pillow 
django-phonenumber-field 
phonenumbers 
psycopg2-binary
flake8
black 
isort

Install the dependencies by running the command

pip3 install -r requirements.txt

The above command installs the libraries specified in the requirements.txt file. To view a list of all the installed libraries run the command.

pip3 list

you should have a similar output to the one in the image below.

1.1 Set up flake 8.

At the base of your project, create a new file named setup.cfg and add the following lines of code.

#setup.cfg 
[flake8]
max-line-length = 119 
exclude = .git,*/migrations/*,*env*,*venv*,__pycache__,*/staticfiles/*,*/mediafiles/*

1.2 Configure django_environ to read environment variables.

Create a new file named .env and add the following variables

SECRET_KEY=
DEBUG=
ALLOWED_HOSTS=

In your settings file, set up Django environ by first importing it and setting it up to read from the earlier created env file.

#settings.py 
import environ

from pathlib import Path

env = environ.Env(DEBUG=(bool, False))

environ.Env.read_env(BASE_DIR / ".env")

SECRET_KEY = env("SECRET_KEY")

1.3 Create users and profile apps.

In the base of the project, create a folder named apps, this will house all the apps that will be created and make it a python package by creating an init.py file.

Create the users and profile apps.

django-admin startapp users 
django-admin start-app profiles 
django-admin start-app common

Once the apps are created, move them into the apps folder and change the apps.py file specifically the name to have a prefix of apps.

from django.apps import AppConfig


class CommonConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "apps.common"

At this point, your folder structure should be similar to the one below.

register the newly created apps in your settings file

DJANGO_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "django.contrib.sites",
]

THIRD_PARTY_APPS = [
    "rest_framework",
    "django_filters",
    "django_countries",
    "phonenumber_field",
]
LOCAL_APPS = ["apps.common", "apps.users", "apps.profiles"]

INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS + THIRD_PARTY_APPS
SITE_ID = 1

1.4 Split settings file.

In your django_counter folder, create a new folder named settings and add the __init__.py file to make it a Python package.
Add the following files with the content therein.

#base.py 
import environ

from pathlib import Path

env = environ.Env(DEBUG=(bool, False))

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

environ.Env.read_env(BASE_DIR / ".env")


SECRET_KEY = env("SECRET_KEY")

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env("DEBUG")

ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(" ")


# Application definition

DJANGO_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "django.contrib.sites",
]

THIRD_PARTY_APPS = [
    "rest_framework",
    "django_filters",
    "django_countries",
    "phonenumber_field",
]
LOCAL_APPS = ["apps.common", "apps.users", "apps.profiles"]

INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS + THIRD_PARTY_APPS
SITE_ID = 1
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "django_counter.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [],
        "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",
            ],
        },
    },
]

WSGI_APPLICATION = "django_counter.wsgi.application"


# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases


# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
    },
    {
        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
    },
]


# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/

LANGUAGE_CODE = "en-us"

TIME_ZONE = "Africa/Nairobi"

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/

STATIC_URL = "staticfiles/"
STATIC_ROOT = BASE_DIR / "staticfiles"

STATICFILES_DIR = []
MEDIA_URL = "/mediafiles"

MEDIA_ROOT = BASE_DIR / "mediafiles"

# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

and the development.py file

#settings/development.py file 
from .base import *

DATABASES = {
    "default": {
        "ENGINE": env("POSTGRES_ENGINE"),
        "NAME": env("POSTGRES_DB"),
        "USER": env("POSTGRES_USER"),
        "PASSWORD": env("POSTGRES_PASSWORD"),
        "HOST": env("PG_HOST"),
        "PORT": env("PG_PORT"),
    }
}

Register the new settings format in your manage.py and wsgi files

#wsgi.py 
import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_counter.settings.development")

application = get_wsgi_application()

and the manage.py file

#!/usr/bin/env python

import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault(
        "DJANGO_SETTINGS_MODULE", "django_counter.settings.development"
    )
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == "__main__":
    main()

2.0 Set up a custom user model.

In Django, you have 2 ways of extending the base user class :

  • Abstract User - use this when you are comfortable with the default user fields and you only want to extend the email field.

  • Abstract Base User - use this when you want to build your custom User model from the ground up. This is what will be used in this article.

In the user app, create a file named managers.py and paste in the following lines of code.

from django.contrib.auth.base_user import BaseUserManager

from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.utils.translation import gettext_lazy as _


class CustomUserManager(BaseUserManager):
    def email_validator(self, email):
        try:
            validate_email
        except ValidationError:
            raise ValueError(_("You must provide valid email address"))

    def create_user(
        self, username, first_name, last_name, email, password, **extra_fields
    ):
        if not username:
            raise ValueError(_("Users must submit a username "))
        if not first_name:
            raise ValueError(_("Users must submit a first_name "))
        if not last_name:
            raise ValueError(_("Users must submit a last_name "))
        if email:
            email = self.normalize_email(email)
            self.email_validator(email)
        else:
            raise ValueError(_("Base user account: An email address is required"))

        user = self.model(
            username=username,
            first_name=first_name,
            last_name=last_name,
            email=email,
            **extra_fields
        )
        user.set_password(password)
        extra_fields.setdefault("is_staff", False)
        extra_fields.setdefault("is_superuser", False)
        user.save(using=self._db)
        return user

    def create_superuser(
        self, username, first_name, last_name, email, password, **extra_fields
    ):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)
        extra_fields.setdefault("is_active", True)

        if extra_fields.get("is_staff") is not True:
            raise ValueError(_("super users must have is_staff=True"))

        if extra_fields.get("is_superuser") is not True:
            raise ValueError(_("super users must have is_superuser=True"))
        if not password:
            raise ValueError(_("Users must submit a password"))
        if email:
            email = self.normalize_email(email)
            self.email_validator(email)
        else:
            raise ValueError(_("Admin account: An email address is required"))

        user = self.create_user(
            username, first_name, last_name, email, password, **extra_fields
        )
        user.save(using=self._db)
        return user

In the snippet above, you are extending the BaseUserManager class and adding some functionality to create custom users.

Replace the content in the users/models.py file with the following, the code snippet below imports the AbstractBaseUser and the Permissions mixin, extends it and creates a user model with the fields specified below.

#users/models.py 
import uuid

from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _

from .managers import CustomUserManager


class User(AbstractBaseUser, PermissionsMixin):
    pkid = models.BigAutoField(primary_key=True, editable=False)
    id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
    username = models.CharField(verbose_name=_("Username"), max_length=255)
    first_name = models.CharField(verbose_name=_("First Name"), max_length=50)
    last_name = models.CharField(verbose_name=_("Last Name"), max_length=50)
    email = models.EmailField(verbose_name=_("Email Address"), unique=True)
    is_staff = models.BooleanField(default=False)
    is_active = models.BooleanField(default=False)
    date_joined = models.DateTimeField(default=timezone.now)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["username", "first_name", "last_name"]

    objects = CustomUserManager()

    class Meta:
        verbose_name = _("User")
        verbose_name_plural = _("Users")

    def __str__(self) -> str:
        return self.username

    @property
    def get_full_name(self):
        return f"{self.first_name.title()} {self.last_name.title()}"

    @property
    def get_short_name(self):
        return self.username

With the model in place, head over to the settings/base.py file and register the User model. This informs Django that you will be using the custom user model defined.

#settings/base.py 
AUTH_USER_MODEL = "users.User"

Create and run migrations to propagate the changes we have made to the User model.

python3 manage.py makemigrations 
python3 manage.py migrate

3.0 Create a common model.

In this part, you will create a common timestamped model which is an abstract model that will hold common fields in all the models in the application.

# common/models.py 
from django.db import models
import uuid

# Create your models here.


class TimeStampedModel(models.Model):
    pkid = models.BigAutoField(primary_key=True, editable=False)
    id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

4.0 Create the profile model and associated signal and auth api's.

4.1 Create profile model.

With the user and the common timestamped model up, you will now create the Profile model with the fields you need. In the profiles/models.py, add the following lines of code.

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

from django.utils.translation import gettext_lazy as _
from django_countries.fields import CountryField
from apps.common.models import TimeStampedModel

User = get_user_model()

# Create your models here.


class Gender(models.TextChoices):
    MALE = "MALE", _("MALE")
    FEMALE = "FEMALE", _("FEMALE")
    OTHER = "OTHER", _("OTHER")


class Profile(TimeStampedModel):
    user = models.OneToOneField(User, related_name="profile", on_delete=models.CASCADE)
    about_me = models.TextField(verbose_name=_("About Me"))
    gender = models.CharField(
        verbose_name=_("Gender"), choices=Gender.choices, default=Gender.MALE
    )
    country = CountryField(
        verbose_name=_("Country"), default="KE", blank=False, null=False
    )
    city = models.CharField(
        verbose_name=_("City"),
        max_length=180,
        default="Nairobi",
        blank=False,
        null=False,
    )

    def __str__(self) -> str:
        return f"{self.user.username}"

4.2 Create the profile creation signals.

Create the signals, this will get fired up when a new user is created and it will create the profile for the corresponding user.

#profiles/signals.py 
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver

from django.contrib.auth import get_user_model

from apps.profiles.models import Profile

logger = logging.getLogger(__name__)

User = get_user_model()


@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)


@receiver(post_save, sender=User)
def save_user_profile(sender, instance, **kwargs):
    instance.profile.save()
    logger.info(f" {instance}'s profile created")

Do not forget to create and run the migrations to effect the schema changes.

Create a new super user and the profile for the new super user will be created as well to show that the signals are working as expected.

4.3 Create authentication api's.

In this part, you will learn how to create authentication APIs using Django rest framework and jwt.
In the requirements.txt file, add the following libraries and install them by running the command pip install -r requirements.txt

#requirements.txt 
...,
django-rest-knox
PyJWT

register and set up the newly installed libraries in your settings/base.py file

# settings/base.py

THIRD_PARTY_APPS = [
...
    "knox",
]



from datetime import timedelta


from rest_framework.settings import api_settings


REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": "knox.auth.TokenAuthentication",
}

REST_KNOX = {
    "SECURE_HASH_ALGORITHM": "cryptography.hazmat.primitives.hashes.SHA512",
    "AUTH_TOKEN_CHARACTER_LENGTH": 64,  # By default, it is set to 64 characters (this shouldn't need changing).
    "TOKEN_TTL": timedelta(
        minutes=45
    ),  # The default is 10 hours i.e., timedelta(hours=10)).
    "USER_SERIALIZER": "knox.serializers.UserSerializer",
    "TOKEN_LIMIT_PER_USER": None,  # By default, this option is disabled and set to None -- thus no limit.
    "AUTO_REFRESH": False,  # This defines if the token expiry time is extended by TOKEN_TTL each time the token is used.
    "EXPIRY_DATETIME_FORMAT": api_settings.DATETIME_FORMAT,
}

With the set-up complete, you can now create the user serializer file.

According to the official django rest framework documentation , serializer allows complex data to be converted to native Python types that can then be rendered into formats such as JSON or other content types.

Create the serializers.py file and add the following lines of code.

#users/serializers.py 
from django.contrib.auth import get_user_model
from django_countries.serializer_fields import CountryField
from djoser.serializers import UserCreateSerializer
from phonenumber_field.serializerfields import PhoneNumberField
from rest_framework import serializers

User = get_user_model()


class UserSerializer(serializers.ModelSerializer):
    gender = serializers.CharField(source="profile.gender")
    country = CountryField(source="profile.country")
    city = serializers.CharField(source="profile.city")

    first_name = serializers.SerializerMethodField()
    last_name = serializers.SerializerMethodField()
    full_name = serializers.SerializerMethodField(source="get_full_name")

    class Meta:
        model = User
        fields = [
            "id",
            "username",
            "email",
            "first_name",
            "last_name",
            "gender",
            "country",
            "city",
        ]

    def get_first_name(self, obj):
        return obj.first_name.title()

    def get_last_name(self, obj):
        return obj.last_name.title()

    def to_representation(self, instance):
        representation = super(UserSerializer, self).to_representation(instance)
        if instance.is_superuser:
            representation["admin"] = True
        return representation


class CreateUserSerializer(UserCreateSerializer):
    class Meta(UserCreateSerializer.Meta):
        model = User
        fields = ["id", "username", "email", "first_name", "last_name", "password"]

The serializer is now set up and we can now extend the views.

# apps/users/api.py 
from rest_framework import generics, views, status, permissions
from rest_framework.response import Response
from .serializers import LoginSerializer, UserSerializer, CreateUserSerializer
from knox.models import AuthToken
from apps.users.models import User

# from django.contrib import
# from rest_framework.


class LoginView(generics.CreateAPIView):
    serializer_class = LoginSerializer

    def post(self, request):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        user = serializer.validated_data

        print("user -->", user)
        return Response(
            {
                "user": UserSerializer(user, context=self.serializer_class).data,
                "token": AuthToken.objects.create(user)[1],
            }
        )


class RegisterView(generics.CreateAPIView):
    # pass

    serializer_class = CreateUserSerializer

    def post(self, request):
        serializer = CreateUserSerializer(data=request.data)

        if serializer.is_valid():
            serializer.save(is_active=True)
            return Response("User created successfully", status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


class ProfileView(generics.RetrieveAPIView):
    permission_classes = (permissions.IsAuthenticated,)
    serializer_class = UserSerializer

    def get_queryset(self):
        print(self.request.user)
        return User.objects.get(id=self.request.user.id)

And add the URLs to expose the functionality.

#apps/users/urls.py
from django.conf import settings
from django.urls import path, include
from .api import LoginView, RegisterView, ProfileView

urlpatterns = [
    path("login", LoginView.as_view(), name="login_view"),
    path("register", RegisterView.as_view(), name="registration_view"),
    path("profile", ProfileView.as_view(), name="profile_endpoint"),
]

register the above-added users URLs to the main URL file.

#django-counter/urls 
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path("supersecret/", admin.site.urls),
    path("api/v1/auth/", include("apps.users.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

3.0 Login counter set up.

With the authentication set up, you will now set up the login counter, this will limit the number of incorrect login attempts a user makes and block them for a certain period.

This helps prevent brute force attacks on the login API. This can also be extended to IP blocking.

Create a new model named LoginCounter in the users app and add the following lines of code.

# apps/users/models.py 
class LoginCounter(TimeStampedModel):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    max_tries = models.IntegerField(default=0)
    locked_at = models.DateTimeField(blank=True, null=True)

    def __str__(self) -> str:
        return f"{self.user.email} - {self.max_tries}"

This model will hold the invalid login attempts of the user and in the next part, refactor the LoginSerializer to implement this functionality.

class LoginSerializer(serializers.Serializer):
    username = serializers.CharField()
    password = serializers.CharField()

    def validate(self, data):
        userExists = User.objects.filter(email=data["username"])
        if not userExists:
            return serializers.ValidationError("User does not exist")
        loginCounter, created = LoginCounter.objects.get_or_create(user=userExists[0])
        max_tries = 5

        user = authenticate(**data)
        if not created:
            if (
                loginCounter.locked_at is not None
                and loginCounter.locked_at > datetime.datetime.now(pytz.utc)
            ):
                raise serializers.ValidationError(
                    "Account locked, try after 30 minutes"
                )
        if user and user.is_active:
            if not created:
                loginCounter.max_tries = 0
                loginCounter.locked_at = None
                loginCounter.save()
            return user
        if not created:
            loginCounter.max_tries += 1
            if loginCounter.max_tries >= max_tries:
                print(True)
                loginCounter.locked_at = datetime.datetime.now(
                    pytz.utc
                ) + datetime.timedelta(minutes=2)

            loginCounter.save()


        raise serializers.ValidationError("Incorrect Validation")

Head over to Postman or your favourite API testing tools and test out the implementations. Feel free to change the max_tries value and the period in which the account is to be locked.
The full code of this project can be found at this repository.
Happy hacking.