Repository: wger-project/wger

Issue:
No protection against bruteforce attacks on login page
At the moment there is no protection against brute force attacks on the login page, while not a priority it would be nice if we could do something about it.

* https://github.com/jazzband/django-defender
* https://github.com/jazzband/django-axes


Discussion:
@rolandgeider , If this issue is still relevant, I would like to work on this.
Go ahead 👍🏻
Is redis used in production server ? Because then we can use the cache for monitoring login attempts which can provide better performance
Yes there's a redis server, also on the provided docker compose, we can just use it

Resolving patch:
diff --git a/extras/docker/development/settings.py b/extras/docker/development/settings.py
--- a/extras/docker/development/settings.py
+++ b/extras/docker/development/settings.py
@@ -116,9 +116,16 @@
 # The site's domain as used by the email verification workflow
 EMAIL_PAGE_DOMAIN = 'http://localhost/'
 
+
+# Django Axes
+AXES_ENABLED = True  # allow to disable axes entirely (e.g. if this is run in a local network or so we would save up some resources), but default is true
+AXES_FAILURE_LIMIT = 5  # configurable, default is 5
+AXES_COOLOFF_TIME = 0.5  # configurable, default is 0.5 hours
+AXES_HANDLER = 'axes.handlers.cache.AxesCacheHandler'  # Configurable, but default is the cache handler
+
 #
 # Django Rest Framework SimpleJWT
 #
 SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'] = timedelta(minutes=env.int("ACCESS_TOKEN_LIFETIME", 15))
 SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'] = timedelta(hours=env.int("REFRESH_TOKEN_LIFETIME", 24))
-SIMPLE_JWT['SIGNING_KEY'] = env.str("SIGNING_KEY", SECRET_KEY)
+SIMPLE_JWT['SIGNING_KEY'] = env.str("SIGNING_KEY", SECRET_KEY)
\ No newline at end of file
diff --git a/wger/core/views/user.py b/wger/core/views/user.py
--- a/wger/core/views/user.py
+++ b/wger/core/views/user.py
@@ -282,7 +282,7 @@ def registration(request):
                 config.save()
 
             user.userprofile.save()
-            user = authenticate(username=username, password=password)
+            user = authenticate(request=request, username=username, password=password)
 
             # Log the user in
             django_login(request, user)
diff --git a/wger/settings_global.py b/wger/settings_global.py
--- a/wger/settings_global.py
+++ b/wger/settings_global.py
@@ -91,6 +91,9 @@
     # CORS
     'corsheaders',
 
+    # Django Axes
+    'axes',
+
     # History keeping
     'simple_history',
 
@@ -127,10 +130,15 @@
 
     # History keeping
     'simple_history.middleware.HistoryRequestMiddleware',
+
+    # Django Axes
+    'axes.middleware.AxesMiddleware',  # should be the last one in the list
 )
 
 AUTHENTICATION_BACKENDS = (
-    'django.contrib.auth.backends.ModelBackend', 'wger.utils.helpers.EmailAuthBackend'
+    'axes.backends.AxesStandaloneBackend',  # should be the first one in the list
+    'django.contrib.auth.backends.ModelBackend',
+    'wger.utils.helpers.EmailAuthBackend',
 )
 
 TEMPLATES = [
@@ -158,7 +166,7 @@
                 'django.template.loaders.app_directories.Loader',
             ],
             'debug':
-            False
+                False
         },
     },
 ]
@@ -274,6 +282,25 @@
     }
 }
 
+
+#
+# Django Axes
+#
+AXES_ENABLED = True
+AXES_FAILURE_LIMIT = 5
+AXES_COOLOFF_TIME = 0.5  # in hours
+AXES_LOCKOUT_TEMPLATE = None
+AXES_RESET_ON_SUCCESS = False
+AXES_RESET_COOL_OFF_ON_FAILURE_DURING_LOCKOUT = True
+
+# If you want to set up redis, set AXES_HANDLER = 'axes.handlers.cache.AxesCacheHandler'
+AXES_HANDLER = 'axes.handlers.database.AxesDatabaseHandler'
+
+# If your redis or MemcachedCache has a different name other than 'default'
+# (e.g. when you have multiple caches defined in CACHES), change the following value to that name
+AXES_CACHE = 'default'
+
+
 #
 # Django Crispy Templates
 #
@@ -381,15 +408,15 @@
 # Django Rest Framework
 #
 REST_FRAMEWORK = {
-    'DEFAULT_PERMISSION_CLASSES': ('wger.utils.permissions.WgerPermission', ),
+    'DEFAULT_PERMISSION_CLASSES': ('wger.utils.permissions.WgerPermission',),
     'DEFAULT_PAGINATION_CLASS':
-    'rest_framework.pagination.LimitOffsetPagination',
+        'rest_framework.pagination.LimitOffsetPagination',
     'PAGE_SIZE':
-    20,
+        20,
     'PAGINATE_BY_PARAM':
-    'limit',  # Allow client to override, using `?limit=xxx`.
+        'limit',  # Allow client to override, using `?limit=xxx`.
     'TEST_REQUEST_DEFAULT_FORMAT':
-    'json',
+        'json',
     'DEFAULT_AUTHENTICATION_CLASSES': (
         'rest_framework.authentication.SessionAuthentication',
         'rest_framework.authentication.TokenAuthentication',
@@ -425,7 +452,7 @@
 #
 # Ignore these URLs if they cause 404
 #
-IGNORABLE_404_URLS = (re.compile(r'^/favicon\.ico$'), )
+IGNORABLE_404_URLS = (re.compile(r'^/favicon\.ico$'),)
 
 #
 # Password rules


Test patch:
diff --git a/wger/core/tests/base_testcase.py b/wger/core/tests/base_testcase.py
--- a/wger/core/tests/base_testcase.py
+++ b/wger/core/tests/base_testcase.py
@@ -152,6 +152,10 @@ def setUp(self):
         self.media_root = tempfile.mkdtemp()
         settings.MEDIA_ROOT = self.media_root
 
+        # Disable django-axes
+        # https://django-axes.readthedocs.io/en/latest/3_usage.html#authenticating-users
+        settings.AXES_ENABLED = False
+
     def tearDown(self):
         """
         Reset settings