mirror of
https://github.com/jazzband/django-axes.git
synced 2026-03-16 22:30:23 +00:00
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:
parent
d33a55b927
commit
99175dc57f
12 changed files with 328 additions and 77 deletions
18
CHANGES.txt
18
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)
|
||||
------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
40
axes/exceptions.py
Normal 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
41
axes/middleware.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>`_.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ Contents
|
|||
|
||||
installation
|
||||
configuration
|
||||
migration
|
||||
usage
|
||||
requirements
|
||||
development
|
||||
|
|
|
|||
35
docs/migration.rst
Normal file
35
docs/migration.rst
Normal 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``.
|
||||
Loading…
Reference in a new issue