diff --git a/axes/checks.py b/axes/checks.py index 56f6283..ab31205 100644 --- a/axes/checks.py +++ b/axes/checks.py @@ -27,7 +27,9 @@ def axes_cache_backend_check(app_configs, **kwargs): # pylint: disable=unused-a axes_cache_backend = axes_cache_config.get('BACKEND', '') axes_cache_incompatible_backends = [ + 'django.core.cache.backends.dummy.DummyCache', 'django.core.cache.backends.locmem.LocMemCache', + 'django.core.cache.backends.filebased.FileBasedCache', ] axes_handler = getattr(settings, 'AXES_HANDLER', '') diff --git a/axes/conf.py b/axes/conf.py index 75f25fc..b405204 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -21,6 +21,13 @@ class AxesAppConf(AppConf): # see if the user has set axes to lock out logins after failure limit LOCK_OUT_AT_FAILURE = True + # lock out with the combination of username and IP address + LOCK_OUT_BY_COMBINATION_USER_AND_IP = False + + # lock out with username and never the IP or user agent + ONLY_USER_FAILURES = False + + # lock out with the user agent, has no effect when ONLY_USER_FAILURES is set USE_USER_AGENT = False # use a specific username field to retrieve from login POST data @@ -32,15 +39,9 @@ class AxesAppConf(AppConf): # use a provided callable to transform the POSTed username into the one used in credentials USERNAME_CALLABLE = None - # only check user name and not location or user_agent - ONLY_USER_FAILURES = False - # reset the number of failed attempts after one successful attempt RESET_ON_SUCCESS = False - # lock out user from particular IP based on combination USER+IP - LOCK_OUT_BY_COMBINATION_USER_AND_IP = False - DISABLE_ACCESS_LOG = False DISABLE_SUCCESS_ACCESS_LOG = False diff --git a/axes/tests/settings.py b/axes/tests/settings.py index e150568..46d46ad 100644 --- a/axes/tests/settings.py +++ b/axes/tests/settings.py @@ -10,6 +10,9 @@ DATABASES = { CACHES = { 'default': { + # This cache backend is OK to use in development and testing + # but has the potential to break production setups with more than on server + # due to each computer having their own filesystems and cache files 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', 'LOCATION': os.path.abspath(os.path.join(tempfile.gettempdir(), 'axes')), 'OPTIONS': { diff --git a/docs/architecture.rst b/docs/architecture.rst new file mode 100644 index 0000000..69e4e62 --- /dev/null +++ b/docs/architecture.rst @@ -0,0 +1,124 @@ +.. _architecture: + +Architecture +============ + +Axes is based on the existing Django authentication backend +architecture and framework for recognizing users and aims to be +compatible with the stock design and implementation of Django +while offering extensibility and configurability for using the +Axes authentication monitoring and logging for users of the package +as well as 3rd party package vendors such as Django REST Framework, +Django Allauth, Python Social Auth and so forth. + +The development of custom 3rd party package support are active goals, +but you should check the up-to-date documentation and implementation +of Axes for current compatibility before using Axes with custom solutions +and make sure that authentication monitoring is working correctly. + +This document describes the Django authentication flow +and how Axes augments it to achieve authentication and login +monitoring and lock users out on too many access attempts. + +Django authentication flow +-------------------------- + +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. Login view is called by, for example, + a user sending form data with browser. + + 2. django.contrib.auth.authenticate is called by + the view code to check the authentication request + for credentials and return a user object matching them. + + 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. + + +Django authentication flow with Axes +------------------------------------ + +Axes monitors logins with the ``user_login_failed`` signal handler +and after login attempts exceed the given maximum, starts blocking them. + +Django emits the ``user_login_failed`` signal when an authentication backend +either raises the PermissionDenied signal or alternatively no authentication backend +manages to recognize a given authentication request and return a user for it. + +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. 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 lockout rules and either aborts the + authentication flow or lets the authentication + process proceed to the next configured + authentication backend in the list. + + [Axes handler runs at this this stage if appropriate] + + 5. If the user authentication request fails due to + any reason, e.g. a lockout or wrong credentials, + Axes receives authentication failure information + via the axes.signals.handle_user_login_failed signal. + + 6. The selected Axes handler is run to check + the user login failure statistics and rules. + + [Axes default handler implements these steps] + + 7. Axes logs the failure and increments the failure + counters which keep track of failure statistics. + + 8. AxesSignalPermissionDenied exception is raised + if appropriate and it bubbles up the middleware stack. + + 9. AxesMiddleware processes the exception + and returns a readable lockout message 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. diff --git a/docs/captcha.rst b/docs/captcha.rst deleted file mode 100644 index 4accb44..0000000 --- a/docs/captcha.rst +++ /dev/null @@ -1,47 +0,0 @@ -.. _captcha: - -Using a captcha -=============== - -Using https://github.com/mbi/django-simple-captcha you do the following: - -1. Change axes lockout url in ``settings.py``:: - - AXES_LOCKOUT_URL = '/locked' - -2. Add the url in ``urls.py``:: - - url(r'^locked/$', locked_out, name='locked_out'), - -3. Create a captcha form:: - - class AxesCaptchaForm(forms.Form): - captcha = CaptchaField() - -4. Create a captcha view for the above url that resets on captcha success and redirects:: - - def locked_out(request): - if request.POST: - form = AxesCaptchaForm(request.POST) - if form.is_valid(): - ip = get_ip_address_from_request(request) - reset(ip=ip) - return HttpResponseRedirect(reverse_lazy('signin')) - else: - form = AxesCaptchaForm() - - return render_to_response('locked_out.html', dict(form=form), context_instance=RequestContext(request)) - -5. Add a captcha template:: - -
- {% csrf_token %} - - {{ form.captcha.errors }} - {{ form.captcha }} - -
- -
-
- diff --git a/docs/configuration.rst b/docs/configuration.rst index f896664..7f3a16d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -3,242 +3,45 @@ Configuration ============= -Add ``axes`` to your ``INSTALLED_APPS``:: +Minimal Axes configuration is done with just ``settings.py`` updates. - INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - # ... other applications per your preference. - - 'axes', - ] - -Add ``axes.backends.AxesBackend`` to the top of ``AUTHENTICATION_BACKENDS``:: - - AUTHENTICATION_BACKENDS = [ - # 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 --------------- - -Use the ``python manage.py check`` command to verify the correct configuration in both -development and production environments. It is probably best to use this step as part -of your regular CI workflows to verify that your project is not misconfigured. - -Axes uses the checks to verify your cache configuration to see that your caches -should be functional with the configuration of Axes. Many people have different configurations -for their development and production environments. +More advanced configuration and integrations might require updates +on source code level depending on your project implementation. -Known configuration problems +Configuring project settings ---------------------------- -Axes has a few configuration issues with external packages and specific cache backends -due to their internal implementations. +The following ``settings.py`` options are available for customizing Axes behaviour. -Cache problems -~~~~~~~~~~~~~~ - -If you are running Axes on a deployment with in-memory Django cache, -the ``axes_reset`` functionality might not work predictably. - -Axes caches access attempts application-wide, and the in-memory cache -only caches access attempts per Django process, so for example -resets made in one web server process or the command line with ``axes_reset`` -might not remove lock-outs that are in the sepate process' in-memory cache -such as the web server process serving your login or admin page. - -To circumvent this problem please use somethings else than -``django.core.cache.backends.locmem.LocMemCache`` as your -cache backend in Django cache ``BACKEND`` setting. - -If it is not an option to change the default cache you can add a cache -specifically for use with Axes. This is a two step process. First you need to -add an extra cache to ``CACHES`` with a name of your choice:: - - CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - }, - 'axes_cache': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', - } - } - -The next step is to tell Axes to use this cache through adding ``AXES_CACHE`` -to your ``settings.py`` file:: - - AXES_CACHE = 'axes_cache' - -There are no known problems in other cache backends such as -``DummyCache``, ``FileBasedCache``, or ``MemcachedCache`` backends. - -Authentication backend problems -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -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 `_. - -Reverse proxy configuration ---------------------------- - -Django Axes makes use of ``django-ipware`` package to detect the IP address of the client -and uses some conservative configuration parameters by default for security. - -If you are using reverse proxies, you will need to configure one or more of the -following settings to suit your set up to correctly resolve client IP addresses: - -* ``AXES_PROXY_COUNT``: The number of reverse proxies in front of Django as an integer. Default: ``None`` -* ``AXES_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings - to check to get the client IP address. Check the Django documentation for header naming conventions. - Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )`` - -Customizing Axes ----------------- - -You have a couple options available to you to customize ``django-axes`` a bit. -These should be defined in your ``settings.py`` file. - -* ``AXES_CACHE``: The name of the cache for Axes to use. - Default: ``'default'`` * ``AXES_FAILURE_LIMIT``: The number of login attempts allowed before a record is created for the failed logins. Default: ``3`` * ``AXES_LOCK_OUT_AT_FAILURE``: After the number of allowed login attempts are exceeded, should we lock out this IP (and optional user agent)? Default: ``True`` -* ``AXES_USE_USER_AGENT``: If ``True``, lock out / log based on an IP address - AND a user agent. This means requests from different user agents but from - the same IP are treated differently. Default: ``False`` * ``AXES_COOLOFF_TIME``: If set, defines a period of inactivity after which - old failed login attempts will be forgotten. Can be set to a python + old failed login attempts will be cleared. Can be set to a Python timedelta object or an integer. If an integer, will be interpreted as a number of hours. Default: ``None`` -* ``AXES_HANDLER``: If set, overrides the default signal handler backend. - Default: ``'axes.handlers.database.AxesDatabaseHandler'`` +* ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``: If ``True``, prevent login + from IP under a particular username if the attempt limit has been exceeded, + otherwise lock out based on IP. + Default: ``False`` +* ``AXES_ONLY_USER_FAILURES`` : If ``True``, only lock based on username, + and never lock based on IP if attempts exceed the limit. + Otherwise utilize the existing IP and user locking logic. + Default: ``False`` +* ``AXES_USE_USER_AGENT``: If ``True``, lock out and log based on the IP address + and the user agent. This means requests from different user agents but from + the same IP are treated differently. This settings has no effect if the + ``AXES_ONLY_USER_FAILURES`` setting is active. Default: ``False`` * ``AXES_LOGGER``: If set, specifies a logging mechanism for Axes to use. Default: ``'axes.watch_login'`` +* ``AXES_HANDLER``: The path to to handler class to use. + If set, overrides the default signal handler backend. + Default: ``'axes.handlers.database.DatabaseHandler'`` +* ``AXES_CACHE``: The name of the cache for Axes to use. + Default: ``'default'`` * ``AXES_LOCKOUT_TEMPLATE``: If set, specifies a template to render when a user is locked out. Template receives cooloff_time and failure_limit as context variables. Default: ``None`` @@ -259,13 +62,6 @@ These should be defined in your ``settings.py`` file. dictionaries based on ``AXES_USERNAME_FORM_FIELD``. Default: ``None`` * ``AXES_PASSWORD_FORM_FIELD``: the name of the form or credentials field that contains your users password. Default: ``password`` -* ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``: If ``True`` prevents the login - from IP under a particular user if the attempt limit has been exceeded, - otherwise lock out based on IP. - Default: ``False`` -* ``AXES_ONLY_USER_FAILURES`` : If ``True`` only locks based on user id and never locks by IP - if attempts limit exceed, otherwise utilize the existing IP and user locking logic - Default: ``False`` * ``AXES_NEVER_LOCKOUT_GET``: If ``True``, Axes will never lock out HTTP GET requests. Default: ``False`` * ``AXES_NEVER_LOCKOUT_WHITELIST``: If ``True``, users can always login from whitelisted IP addresses. @@ -276,3 +72,116 @@ These should be defined in your ``settings.py`` file. * ``AXES_DISABLE_ACCESS_LOG``: If ``True``, disable all access logging, so the admin interface will be empty. Default: ``False`` * ``AXES_DISABLE_SUCCESS_ACCESS_LOG``: If ``True``, successful logins will not be logged, so the access log shown in the admin interface will only list unsuccessful login attempts. Default: ``False`` * ``AXES_RESET_ON_SUCCESS``: If ``True``, a successful login will reset the number of failed logins. Default: ``False`` + +The configuration option precedences for the access attempt monitoring are: + +1. Default: only use IP address. +2. ``AXES_ONLY_USER_FAILURES``: only user username (``AXES_USE_USER_AGENT`` has no effect). +3. ``AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP``: use username and IP address. + +The ``AXES_USE_USER_AGENT`` setting can be used with username and IP address or just IP address monitoring, +but does nothing when the ``AXES_ONLY_USER_FAILURES`` setting is set. + + +Configuring reverse proxies +--------------------------- + +Axes makes use of ``django-ipware`` package to detect the IP address of the client +and uses some conservative configuration parameters by default for security. + +If you are using reverse proxies, you will need to configure one or more of the +following settings to suit your set up to correctly resolve client IP addresses: + +* ``AXES_PROXY_COUNT``: The number of reverse proxies in front of Django as an integer. Default: ``None`` +* ``AXES_META_PRECEDENCE_ORDER``: The names of ``request.META`` attributes as a tuple of strings + to check to get the client IP address. Check the Django documentation for header naming conventions. + Default: ``IPWARE_META_PRECEDENCE_ORDER`` setting if set, else ``('REMOTE_ADDR', )`` + + +Configuring handlers +-------------------- + +Axes uses and provides handlers for processing signals and events +from Django authentication and login attempts. + +The following handlers are offered by default and can be configured +with the ``AXES_HANDLER`` setting in project configuration: + +- ``axes.handlers.database.AxesDatabaseHandler`` + logs attempts to database and creates AccessAttempt and AccessLog records + that persist until removed from the database manually or automatically + after their cool offs expire (checked on each login event). +- ``axes.handlers.cache.AxesCacheHandler`` + only uses the cache for monitoring attempts and does not persist data + other than in the cache backend; this data can be purged automatically + depending on your cache configuration, so the cache handler is by design + less secure than the database backend but offers higher throughput + and can perform better with less bottlenecks. + The cache backend should ideally be used with a central cache system + such as a Memcached cache and should not rely on individual server + state such as the local memory or file based cache does. +- ``axes.handlers.dummy.AxesDummyHandler`` + does nothing with attempts and can be used to disable Axes handlers + if the user does not wish Axes to execute any logic on login signals. + Note that this effectively disables any Axes security features, + and is meant to be used on e.g. local development setups + and testing deployments where login monitoring is not wanted. + + +Configuring caches +------------------ + +If you are running Axes with the cache based handler on a deployment with a +local Django cache, the Axes lockout and reset functionality might not work +predictably if the cache in use is not the same for all the Django processes. + +Axes needs to cache access attempts application-wide, and e.g. the +in-memory cache only caches access attempts per Django process, so for example +resets made in the command line might not remove lock-outs that are in a sepate +processes in-memory cache such as the web server serving your login or admin page. + +To circumvent this problem, please use somethings else than +``django.core.cache.backends.locmem.LocMemCache`` as your +cache backend in Django cache ``BACKEND`` setting. + +If changing the ``'default'`` cache is not an option, you can add a cache +specifically for use with Axes. This is a two step process. First you need to +add an extra cache to ``CACHES`` with a name of your choice:: + + CACHES = { + 'axes': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': '127.0.0.1:11211', + } + } + +The next step is to tell Axes to use this cache through adding ``AXES_CACHE`` +to your ``settings.py`` file:: + + AXES_CACHE = 'axes' + +There are no known problems in e.g. ``MemcachedCache`` or Redis based caches. + + +Configuring authentication backends +----------------------------------- + +Axes requires authentication backends to pass request objects +with the authentication requests for performing monitoring. + +If you get ``AxesBackendRequestParameterRequired`` exceptions, +make sure any libraries and middleware you use pass the request object. + +Please check the integration documentation for further information. + + +Configuring 3rd party apps +-------------------------- + +Refer to the integration documentation for Axes configuration +with third party applications and plugins such as + +- Django REST Framework +- Django Allauth +- Django Simple Captcha + diff --git a/docs/customization.rst b/docs/customization.rst new file mode 100644 index 0000000..3b4dac5 --- /dev/null +++ b/docs/customization.rst @@ -0,0 +1,144 @@ +.. customization: + +Customization +============= + +Axes can be customized and extended by using the correct signals. + +Axes listens to the following signals from ``django.contrib.auth.signals`` to log access attempts: + +* ``user_logged_in`` +* ``user_logged_out`` +* ``user_login_failed`` + +You can also use Axes with your own auth module, but you'll need +to ensure that it sends the correct signals in order for Axes to +log the access attempts. + +Customizing authentication views +-------------------------------- + +Here is a more detailed example of sending the necessary signals using +and a custom auth backend at an endpoint that expects JSON +requests. The custom authentication can be swapped out with ``authenticate`` +and ``login`` from ``django.contrib.auth``, but beware that those methods take +care of sending the nessary signals for you, and there is no need to duplicate +them as per the example. + +``example/forms.py``:: + + from django import forms + + class LoginForm(forms.Form): + username = forms.CharField(max_length=128, required=True) + password = forms.CharField(max_length=128, required=True) + +``example/views.py``:: + + from django.contrib.auth import signals + from django.http import JsonResponse, HttpResponse + from django.utils.decorators import method_decorator + from django.views import View + from django.views.decorators.csrf import csrf_exempt + + from axes.decorators import axes_dispatch + + from example.forms import LoginForm + from example.authentication import authenticate, login + + + @method_decorator(axes_dispatch, name='dispatch') + @method_decorator(csrf_exempt, name='dispatch') + class Login(View): + """ + Custom login view that takes JSON credentials + """ + + http_method_names = ['post'] + + def post(self, request): + form = LoginForm(request.POST) + + if not form.is_valid(): + # inform django-axes of failed login + signals.user_login_failed.send( + sender=User, + request=request, + credentials={ + 'username': form.cleaned_data.get('username'), + }, + ) + return HttpResponse(status=400) + + user = authenticate( + request=request, + username=form.cleaned_data.get('username'), + password=form.cleaned_data.get('password'), + ) + + if user is not None: + login(request, user) + + signals.user_logged_in.send( + sender=User, + request=request, + user=user, + ) + + return JsonResponse({ + 'message':'success' + }, status=200) + + # inform django-axes of failed login + signals.user_login_failed.send( + sender=User, + request=request, + credentials={ + 'username': form.cleaned_data.get('username'), + }, + ) + + return HttpResponse(status=403) + +``urls.py``:: + + from django.urls import path + from example.views import Login + + urlpatterns = [ + path('login/', Login.as_view(), name='login'), + ] + + + +Customizing username lookups +---------------------------- + +In special cases, you may have the need to modify the username that is +submitted before attempting to authenticate. For example, adding namespacing or +removing client-set prefixes. In these cases, ``axes`` needs to know how to make +these changes so that it can correctly identify the user without any form +cleaning or validation. This is where the ``AXES_USERNAME_CALLABLE`` setting +comes in. You can define how to make these modifications in a callable that +takes a request object and a credentials dictionary, +and provide that callable to ``axes`` via this setting. + +For example, a function like this could take a post body with something like +``username='prefixed-username'`` and ``namespace=my_namespace`` and turn it +into ``my_namespace-username``: + +``example/utils.py``:: + + def get_username(request, credentials): + username = credentials.get('username') + namespace = credentials.get('namespace') + return namespace + '-' + username + +``settings.py``:: + + AXES_USERNAME_CALLABLE = 'example.utils.get_username' + +NOTE: You still have to make these modifications yourself before calling +authenticate. If you want to re-use the same function for consistency, that's +fine, but Axes does not inject these changes into the authentication flow +for you. \ No newline at end of file diff --git a/docs/development.rst b/docs/development.rst index 8693ea1..d574fac 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -3,11 +3,7 @@ Development =========== -You can contribute to this project forking it from github and sending pull requests. - -This is a `Jazzband `_ project. By contributing you agree to -abide by the `Contributor Code of Conduct `_ -and follow the `guidelines `_. +You can contribute to this project forking it from GitHub and sending pull requests. Running tests ------------- diff --git a/docs/index.rst b/docs/index.rst index 4682cb1..0efaa7c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,13 +14,14 @@ Contents .. toctree:: :maxdepth: 2 - installation - configuration - migration - usage requirements + installation + usage + configuration + customization + migration development - captcha + architecture Indices and tables ------------------ diff --git a/docs/installation.rst b/docs/installation.rst index 64b1e71..01f685c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -3,7 +3,76 @@ Installation ============ -You can install the latest stable package running this command:: +Axes is easy to install from the PyPI package:: $ pip install django-axes + +Configuring settings +-------------------- + +After installing the package, the project ``settings.py`` needs to be configured. + +1. add ``axes`` to your ``INSTALLED_APPS``:: + + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # ... other applications per your preference. + + 'axes', + ] + +2. add ``axes.backends.AxesBackend`` to the top of ``AUTHENTICATION_BACKENDS``:: + + AUTHENTICATION_BACKENDS = [ + # 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', + ] + +3. 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', + ] + +4. Run ``python manage.py migrate`` to sync the database. + +Axes is now functional with the default settings and is saving user attempts +into your database and locking users out if they exceed the maximum attempts. + + +Running Django system checks +---------------------------- + +Use the ``python manage.py check`` command to verify the correct configuration in both +development and production environments. It is probably best to use this step as part +of your regular CI workflows to verify that your project is not misconfigured. + +Axes uses the checks to verify your cache configuration to see that your caches +should be functional with the configuration of Axes. Many people have different configurations +for their development and production environments. \ No newline at end of file diff --git a/docs/integration.rst b/docs/integration.rst new file mode 100644 index 0000000..3f27ea4 --- /dev/null +++ b/docs/integration.rst @@ -0,0 +1,156 @@ +.. _usage: + +Integration +=========== + +Axes is intended to be pluggable and usable with 3rd party authentication solutions. + +This document describes the integration with some commonly used 3rd party packages +such as Django Allauth and Django REST Framework. + + +Integrating with Django Allauth +------------------------------- + +Axes relies on having login information stored under ``AXES_USERNAME_FORM_FIELD`` key +both in ``request.POST`` and in ``credentials`` dict passed to +``user_login_failed`` signal. + +This is not the case with Allauth. Allauth always uses the ``login`` key in post POST data +but it becomes ``username`` key in ``credentials`` dict in signal handler. + +To overcome this you need to use custom login form that duplicates the value +of ``username`` key under a ``login`` key in that dict and set ``AXES_USERNAME_FORM_FIELD = 'login'``. + +You also need to decorate ``dispatch()`` and ``form_invalid()`` methods of the Allauth login view. + +``settings.py``:: + + AXES_USERNAME_FORM_FIELD = 'login' + +``example/forms.py``:: + + from allauth.account.forms import LoginForm + + class AxesLoginForm(LoginForm): + """ + Extended login form class that supplied the + user credentials for Axes compatibility. + """ + + def user_credentials(self): + credentials = super(AllauthCompatLoginForm, self).user_credentials() + credentials['login'] = credentials.get('email') or credentials.get('username') + return credentials + +``example/urls.py``:: + + from django.utils.decorators import method_decorator + + from allauth.account.views import LoginView + + from axes.decorators import axes_dispatch + from axes.decorators import axes_form_invalid + + from example.forms import AxesLoginForm + + LoginView.dispatch = method_decorator(axes_dispatch)(LoginView.dispatch) + LoginView.form_invalid = method_decorator(axes_form_invalid)(LoginView.form_invalid) + + urlpatterns = [ + # Override allauth default login view with a patched view + url(r'^accounts/login/$', LoginView.as_view(form_class=AllauthCompatLoginForm), name='account_login'), + url(r'^accounts/', include('allauth.urls')), + ] + + +Integrating with Django REST Framework +-------------------------------------- + +Modern versions of Django REST Framework after 3.7.0 work normally with Axes. + +Django REST Framework versions prior to +[3.7.0](https://github.com/encode/django-rest-framework/commit/161dc2df2ccecc5307cdbc05ef6159afd614189e) +require the request object to be passed for authentication. + +``example/authentication.py``:: + + from rest_framework.authentication import BasicAuthentication + + class AxesBasicAuthentication(BasicAuthentication): + """ + Extended basic authentication backend class that supplies the + request object into the authentication call for Axes compatibility. + + NOTE: This patch is only needed for DRF versions < 3.7.0. + """ + + def authenticate(self, request): + # NOTE: Request is added as an instance attribute in here + self._current_request = request + return super(AxesBasicAuthentication, self).authenticate(request) + + def authenticate_credentials(self, userid, password, request=None): + credentials = { + get_user_model().USERNAME_FIELD: userid, + 'password': password + } + + # NOTE: Request is added as an argument to the authenticate call here + user = authenticate(request=request or self._current_request, **credentials) + + if user is None: + raise exceptions.AuthenticationFailed(_('Invalid username/password.')) + + if not user.is_active: + raise exceptions.AuthenticationFailed(_('User inactive or deleted.')) + + return (user, None) + + +Integrating with Django Simple Captcha +-------------------------------------- + +Axes supports Captcha with the Django Simple Captcha package in the following manner. + +``settings.py``:: + + AXES_LOCKOUT_URL = '/locked' + +``example/urls.py``:: + + url(r'^locked/$', locked_out, name='locked_out'), + +``example/forms.py``:: + + class AxesCaptchaForm(forms.Form): + captcha = CaptchaField() + +``example/views.py``:: + + from example.forms import AxesCaptchaForm + + def locked_out(request): + if request.POST: + form = AxesCaptchaForm(request.POST) + if form.is_valid(): + ip = get_ip_address_from_request(request) + reset(ip=ip) + return HttpResponseRedirect(reverse_lazy('signin')) + else: + form = AxesCaptchaForm() + + return render_to_response('captcha.html', dict(form=form), context_instance=RequestContext(request)) + +``example/templates/example/captcha.html``:: + +
+ {% csrf_token %} + + {{ form.captcha.errors }} + {{ form.captcha }} + +
+ +
+
diff --git a/docs/migration.rst b/docs/migration.rst index 1f292f0..716a7f2 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -3,35 +3,36 @@ Migration ========= -This page contains migration instructions between different django-axes +This page contains migration instructions between different Axes versions so that users might more confidently upgrade their installations. -From django-axes version 4 to version 5 ---------------------------------------- +Migrating from Axes version 4 to 5 +---------------------------------- -Application version 5 has a few differences compared to django-axes 4. +Axes version 5 has a few differences compared to 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 + Login monitoring is now implemented with signal handlers and locking users out is implemented with a combination - of a custom authentication backend, middlware, and signals. + of a custom authentication backend, middleware, and signals. +- ``axes.utils.reset`` was moved to ``axes.attempts.reset``. - ``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``. + exception was renamed and retyped from ``Exception`` to ``ValueError``. + Exception was moved to ``axes.exception.AxesBackendRequestParameterRequired``. - ``AxesBackend`` now raises a ``axes.exceptions.AxesBackendPermissionDenied`` - exception when user is locked out which triggers signal handler + 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``. +- ``AxesMiddleware`` was added to process lockout events. + The middleware handles the ``axes.exception.AxesSignalPermissionDenied`` + exception and converts it to a lockout response. - ``AXES_USERNAME_CALLABLE`` is now always called with two arguments, - ``request`` and ``credentials`` instead of ``request``. + ``request`` and ``credentials`` instead of just ``request``. diff --git a/docs/requirements.rst b/docs/requirements.rst index 41b37c4..8dab0ab 100644 --- a/docs/requirements.rst +++ b/docs/requirements.rst @@ -3,8 +3,13 @@ Requirements ============ -``django-axes`` requires a supported Django version. The application is -intended to work around the Django admin and the regular -``django.contrib.auth`` login-powered pages. -Look here https://github.com/jazzband/django-axes/blob/master/.travis.yml -to check if your django / python version are supported. +Axes requires a supported Django version and runs on Python and PyPy versions 3.5 and above. + +Refer to the project source code repository in +`GitHub `_ and see the +`Travis CI configuration `_ and +`Python package definition `_ +to check if your Django and Python version are supported. + +The `Travis CI builds `_ +test Axes compatibility with the Django master branch for future compatibility as well. diff --git a/docs/usage.rst b/docs/usage.rst index 11b24ac..27b63f9 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,230 +1,27 @@ -.. _usage: - Usage ===== -``django-axes`` listens to signals from ``django.contrib.auth.signals`` to -log access attempts: - -* ``user_logged_in`` -* ``user_logged_out`` -* ``user_login_failed`` - -You can also use ``django-axes`` with your own auth module, but you'll need -to ensure that it sends the correct signals in order for ``django-axes`` to -log the access attempts. - -Quickstart ----------- - -Once ``axes`` is in your ``INSTALLED_APPS`` in your project settings file, you can -login and logout of your application via the ``django.contrib.auth`` views. -The attempts will be logged and visible in the "Access Attempts" section in admin. +Once Axes is is installed and configured, you can login and logout +of your application via the ``django.contrib.auth`` views. +The attempts will be logged and visible in the Access Attempts section in admin. By default, Axes will lock out repeated access attempts from the same IP address. -You can allow this IP to attempt again by deleting relevant ``AccessAttempt`` records. +You can allow this IP to attempt again by deleting relevant AccessAttempt records. Records can be deleted, for example, by using the Django admin application. -You can also use the ``axes_reset``, ``axes_reset_ip``, and ``axes_reset_user`` +You can also use the ``axes_reset``, ``axes_reset_ip``, and ``axes_reset_username`` management commands with the Django ``manage.py`` command helpers: -* ``manage.py axes_reset`` will reset all lockouts and access records. -* ``manage.py axes_reset_ip ip [ip ...]`` +- ``python manage.py axes_reset`` + will reset all lockouts and access records. +- ``python manage.py axes_reset_ip ip [ip ...]`` will clear lockouts and records for the given IP addresses. -* ``manage.py axes_reset_user username [username ...]`` +- ``python manage.py axes_reset_username username [username ...]`` will clear lockouts and records for the given usernames. -In your code, you can use the ``axes.utils.reset`` function. +In your code, you can use the ``axes.attempts.reset`` function. -* ``reset()`` will reset all lockouts and access records. -* ``reset(ip=ip)`` will clear lockouts and records for the given IP address. -* ``reset(username=username)`` will clear lockouts and records for the given username. - -Example usage -------------- - -Here is a more detailed example of sending the necessary signals using -`django-axes` and a custom auth backend at an endpoint that expects JSON -requests. The custom authentication can be swapped out with ``authenticate`` -and ``login`` from ``django.contrib.auth``, but beware that those methods take -care of sending the nessary signals for you, and there is no need to duplicate -them as per the example. - -*forms.py:* :: - - from django import forms - - class LoginForm(forms.Form): - username = forms.CharField(max_length=128, required=True) - password = forms.CharField(max_length=128, required=True) - -*views.py:* :: - - from django.http import JsonResponse, HttpResponse - from django.utils.decorators import method_decorator - from django.contrib.auth import signals - from django.views import View - from django.views.decorators.csrf import csrf_exempt - - from axes.decorators import axes_dispatch - - from myapp.forms import LoginForm - from myapp.auth import custom_authenticate, custom_login - - - @method_decorator(axes_dispatch, name='dispatch') - @method_decorator(csrf_exempt, name='dispatch') - class Login(View): - """ - Custom login view that takes JSON credentials - """ - - http_method_names = ['post'] - - def post(self, request): - form = LoginForm(request.POST) - - if not form.is_valid(): - # inform django-axes of failed login - signals.user_login_failed.send( - sender=User, - request=request, - credentials={ - 'username': form.cleaned_data.get('username'), - }, - ) - return HttpResponse(status=400) - - user = custom_authenticate( - request=request, - username=form.cleaned_data.get('username'), - password=form.cleaned_data.get('password'), - ) - - if user is not None: - custom_login(request, user) - - signals.user_logged_in.send( - sender=User, - request=request, - user=user, - ) - - return JsonResponse({ - 'message':'success' - }, status=200) - - # inform django-axes of failed login - signals.user_login_failed.send( - sender=User, - request=request, - credentials={ - 'username': form.cleaned_data.get('username'), - }, - ) - - return HttpResponse(status=403) - -*urls.py:* :: - - from django.urls import path - from myapp.views import Login - - urlpatterns = [ - path('login/', Login.as_view(), name='login'), - ] - -Integration with django-allauth -------------------------------- - -``axes`` relies on having login information stored under ``AXES_USERNAME_FORM_FIELD`` key -both in ``request.POST`` and in ``credentials`` dict passed to -``user_login_failed`` signal. This is not the case with ``allauth``. -``allauth`` always uses ``login`` key in post POST data but it becomes ``username`` -key in ``credentials`` dict in signal handler. - -To overcome this you need to use custom login form that duplicates the value -of ``username`` key under a ``login`` key in that dict -(and set ``AXES_USERNAME_FORM_FIELD = 'login'``). - -You also need to decorate ``dispatch()`` and ``form_invalid()`` methods -of the ``allauth`` login view. By default ``axes`` is patching only the -``LoginView`` from ``django.contrib.auth`` app and with ``allauth`` you have to -do the patching of views yourself. - -*settings.py:* :: - - AXES_USERNAME_FORM_FIELD = 'login' - -*forms.py:* :: - - from allauth.account.forms import LoginForm - - class AllauthCompatLoginForm(LoginForm): - def user_credentials(self): - credentials = super(AllauthCompatLoginForm, self).user_credentials() - credentials['login'] = credentials.get('email') or credentials.get('username') - return credentials - -*urls.py:* :: - - from allauth.account.views import LoginView - from axes.decorators import axes_dispatch - from axes.decorators import axes_form_invalid - from django.utils.decorators import method_decorator - - from my_app.forms import AllauthCompatLoginForm - - LoginView.dispatch = method_decorator(axes_dispatch)(LoginView.dispatch) - LoginView.form_invalid = method_decorator(axes_form_invalid)(LoginView.form_invalid) - - urlpatterns = [ - # ... - url(r'^accounts/login/$', # Override allauth's default view with a patched view - LoginView.as_view(form_class=AllauthCompatLoginForm), - name="account_login"), - url(r'^accounts/', include('allauth.urls')), - # ... - ] - -Altering username before login ------------------------------- - -In special cases, you may have the need to modify the username that is -submitted before attempting to authenticate. For example, adding namespacing or -removing client-set prefixes. In these cases, ``axes`` needs to know how to make -these changes so that it can correctly identify the user without any form -cleaning or validation. This is where the ``AXES_USERNAME_CALLABLE`` setting -comes in. You can define how to make these modifications in a callable that -takes a request object and a credentials dictionary, -and provide that callable to ``axes`` via this setting. - -For example, a function like this could take a post body with something like -``username='prefixed-username'`` and ``namespace=my_namespace`` and turn it -into ``my_namespace-username``: - -*settings.py:* :: - - def sample_username_modifier(request): - provided_username = request.POST.get('username') - some_namespace = request.POST.get('namespace') - return '-'.join([some_namespace, provided_username[9:]]) - - AXES_USERNAME_CALLABLE = sample_username_modifier - - # New format that can also be used - # the credentials argument is provided if the - # function signature has two arguments instead of one - - def sample_username_modifier_credentials(request, credentials): - provided_username = credentials.get('username') - some_namespace = credentials.get('namespace') - return '-'.join([some_namespace, provided_username[9:]]) - - AXES_USERNAME_CALLABLE = sample_username_modifier_new - -NOTE: You still have to make these modifications yourself before calling -authenticate. If you want to re-use the same function for consistency, that's -fine, but ``axes`` doesn't inject these changes into the authentication flow -for you. +- ``reset()`` will reset all lockouts and access records. +- ``reset(ip=ip)`` will clear lockouts and records for the given IP address. +- ``reset(username=username)`` will clear lockouts and records for the given username. \ No newline at end of file