From 99175dc57fc1afb214d70ddd1291b9002c82f652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20Ha=CC=88kli?= Date: Sun, 3 Feb 2019 00:59:14 +0200 Subject: [PATCH] Use middleware, backends, and signals for lockouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #389 Remove monkey-patching from the application loader phase and use the Django authentication stack for lockout signals. Utilize custom AUTHENTICATION_BACKENDS and MIDDLEWARE with signals with backwards compatible implementation of features. Update documentation, configuration and migration instructions to match the new configuration and improve the code commentation. Signed-off-by: Aleksi Häkli --- CHANGES.txt | 18 ++++++ axes/apps.py | 9 --- axes/backends.py | 35 ++++++----- axes/decorators.py | 45 +------------- axes/exceptions.py | 40 +++++++++++++ axes/middleware.py | 41 +++++++++++++ axes/signals.py | 3 + axes/test_settings.py | 5 +- axes/utils.py | 40 +++++++++++++ docs/configuration.rst | 133 +++++++++++++++++++++++++++++++++++++---- docs/index.rst | 1 + docs/migration.rst | 35 +++++++++++ 12 files changed, 328 insertions(+), 77 deletions(-) create mode 100644 axes/exceptions.py create mode 100644 axes/middleware.py create mode 100644 docs/migration.rst diff --git a/CHANGES.txt b/CHANGES.txt index 2c55b17..5b837fc 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,6 +1,24 @@ Changes ======= +5.0.0 (WIP) +----------- + +- Add a Django native authentication stack that utilizes + ``AUTHENTICATION_BACKENDS``, ``MIDDLEWARE``, and signal handlers + for tracking login attempts and implementing user lockouts. + This results in configuration changes, refer to the documentation. + [aleksihakli] + +- Remove automatic decoration of Django login views and forms. + Leave decorations available for application users who wish to + decorate their own login or other views as before. + [aleksihakli] + +- Clean up code, tests, and documentation. + [aleksihakli] + + 4.5.4 (2019-01-15) ------------------ diff --git a/axes/apps.py b/axes/apps.py index 8896d07..0ceb5d0 100644 --- a/axes/apps.py +++ b/axes/apps.py @@ -7,13 +7,4 @@ class AppConfig(apps.AppConfig): name = 'axes' def ready(self): - from django.contrib.auth.views import LoginView - from django.utils.decorators import method_decorator - from axes import signals # pylint: disable=unused-import,unused-variable - - from axes.decorators import axes_dispatch - from axes.decorators import axes_form_invalid - - LoginView.dispatch = method_decorator(axes_dispatch)(LoginView.dispatch) - LoginView.form_invalid = method_decorator(axes_form_invalid)(LoginView.form_invalid) diff --git a/axes/backends.py b/axes/backends.py index d153fa3..67f8d13 100644 --- a/axes/backends.py +++ b/axes/backends.py @@ -1,20 +1,16 @@ from __future__ import unicode_literals from django.contrib.auth.backends import ModelBackend -from django.core.exceptions import PermissionDenied from axes.attempts import is_already_locked +from axes.exceptions import AxesBackendPermissionDenied, AxesBackendRequestParameterRequired from axes.utils import get_credentials, get_lockout_message -class AxesModelBackend(ModelBackend): - - class RequestParameterRequired(Exception): - msg = 'AxesModelBackend requires calls to authenticate to pass `request` as an argument.' - - def __init__(self): - super(AxesModelBackend.RequestParameterRequired, self).__init__( - AxesModelBackend.RequestParameterRequired.msg) +class AxesBackend(ModelBackend): + """ + Authentication backend that forbids login attempts for locked out users + """ def authenticate(self, request, username=None, password=None, **kwargs): """Checks user lock out status and raises PermissionDenied if user is not allowed to log in. @@ -26,15 +22,18 @@ class AxesModelBackend(ModelBackend): Note that this method does not log your user in and delegates login to other backends. - :param request: see ModelBackend.authenticate - :param kwargs: see ModelBackend.authenticate + :param request: see django.contrib.auth.backends.ModelBackend.authenticate + :param username: see django.contrib.auth.backends.ModelBackend.authenticate + :param password: see django.contrib.auth.backends.ModelBackend.authenticate + :param kwargs: see django.contrib.auth.backends.ModelBackend.authenticate :keyword response_context: context dict that will be updated with error information - :raises PermissionDenied: if user is already locked out + :raises AxesBackendRequestParameterRequired: if request parameter is not given correctly + :raises AxesBackendPermissionDenied: if user is already locked out :return: None """ if request is None: - raise AxesModelBackend.RequestParameterRequired() + raise AxesBackendRequestParameterRequired('AxesBackend requires a request as an argument to authenticate') credentials = get_credentials(username=username, password=password, **kwargs) @@ -44,6 +43,14 @@ class AxesModelBackend(ModelBackend): error_msg = get_lockout_message() response_context = kwargs.get('response_context', {}) response_context['error'] = error_msg - raise PermissionDenied(error_msg) + + # Raise an error that stops the authentication flows at django.contrib.auth.authenticate. + # This error stops bubbling up at the authenticate call which catches backend PermissionDenied errors. + # After this error is caught by authenticate it emits a signal indicating user login failed, + # which is processed by axes.signals.log_user_login_failed which logs the attempt and raises + # a second exception which bubbles up the middleware stack and produces a HTTP 403 Forbidden reply + # in the axes.middleware.AxesMiddleware.process_exception middleware exception handler. + + raise AxesBackendPermissionDenied('AxesBackend detected that the given user is locked out') # No-op diff --git a/axes/decorators.py b/axes/decorators.py index 7a50857..e38de07 100644 --- a/axes/decorators.py +++ b/axes/decorators.py @@ -1,18 +1,12 @@ from __future__ import unicode_literals -from datetime import timedelta from functools import wraps -import json import logging -from django.http import HttpResponse -from django.http import HttpResponseRedirect -from django.shortcuts import render - from axes import get_version from axes.conf import settings from axes.attempts import is_already_locked -from axes.utils import iso8601, get_client_username, get_lockout_message +from axes.utils import get_lockout_response log = logging.getLogger(settings.AXES_LOGGER) if settings.AXES_VERBOSE: @@ -29,7 +23,7 @@ if settings.AXES_VERBOSE: def axes_dispatch(func): def inner(request, *args, **kwargs): if is_already_locked(request): - return lockout_response(request) + return get_lockout_response(request) return func(request, *args, **kwargs) @@ -40,41 +34,8 @@ def axes_form_invalid(func): @wraps(func) def inner(self, *args, **kwargs): if is_already_locked(self.request): - return lockout_response(self.request) + return get_lockout_response(self.request) return func(self, *args, **kwargs) return inner - - -def lockout_response(request): - context = { - 'failure_limit': settings.AXES_FAILURE_LIMIT, - 'username': get_client_username(request) or '' - } - - cool_off = settings.AXES_COOLOFF_TIME - if cool_off: - if isinstance(cool_off, (int, float)): - cool_off = timedelta(hours=cool_off) - - context.update({ - 'cooloff_time': iso8601(cool_off) - }) - - if request.is_ajax(): - return HttpResponse( - json.dumps(context), - content_type='application/json', - status=403, - ) - - if settings.AXES_LOCKOUT_TEMPLATE: - return render( - request, settings.AXES_LOCKOUT_TEMPLATE, context, status=403 - ) - - if settings.AXES_LOCKOUT_URL: - return HttpResponseRedirect(settings.AXES_LOCKOUT_URL) - - return HttpResponse(get_lockout_message(), status=403) diff --git a/axes/exceptions.py b/axes/exceptions.py new file mode 100644 index 0000000..3139ce1 --- /dev/null +++ b/axes/exceptions.py @@ -0,0 +1,40 @@ +from __future__ import unicode_literals + +from django.core.exceptions import PermissionDenied + + +class AxesPermissionDenied(PermissionDenied): + """ + Base class for permission denied errors raised by axes specifically for easier debugging + + Two different types of errors are used because of the behaviour Django has: + + - If an authentication backend raises a PermissionDenied error the authentication flow is aborted. + - If another component raises a PermissionDenied error a HTTP 403 Forbidden response is returned. + """ + + pass + + +class AxesSignalPermissionDenied(AxesPermissionDenied): + """ + Raised by signal handler on failed authentication attempts to send user a HTTP 403 Forbidden status code + """ + + pass + + +class AxesBackendPermissionDenied(AxesPermissionDenied): + """ + Raised by authentication backend on locked out requests to stop the Django authentication flow + """ + + pass + + +class AxesBackendRequestParameterRequired(ValueError): + """ + Raised by authentication backend on invalid or missing request parameter value + """ + + pass diff --git a/axes/middleware.py b/axes/middleware.py new file mode 100644 index 0000000..f3be766 --- /dev/null +++ b/axes/middleware.py @@ -0,0 +1,41 @@ +from __future__ import unicode_literals + +from axes.exceptions import AxesSignalPermissionDenied +from axes.utils import get_lockout_response + + +class AxesMiddleware: + """ + Middleware that maps lockout signals into readable HTTP 403 Forbidden responses + + Without this middleware the backend returns HTTP 403 errors with the + django.views.defaults.permission_denied view that renders the 403.html + template from the root template directory if found. + + Refer to the Django documentation for further information: + + https://docs.djangoproject.com/en/dev/ref/views/#the-403-http-forbidden-view + + To customize the error rendering, you can for example inherit this middleware + and change the process_exception handler to your own liking. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + return self.get_response(request) + + def process_exception(self, request, exception): # pylint: disable=inconsistent-return-statements + """ + Exception handler that processes exceptions raised by the axes signal handler when request fails with login + + Refer to axes.signals.log_user_login_failed for the error code. + + :param request: HTTPRequest that will be locked out. + :param exception: Exception raised by Django views or signals. Only AxesSignalPermissionDenied will be handled. + :return: HTTPResponse that indicates the lockout or None. + """ + + if isinstance(exception, AxesSignalPermissionDenied): + return get_lockout_response(request) diff --git a/axes/signals.py b/axes/signals.py index be2bbea..141069b 100644 --- a/axes/signals.py +++ b/axes/signals.py @@ -17,6 +17,7 @@ from axes.attempts import get_user_attempts from axes.attempts import is_user_lockable from axes.attempts import ip_in_whitelist from axes.attempts import reset_user_attempts +from axes.exceptions import AxesSignalPermissionDenied from axes.models import AccessLog, AccessAttempt from axes.utils import get_client_str from axes.utils import query2str @@ -122,6 +123,8 @@ def log_user_login_failed(sender, credentials, request, **kwargs): # pylint: di 'axes', request=request, username=username, ip_address=ip_address ) + raise AxesSignalPermissionDenied('User locked out due to failed login attempts') + @receiver(user_logged_in) def log_user_logged_in(sender, request, user, **kwargs): # pylint: disable=unused-argument diff --git a/axes/test_settings.py b/axes/test_settings.py index f552dd0..a7e72d7 100644 --- a/axes/test_settings.py +++ b/axes/test_settings.py @@ -20,10 +20,13 @@ MIDDLEWARE = ( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', + + 'axes.middleware.AxesMiddleware', ) AUTHENTICATION_BACKENDS = ( - 'axes.backends.AxesModelBackend', + 'axes.backends.AxesBackend', + 'django.contrib.auth.backends.ModelBackend', ) diff --git a/axes/utils.py b/axes/utils.py index eb6a83b..476e137 100644 --- a/axes/utils.py +++ b/axes/utils.py @@ -5,11 +5,14 @@ try: except ImportError: pass +from datetime import timedelta from inspect import getargspec from logging import getLogger from socket import error, inet_pton, AF_INET6 from django.core.cache import caches +from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.shortcuts import render from django.utils import six import ipware.ip2 @@ -165,3 +168,40 @@ def get_lockout_message(): if settings.AXES_COOLOFF_TIME: return settings.AXES_COOLOFF_MESSAGE return settings.AXES_PERMALOCK_MESSAGE + + +def get_lockout_response(request): + context = { + 'failure_limit': settings.AXES_FAILURE_LIMIT, + 'username': get_client_username(request) or '' + } + + cool_off = settings.AXES_COOLOFF_TIME + if cool_off: + if isinstance(cool_off, (int, float)): + cool_off = timedelta(hours=cool_off) + + context.update({ + 'cooloff_time': iso8601(cool_off) + }) + + status = 403 + + if request.is_ajax(): + return JsonResponse( + context, + status=status, + ) + + if settings.AXES_LOCKOUT_TEMPLATE: + return render( + request, + settings.AXES_LOCKOUT_TEMPLATE, + context, + status=status, + ) + + if settings.AXES_LOCKOUT_URL: + return HttpResponseRedirect(settings.AXES_LOCKOUT_URL) + + return HttpResponse(get_lockout_message(), status=status) diff --git a/docs/configuration.rst b/docs/configuration.rst index b36d024..b83c1ef 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -5,28 +5,139 @@ Configuration Add ``axes`` to your ``INSTALLED_APPS``:: - INSTALLED_APPS = ( + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', - 'django.contrib.sites', - # ... - 'axes', - # ... - ) + 'django.contrib.messages', + 'django.contrib.staticfiles', -Add ``axes.backends.AxesModelBackend`` to the top of ``AUTHENTICATION_BACKENDS``:: + # ... other applications per your preference. + + 'axes', + ] + +Add ``axes.backends.AxesBackend`` to the top of ``AUTHENTICATION_BACKENDS``:: AUTHENTICATION_BACKENDS = [ - 'axes.backends.AxesModelBackend', - # ... + # AxesBackend should be the first backend in the list. + # It stops the authentication flow when a user is locked out. + 'axes.backends.AxesBackend', + + # ... other authentication backends per your preference. + + # Django ModelBackend is the default authentication backend. 'django.contrib.auth.backends.ModelBackend', - # ... + ] + +Add ``axes.middleware.AxesMiddleware`` to your list of ``MIDDLEWARE``:: + + MIDDLEWARE = [ + # The following is the list of default middleware in new Django projects. + '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', + + # ... other middleware per your preference. + + # AxesMiddleware should be the last middleware in the list. + # It pretty formats authentication errors into readable responses. + 'axes.middleware.AxesMiddleware', ] Run ``python manage.py migrate`` to sync the database. +How does Axes function? +----------------------- + +When a user tries to log in in Django, the login is usually performed +by running a number of authentication backends that check user login +information by calling the ``django.contrib.auth.authenticate`` function. + +If an authentication backend does not approve of a user login, +it can raise a ``django.core.exceptions.PermissionDenied`` exception. + +If a login fails, Django then fires a +``from django.contrib.auth.signals.user_login_failed`` signal. + +If this signal raises an exception, it is propagated through the +Django middleware stack where it can be caught, or alternatively +where it can bubble up to the default Django exception handlers. + +A normal login flow for Django runs as follows:: + + 1. Django or plugin login view is called by + for example user sending form data with browser. + + 2. django.contrib.auth.authenticate is called by + the view code to check the authentication request + for user and return a user object matching it. + + 3. AUTHENTICATION_BACKENDS are iterated over + and their authenticate methods called one-by-one. + + 4. An authentication backend either returns + a user object which results in that user + being logged in or returns None. + If a PermissionDenied error is raised + by any of the authentication backends + the whole request authentication flow + is aborted and signal handlers triggered. + +Axes monitors logins with the ``user_login_failed`` signal handler +and after login attempts exceed the given maximum, starts blocking them. + +The blocking is done by ``AxesBackend`` which checks every request +coming through the Django authentication flow and verifies they +are not blocked, and allows the requests to go through if the check passes. + +If any of the checks fails, an exception is raised which interrupts +the login process and triggers the Django login failed signal handlers. + +Another exception is raised by a Axes signal handler, which is +then caught by ``AxesMiddleware`` and converted into a readable +error because the user is currently locked out of the system. + +Axes implements the lockout flow as follows:: + + 1. Django or plugin login view is called. + + 2. django.contrib.auth.authenticate is called. + + 3. AUTHENTICATION_BACKENDS are iterated over + where axes.backends.AxesBackend is the first. + + 4. AxesBackend checks authentication request + for lockouts rules and either aborts the + authentication flow or lets the authentication + process proceed to the next + configured authentication backend. + + [The lockout happens at this stage if appropriate] + + 5. User is locked out and signal handlers + are notified of the failed login attempt. + + 6. axes.signals.log_user_login_failed runs + and raises a AxesSignalPermissionDenied + exception that bubbles up the middleware stack. + + 7. AxesMiddleware processes the exception + and returns a readable error to the user. + +This plugin assumes that the login views either call +the django.contrib.auth.authenticate method to log in users +or otherwise take care of notifying Axes of authentication +attempts or login failures the same way Django does. + +The login flows can be customized and the Axes +authentication backend or middleware can be easily swapped. + Running checks -------------- @@ -85,7 +196,7 @@ There are no known problems in other cache backends such as Authentication backend problems ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you get ``AxesModelBackend.RequestParameterRequired`` exceptions, +If you get ``AxesBackendRequestParameterRequired`` exceptions, make sure any auth libraries and middleware you use pass the request object to authenticate. Notably in older versions of Django Rest Framework (DRF) (before 3.7.0), ``BasicAuthentication`` does not pass request. `Here is an example workaround for DRF `_. diff --git a/docs/index.rst b/docs/index.rst index af423c0..4682cb1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,7 @@ Contents installation configuration + migration usage requirements development diff --git a/docs/migration.rst b/docs/migration.rst new file mode 100644 index 0000000..d26fafd --- /dev/null +++ b/docs/migration.rst @@ -0,0 +1,35 @@ +.. _migration: + +Migration +========= + +This page contains migration instructions between different django-axes +versions so that users might more confidently upgrade their installations. + +From django-axes version 4 to version 5 +--------------------------------------- + +Application version 5 has a few differences compared to django-axes 4. + +You might need to search your own codebase and check if you need to change +API endpoints or names for compatibility reasons. + +- Login and logout view monkey-patching was removed. + Login monitoring is now implemented with signals + and locking users out is implemented with a combination + of a custom authentication backend, middlware, and signals. +- ``AxesModelBackend`` was renamed to ``AxesBackend`` + for better naming and preventing the risk of users accidentally + upgrading without noticing that the APIs have changed. + Documentation was improved. Exceptions were renamed. +- ``axes.backends.AxesModelBackend.RequestParameterRequired`` + exception was renamed, retyped to ``ValueError`` from ``Exception``, and + moved to ``axes.exception.AxesBackendRequestParameterRequired``. +- ``AxesBackend`` now raises a + ``axes.exceptions.AxesBackendPermissionDenied`` + exception when user is locked out which triggers signal handler + to run on failed logins, checking user lockout statuses. +- Axes lockout signal handler now raises exception + ``axes.exceptions.AxesSignalPermissionDenied`` on lockouts. +- ``AxesMiddleware`` was added to return lockout responses. + The middleware handles ``axes.exception.AxesSignalPermissionDenied``.