Use middleware, backends, and signals for lockouts

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 <aleksi.hakli@iki.fi>
This commit is contained in:
Aleksi Häkli 2019-02-03 00:59:14 +02:00
parent d33a55b927
commit 99175dc57f
No known key found for this signature in database
GPG key ID: 3E7146964D726BBE
12 changed files with 328 additions and 77 deletions

View file

@ -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)
------------------

View file

@ -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)

View file

@ -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

View file

@ -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)

40
axes/exceptions.py Normal file
View file

@ -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

41
axes/middleware.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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',
)

View file

@ -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)

View file

@ -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 <https://gist.github.com/markddavidoff/7e442b1ea2a2e68d390e76731c35afe7>`_.

View file

@ -16,6 +16,7 @@ Contents
installation
configuration
migration
usage
requirements
development

35
docs/migration.rst Normal file
View file

@ -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``.