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.