diff --git a/README.md b/README.md index 4cfad283f905d76a298be7853cb2867d714f41e0..4de44c827ef98a71db2fb83eb50fd885488d7acf 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ BASETHEME_BOOTSTRAP_USER_PREFERENCE_MODEL_NAME = "MyUserPreferences" BASETHEME_BOOTSTRAP_USERNAME_IS_EMAIL = False BASETHEME_BOOTSTRAP_FIRST_LAST_NAME_REQUIRED = False BASETHEME_BOOTSTRAP_VALIDATE_EMAIL_BEFORE_ACTIVATION = False +BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_ENABLED = True +BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_GRADIENT = 1.47 ################################################################################ ``` diff --git a/README.rst b/README.rst index fa7a28b45cd2370b704c75bc4eb3c98039d1c395..8788a90a997852e14a68303830c45f9c76bb9849 100644 --- a/README.rst +++ b/README.rst @@ -55,6 +55,8 @@ Quick start BASETHEME_BOOTSTRAP_USERNAME_IS_EMAIL = False BASETHEME_BOOTSTRAP_FIRST_LAST_NAME_REQUIRED = False BASETHEME_BOOTSTRAP_VALIDATE_EMAIL_BEFORE_ACTIVATION = False + BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_ENABLED = True + BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_GRADIENT = 1.47 ################################################################################ diff --git a/basetheme_bootstrap/default_settings.py b/basetheme_bootstrap/default_settings.py index a6583f05903239995025f6b7d644997ff592ed03..fe6f73026c1dcdbbd79f310b5bd3263ac171d594 100644 --- a/basetheme_bootstrap/default_settings.py +++ b/basetheme_bootstrap/default_settings.py @@ -20,3 +20,21 @@ def is_validating_email(): return settings.BASETHEME_BOOTSTRAP_VALIDATE_EMAIL_BEFORE_ACTIVATION except AttributeError: return False + + +def is_failed_login_exponential_backoff_enabled() -> bool: + try: + return settings.BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_ENABLED + except AttributeError: + return True + + +def get_failed_login_exponential_backoff_gradient() -> float: + try: + return settings.BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_GRADIENT + except AttributeError: + # import math + # watch_period = 300 + # max_login_attempt = 15 + # return math.ceil(math.exp(math.log(watch_period) / max_login_attempt) * 100) / 100 + return 1.47 diff --git a/basetheme_bootstrap/forms.py b/basetheme_bootstrap/forms.py index 2c911c8aaf0ddcb5ce46894833b71ddcdcb262ce..50cb2044c4772141a8919d406f8967452715416c 100644 --- a/basetheme_bootstrap/forms.py +++ b/basetheme_bootstrap/forms.py @@ -1,12 +1,19 @@ from django import forms from django.contrib.auth import get_user_model, forms as auth_forms +from django.core.cache import cache +from django.core.exceptions import ValidationError from django.db.models import Q from django.forms import widgets from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from basetheme_bootstrap.default_settings import is_username_is_email, is_first_last_name_required +from basetheme_bootstrap.default_settings import ( + is_username_is_email, + is_first_last_name_required, + get_failed_login_exponential_backoff_gradient, + is_failed_login_exponential_backoff_enabled, +) class CleanUsernameAndSuggestReset: @@ -94,3 +101,36 @@ class AuthenticationForm(auth_forms.AuthenticationForm): super().__init__(*args, **kwargs) if is_username_is_email(): self.fields["username"].label = _("Email") + + def clean(self): + if not is_failed_login_exponential_backoff_enabled(): + return super().clean() + ip_address = self.request.META.get("REMOTE_ADDR") + + # Increment the login attempt count for this IP address + cache_key = f"login_attempts:{ip_address}" + login_attempts = cache.get(cache_key, 0) + cache.set(cache_key, login_attempts + 1, 300) + + # check if lock exists for this ip address + locked_key = f"locked_login_attempts:{ip_address}" + locked_login_attempts = cache.get(locked_key, False) + # expiry of the lock is based on the number of already done login_attempts + wait_time = min( + get_failed_login_exponential_backoff_gradient() + ** login_attempts, + 300, + ) + cache.set(locked_key, True, wait_time) + + # if locked was found we fail the form validation to prevent successful login + if locked_login_attempts: + m = int(wait_time // 60) + s = int(wait_time - 60 * m) + # informing the user that zhe has hit the wall and have to wait + raise ValidationError( + _( + "Too many login attempts ({:d}). Please try again later after {:02d}:{:02d} minute." + ).format(login_attempts, m, s) + ) + return super().clean() diff --git a/basetheme_bootstrap/locale/en/LC_MESSAGES/django.po b/basetheme_bootstrap/locale/en/LC_MESSAGES/django.po index bb28f0b5f2302da975eea1f1de7b86fee195f0bf..6a65c8bdaee70d33d9ab38826cc2b04571d70fee 100644 --- a/basetheme_bootstrap/locale/en/LC_MESSAGES/django.po +++ b/basetheme_bootstrap/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-11-17 12:43+0000\n" +"POT-Creation-Date: 2024-09-03 11:11+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -36,6 +36,11 @@ msgstr "" msgid "Email" msgstr "" +msgid "" +"Too many login attempts ({:d}). Please try again later after {:02d}:{:02d} " +"minute." +msgstr "" + msgid "Error 403" msgstr "" diff --git a/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.mo b/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.mo index bf8d8ab21436b7a690f6cf4a7c15b9b0bc23a4cd..2f1ba904093ce2f4bfd188bdafe14335fb0ba910 100644 Binary files a/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.mo and b/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.mo differ diff --git a/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.po b/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.po index f4e5c1cca1c57abecdc19da9ed1ada8085355e78..d177d18542cd69c16961f6f019c5606c3fbfa3f2 100644 --- a/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.po +++ b/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-11-17 12:43+0000\n" +"POT-Creation-Date: 2024-09-03 11:54+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -38,6 +38,13 @@ msgstr "Le nom est requis" msgid "Email" msgstr "Courriel" +msgid "" +"Too many login attempts ({:d}). Please try again later after {:02d}:{:02d} " +"minute." +msgstr "" +"Il y a trop de tentatives de connection ({:d}). Avant d'essayer de nouveau, " +"merci d'attendre {:02d}m{:02d}s" + msgid "Error 403" msgstr "Erreur 403" diff --git a/basetheme_bootstrap/tests.py b/basetheme_bootstrap/tests.py index 7bfa7d2959138696ef42509c9380b955ccd8f163..8fbe70db771b08ef270425bc71c7d14b1f00ad8a 100644 --- a/basetheme_bootstrap/tests.py +++ b/basetheme_bootstrap/tests.py @@ -1,5 +1,6 @@ import os import re +from time import sleep from django.conf import settings from django.contrib.auth import get_user_model @@ -10,7 +11,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase, RequestFactory, override_settings from django.urls import reverse -from basetheme_bootstrap import user_preferences_utils, tokens +from basetheme_bootstrap import user_preferences_utils, tokens, default_settings from basetheme_bootstrap.user_preferences_utils import get_user_preferences_for_user @@ -697,3 +698,101 @@ class TemplatesTagsTestsDebugFalse(TemplatesTagsTests): ) class UserPreferencesTestsDebugFalse(UserPreferencesTests): pass + + +class ExponentialBackoffFailedLoginEnabledTests(TestCase): + def test_basic(self): + self.assertTrue(default_settings.is_failed_login_exponential_backoff_enabled()) + + +@override_settings( + BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_ENABLED=False, +) +class ExponentialBackoffFailedLoginDisabledTests(TestCase): + def test_basic(self): + self.assertFalse(default_settings.is_failed_login_exponential_backoff_enabled()) + + +@override_settings( + BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_ENABLED=True, +) +class ExponentialBackoffFailedLoginEnabledTests(TestCase): + def test_basic(self): + self.assertTrue(default_settings.is_failed_login_exponential_backoff_enabled()) + + +class ExponentialBackoffFailedLoginTests(TestCase): + def setUp(self): + cache.clear() + self.user_pwd = "eil2guj4cuSho2Vai3hu" + self.user = get_user_model().objects.create( + username="a", + email="a@a.a", + ) + self.user.set_password(self.user_pwd) + self.user.save() + + def test_login_ok(self): + data = { + 'username': self.user.username, + 'email': self.user.email, + 'password': self.user_pwd, + } + response = self.client.post(reverse('basetheme_bootstrap:login'), data, follow=False) + self.assertEqual(response.status_code, 302) + self.assertTrue(response.wsgi_request.user.is_authenticated) + + def test_works(self): + data = { + 'username': self.user.username, + 'email': self.user.email, + 'password': "NOT IT", + } + response = self.client.post(reverse('basetheme_bootstrap:login'), data, follow=False) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.wsgi_request.user.is_authenticated) + + for i in range(10): + self.client.post(reverse('basetheme_bootstrap:login'), data, follow=False) + + response = self.client.post(reverse('basetheme_bootstrap:login'), data, follow=False) + self.assertFalse(response.wsgi_request.user.is_authenticated) + + +@override_settings( + BASETHEME_BOOTSTRAP_FAILED_LOGIN_EXPONENTIAL_BACKOFF_GRADIENT=1, +) +class ExponentialBackoffFailedLoginAfterDelayTests(TestCase): + def setUp(self): + cache.clear() + self.user_pwd = "eil2guj4cuSho2Vai3hu" + self.user = get_user_model().objects.create( + username="a", + email="a@a.a", + ) + self.user.set_password(self.user_pwd) + self.user.save() + + def test_works(self): + data = { + 'username': self.user.username, + 'email': self.user.email, + 'password': "NOT IT", + } + + for i in range(10): + response = self.client.post(reverse('basetheme_bootstrap:login'), data, follow=False) + self.assertFalse(response.wsgi_request.user.is_authenticated) + + sleep(2) + + response = self.client.post( + reverse('basetheme_bootstrap:login'), + data={ + 'username': self.user.username, + 'email': self.user.email, + 'password': self.user_pwd, + }, + follow=False, + ) + self.assertTrue(response.wsgi_request.user.is_authenticated) diff --git a/setup.cfg b/setup.cfg index 4e0e67cd1d9519ca903795e6972c88bee1c2dd69..e46a5c31edf73796cc87bf8c3673431e9b99db90 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-basetheme-bootstrap -version = 1.7.0 +version = 1.8.0 description = Django Basetheme Bootstrap long_description = file: README.rst url = https://gitlab.pasteur.fr/bbrancot/django-basetheme-bootstrap