From 93f34a14be1efddfa5bc63118d3d9e5e5d8a90f8 Mon Sep 17 00:00:00 2001
From: Bryan Brancotte <bryan.brancotte@pasteur.fr>
Date: Tue, 3 Sep 2024 14:11:01 +0200
Subject: [PATCH] [security] adding exponential backoff on (failed) login

---
 README.md                                     |   2 +
 README.rst                                    |   2 +
 basetheme_bootstrap/default_settings.py       |  18 ++++
 basetheme_bootstrap/forms.py                  |  42 +++++++-
 .../locale/en/LC_MESSAGES/django.po           |   7 +-
 .../locale/fr/LC_MESSAGES/django.mo           | Bin 7742 -> 7981 bytes
 .../locale/fr/LC_MESSAGES/django.po           |   9 +-
 basetheme_bootstrap/tests.py                  | 101 +++++++++++++++++-
 setup.cfg                                     |   2 +-
 9 files changed, 178 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 4cfad28..4de44c8 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 fa7a28b..8788a90 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 a6583f0..fe6f730 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 2c911c8..50cb204 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 bb28f0b..6a65c8b 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
GIT binary patch
delta 1741
zcmY+^TWAzl9LMp$HHk4MR%4TDs?F4E(#Bhim#(=OZ&bw#3Vl#19e1bhn%!B+PGUlk
z6#`LE1QlN_^hF;OOhFceTI!3`h&~9SzIbV^s4uNTz&=QYet)x*LJxc9bLM1b&N=`8
zIl10C+@610U2@ha>!~xTtsj`VxVMB0<@ZuEe)f#Z1bl^4@eS7DxH7YkaVAc|7Swk(
zpq}4{pWp%1cZM*DCvdJ=-rmxvrlXFhKg5No2is5|T8I4XTP~9^iCuUIui|r@hUd!7
z=HQR0`+KPGyu^w4XXM|J>a1XVtDsTK12w2cH{lFiidxwg9KbkgqQ4<O8|6}i|DYyX
zT~QoxF|sCGjxlV-Nq7iZu??akco8cZ->%Wnj7Lx}KEm_(2Wmn?yseZRM}_<^)P&0`
zi3--ER(=GR;sxA^4^b0rtSUyX1L?ADcpUd&{%abqXe`8b;#)h;ER6@aA0!`3@HMt#
zJqccpd$9peV<*r5jEcy6mZb$uAWTZtWUR+os7SP-Ce(*o$jNHrPx|Z<9a_N^RL&k?
z4j-Wg+D>?;V;}0pQ>cM1qk{z`t#%)oi#@?Ee2u^0?vIKoozCc5Kr<?W$r|FX7xQ!w
zZ95iuCh`XiCy4y)N_0OG-QPv6@C6QIDPLvIR>1Li6P4@RxELR!B2~%8B8yg=r|}hy
z*+>?w3m0KO>cbaN58lFAcpo*<QT!F(qb7KR@FejLeu^#ZU`1*d2I%8#e2yEjj6W(u
z`K>gRtDlfX*ez5DpQ1wd277UsL%ag#vei4t`5rvV{rAi&hI1HoGk$|w&?Qv=DDrq1
z4W*b&g#T+Au|?<Kp`pS!;XjJc!n^-pSfs6rx|Sh}2n*>AOFLJsu8pdVtfJ^OP#5Ti
zqNCSA{gSH9JfB*n^S_S9=TsfFv7(i#d{HFKnewWlJgV@IGCUpO4VQ8%K?yge`V`fb
zYD8rXDwWy~W2I9$)0x%oUK{l){7q3?r=gk32@H3xx|MH*Rz>G|tgyGj+-v`=rfQ!k
zrP??13d5!E%64S4PR8~7opg48%6Hr#@G=L3oYOE6OB`-o<m^a$Zq9Rp?taJJ@A5?2
z4ZLp0{Z7|`*plYN;aCwTlk$54uW+GqepP-`+TjIW%N}$Ryzcpd8>D)@obKXT-}mA{
zD(n9@OlPm_2To#vm&>{Re3_^GY)`M}_B1&euREUNZ<?E*==P#vGsR(Zw$qCzd4%eu
Q;wjsjO{5A>X1FE)0-z1a8UO$Q

delta 1484
zcmYM!OGs346vy#n&X}oVHa;q+hh>^tPNR-xW<Hu~BwS>{kk!JXg*btWB*LJ93Zh(w
z1Q7%UZA#1rluQi*Ta*i1r9mx%pclBZMHuz{U9a?K&i(xF%zd2yIrq+B<77kPlixF7
zj0mxT2(K{fz%~yX##65u9~)+~5=U_jzQjU&himaC=Ab8S={otS<Et@%^~fO>!!A6H
z+sqO+#l+7S-*GkmK^^GhY$cS7x=@HsHr8Prqd0;|%*F0>vtqo0+JAt$&Iqo;ao5+b
zGnm2s?E{kn4)}sv^dhdu6wcDh0(co~P>Bv9AA7|nAE!}?en#E!FR}*nX0U3^!c2@J
zE4JgP3SPo2?r*o4DC0Y*6CdM4e1=LW#@T1^6sqJCsDx*+6X#JYKbmD$g}tc69-%5W
zj(qGbn|_?d7@qS{|4Jq=m=vU#eZy|{)rSzou>tR4EzV(?ex}ha9AAel*7{hE62FeR
zVLxuhyQm6`p%Pj^Eg+LJ(lsmfQ-5V1<O`MV7+$~=s2fe=I$S`Vm`#~<qjD_55YjDc
zM?HKeF^+MZ!1t(D4&*H@U<jR7qt08*qyALcyrd^{Tm$H2h<q&Q?jLmb+fXY!i-UL-
z$<abwJryI!Vl9fhume@8n@G3pHn!sZ1QV)mUonWkP!}%Y$<u*{a3i*(670b#>_a8k
zOnJJn6*u89s!}u9i$8EHc2b7Jcn#H}H%QKwm}8>S&7(@DvoFFV?#5bP-X?l|9&fP!
zgh#y|!(?>?dr&JGLw%o0794I=*hw&q8f8Qkp_}d_v{+6_I9ZpovpjT>5W)YDZ6`{J
zYNCkHP*qgQ<x$C`*4@$Da)3}Pv`P&MWzED+qJ>afc#fU1SMPts-KoL-L<K<?oS|N7
zG!c!2Dn%ij7J)PLK&r%Qk6KB^o!5B_YV0BE2tDBGg#H6G^q=AUpjS)}iH1tP!<qfx
e&>80~DRIBjyIh$3=>46Rtnd~4lFti!J^ug~Rd~Aq

diff --git a/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.po b/basetheme_bootstrap/locale/fr/LC_MESSAGES/django.po
index f4e5c1c..d177d18 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 7bfa7d2..8fbe70d 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 4e0e67c..e46a5c3 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
-- 
GitLab