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