mirror of
https://github.com/jazzband/django-defender.git
synced 2026-03-17 06:20:24 +00:00
Compare commits
49 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa6235caf | ||
|
|
289af19ce9 | ||
|
|
37e5dd3123 | ||
|
|
e420d76463 | ||
|
|
cc35032a0c | ||
|
|
f2dede8c76 | ||
|
|
4e00500537 | ||
|
|
83ad7ce338 | ||
|
|
07555abd29 | ||
|
|
c290b5a673 | ||
|
|
4bea010b65 | ||
|
|
a972dae7fc | ||
|
|
1e0aa91952 | ||
|
|
ba548fa9c3 | ||
|
|
f6c73e093b | ||
|
|
8d4c6840e9 | ||
|
|
2a0469669a | ||
|
|
91dfbde556 | ||
|
|
cc6145b84e | ||
|
|
6111eb81da | ||
|
|
b0f90e690a | ||
|
|
a4b3f9f332 | ||
|
|
d90dfa8db7 | ||
|
|
428968b238 | ||
|
|
ac36751561 | ||
|
|
73d442e31b | ||
|
|
3e9d68dd5c | ||
|
|
afa2066ba0 | ||
|
|
374971bfc5 | ||
|
|
5139005106 | ||
|
|
359ee90082 | ||
|
|
b9b067472c | ||
|
|
de1c876b99 | ||
|
|
717d44aa7d | ||
|
|
1bf9d6e7d1 | ||
|
|
a280c90bc0 | ||
|
|
7121db4b0f | ||
|
|
be523281ab | ||
|
|
2d288b247a | ||
|
|
5db32ed0be | ||
|
|
bbe7687abd | ||
|
|
177f2ecce8 | ||
|
|
dffa7c3ba3 | ||
|
|
7bb1359514 | ||
|
|
0a5011d450 | ||
|
|
bb122f24b9 | ||
|
|
0b96c53245 | ||
|
|
44ecbee250 | ||
|
|
45c4575ccd |
23 changed files with 630 additions and 204 deletions
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -11,12 +11,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
|
||||
- name: Upload packages to Jazzband
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@master
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: jazzband
|
||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||
|
|
|
|||
15
.github/workflows/test.yml
vendored
15
.github/workflows/test.yml
vendored
|
|
@ -9,16 +9,19 @@ jobs:
|
|||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
python-version: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.8-v7.3.7']
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
redis-version: [5, 6, 7]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Start Redis
|
||||
uses: supercharge/redis-github-action@1.1.0
|
||||
uses: supercharge/redis-github-action@1.5.0
|
||||
with:
|
||||
redis-version: ${{ matrix.redis-version }}
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
|
@ -28,7 +31,7 @@ jobs:
|
|||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key:
|
||||
|
|
@ -46,6 +49,6 @@ jobs:
|
|||
tox -v
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
name: Python ${{ matrix.python-version }}
|
||||
|
|
|
|||
35
.readthedocs.yaml
Normal file
35
.readthedocs.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Read the Docs configuration file for Sphinx projects
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the OS, Python version and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.12"
|
||||
# You can also specify other tool versions:
|
||||
# nodejs: "20"
|
||||
# rust: "1.70"
|
||||
# golang: "1.20"
|
||||
|
||||
# Build documentation in the "docs/" directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
|
||||
# builder: "dirhtml"
|
||||
# Fail on all warnings to avoid broken references
|
||||
# fail_on_warning: true
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF and ePub
|
||||
# formats:
|
||||
# - pdf
|
||||
# - epub
|
||||
|
||||
# Optional but recommended, declare the Python requirements required
|
||||
# to build your documentation
|
||||
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
||||
python:
|
||||
install:
|
||||
- requirements: requirements.txt
|
||||
35
CHANGES.rst
35
CHANGES.rst
|
|
@ -2,6 +2,41 @@
|
|||
Changes
|
||||
=======
|
||||
|
||||
0.9.8
|
||||
=====
|
||||
|
||||
- Fix watch_login with custom username (#228) [@ron8mcr]
|
||||
- Replace datetime.now with timezone.now (#232) [@ericls]
|
||||
- Update tox.ini with Django 4.2, Python 3.11 (#233) [@marius-mather]
|
||||
- Use redis parse_url method instead of a custom one (#234) [@dkr-sahar]
|
||||
- Update DEFENDER_REDIS_NAME documentation (#235) [@bennylope]
|
||||
- Prevent the "Reverse for 'defender_blocks_view' not found" error (#237) [@ataylor32]
|
||||
- Updated app_index.html (#238) [@ataylor32]
|
||||
- Improved the "Blocked Logins" page's admin integration (#239) [@ataylor32]
|
||||
|
||||
0.9.7
|
||||
=====
|
||||
|
||||
- Fix bug related to using a redis version less than 6 and not having a password. [@kencochrane]
|
||||
- Fix bug in remove_prefix method [@dashgin]
|
||||
|
||||
0.9.6
|
||||
=====
|
||||
|
||||
- Confirm support for Django 4.1
|
||||
- Add ``DEFENDER_ATTEMPT_COOLOFF_TIME`` config to override ``DEFENDER_COOLOFF_TIME`` specifically for attempt lifespan [@djmore4]
|
||||
- Add ``DEFENDER_LOCKOUT_COOLOFF_TIME`` config to override ``DEFENDER_COOLOFF_TIME`` specifically for lockout duration [@djmore4]
|
||||
|
||||
0.9.5
|
||||
=====
|
||||
|
||||
- Add username support to Redis configuration. [@erdos4d]
|
||||
|
||||
0.9.4
|
||||
-----
|
||||
|
||||
- Remove port number from IP address string when behind reverse proxy [@ndrsn]
|
||||
|
||||
0.9.3
|
||||
-----
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ include CODE_OF_CONDUCT.md
|
|||
include requirements.txt
|
||||
include tox.ini
|
||||
include .pre-commit-config.yaml
|
||||
include .readthedocs.yaml
|
||||
recursive-include docs *
|
||||
recursive-include exampleapp *
|
||||
recursive-include defender/templates *.html
|
||||
|
|
|
|||
70
README.rst
70
README.rst
|
|
@ -108,9 +108,9 @@ Admin pages
|
|||
Requirements
|
||||
------------
|
||||
|
||||
* Python: 3.7, 3.8, 3.9, 3.10, PyPy
|
||||
* Django: 3.x, 4.x
|
||||
* Redis
|
||||
* Python: 3.8, 3.9, 3.10, 3.11, 3.12, PyPy
|
||||
* Django: 3.2, 4.2, 5.0, 5.1, 5.2
|
||||
* Redis: 5.x, 6.x, 7.x
|
||||
|
||||
|
||||
Installation
|
||||
|
|
@ -169,8 +169,8 @@ following to your ``urls.py``
|
|||
.. code-block:: python
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls), # normal admin
|
||||
path('admin/defender/', include('defender.urls')), # defender admin
|
||||
path('admin/', admin.site.urls), # normal admin
|
||||
# your own patterns follow...
|
||||
]
|
||||
|
||||
|
|
@ -350,8 +350,20 @@ These should be defined in your ``settings.py`` file.
|
|||
* ``DEFENDER_DISABLE_IP_LOCKOUT``\ : Boolean: If this is True, it will not lockout the users IP address, it will only lockout the username. [Default: False]
|
||||
* ``DEFENDER_DISABLE_USERNAME_LOCKOUT``\ : Boolean: If this is True, it will not lockout usernames, it will only lockout IP addresess. [Default: False]
|
||||
* ``DEFENDER_COOLOFF_TIME``\ : Int: If set, defines a period of inactivity after which
|
||||
old failed login attempts will be forgotten. An integer, will be interpreted as a
|
||||
number of seconds. If ``0``\ , the locks will not expire. [Default: ``300``\ ]
|
||||
old failed login attempts and username/ip lockouts will be forgotten. An integer,
|
||||
will be interpreted as a number of seconds. If 0, neither the failed login attempts
|
||||
nor the username/ip locks will expire. [Default: ``300``\ ]
|
||||
* ``DEFENDER_ATTEMPT_COOLOFF_TIME``\ : Int: If set, overrides the period of inactivity
|
||||
after which old failed login attempts will be forgotten set by DEFENDER_COOLOFF_TIME.
|
||||
An integer, will be interpreted as a number of seconds. If 0, the failed login
|
||||
attempts will not expire. [Default: ``DEFENDER_COOLOFF_TIME``\ ]
|
||||
* ``DEFENDER_LOCKOUT_COOLOFF_TIME``\ : Int or List: If set, overrides the period of
|
||||
inactivity after which username/ip lockouts will be forgotten set by
|
||||
DEFENDER_COOLOFF_TIME. An integer, will be interpreted as a number of seconds.
|
||||
A list of integers, will be interpreted as a number of seconds for users with
|
||||
the integer's index being how many previous lockouts (up to some maximum) occurred
|
||||
in the last ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION`` hours. If the property is set to
|
||||
0 or [], the username/ip lockout will not expire. [Default: ``DEFENDER_COOLOFF_TIME``\ ]
|
||||
* ``DEFENDER_LOCKOUT_TEMPLATE``\ : String: [Default: ``None``\ ] If set, specifies a template to render when a user is locked out. Template receives the following context variables:
|
||||
|
||||
* ``cooloff_time_seconds``\ : The cool off time in seconds
|
||||
|
|
@ -369,7 +381,7 @@ These should be defined in your ``settings.py`` file.
|
|||
(Example with password: ``redis://:mypassword@localhost:6379/0``\ )
|
||||
* ``DEFENDER_REDIS_PASSWORD_QUOTE``\ : Boolean: if special character in redis password (like '@'), we can quote password ``urllib.parse.quote("password!@#")``, and set to True.
|
||||
[Default: ``False``\ ]
|
||||
* ``DEFENDER_REDIS_NAME``\ : String: the name of your cache client on the CACHES django setting. If set, ``DEFENDER_REDIS_URL`` will be ignored.
|
||||
* ``DEFENDER_REDIS_NAME``\ : String: the name of the cache from ``CACHES`` in your Django settings (e.g. ``"default"``). If set, ``DEFENDER_REDIS_URL`` will be ignored.
|
||||
[Default: ``None``\ ]
|
||||
* ``DEFENDER_STORE_ACCESS_ATTEMPTS``\ : Boolean: If you want to store the login
|
||||
attempt to the database, set to True. If False, it is not saved
|
||||
|
|
@ -386,6 +398,38 @@ These should be defined in your ``settings.py`` file.
|
|||
[Default: ``defender.utils.username_from_request``\ ]
|
||||
|
||||
|
||||
Rationale for using DEFENDER_ATTEMPT_COOLOFF_TIME and DEFENDER_LOCKOUT_COOLOFF_TIME
|
||||
***********************************************************************************
|
||||
|
||||
While using ``DEFENDER_COOLOFF_TIME`` alone is sufficent for most use cases, when using ``defender`` in some specific scenarios such as in a high security setting, developers may wish to have finer
|
||||
grained control over how long invalid login attempts are "remembered" while under consideration for lockout compared to the time those lockout keys are actually locked out from the system.
|
||||
``DEFENDER_ATTEMPT_COOLOFF_TIME`` and ``DEFENDER_LOCKOUT_COOLOFF_TIME`` allow for this exact fine grained configuration.
|
||||
|
||||
We can also take a low security and low scale example like a high school's website. Such a website might be run on some of the school's computers and administrated by the school's IT staff and computer
|
||||
science teachers (if lucky enough to have any). In this scenario we can imagine that there are significant portions of the website accessible without authentication, but logging in to the website could
|
||||
provide access to some relatively privileged information such as the student's name, email, grades, and class schedule. Finally since there is an email linked with the account, we will assume that there
|
||||
is password reset functionality which unblocks the account when completed. In such a case, one could imagine that there is no need to remember failed logins for long periods of time since the application
|
||||
would simply wish to protect against potential denial of service attacks. This could be accomplished keeping ``DEFENDER_ATTEMPT_COOLOFF_TIME`` low, say 30 seconds, and setting ``DEFENDER_LOCKOUT_COOLOFF_TIME``
|
||||
to something much higher like 600 seconds. By keeping ``DEFENDER_ATTEMPT_COOLOFF_TIME`` low and locking out bad actors for significant periods of time by setting ``DEFENDER_LOCKOUT_COOLOFF_TIME`` high,
|
||||
rapid brute force login attacks will still be defeated and their small server will have more space in their cache for other data. And by providing password reset functionality as described above, these hypothetical
|
||||
administrators could limit their required involvement in unblocking real users while retaining the intended accessibility of their website.
|
||||
|
||||
While the previous example is somewhat contrived, the full power of these configurations is demonstrated with the following explanation and example.
|
||||
|
||||
When ``DEFENDER_STORE_ACCESS_ATTEMPTS`` is True, ``DEFENDER_LOCKOUT_COOLOFF_TIME`` can also be configured as a list of integers. When configured as a list,
|
||||
the number of previous failed login attempts for the configured lockout key is divided by ``DEFENDER_LOGIN_FAILURE_LIMIT`` to produce an intentionally overestimated count
|
||||
of the number of failed logins for the period defined by ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION``. This ends up being an overestimate because the time between the failed login attempts
|
||||
is not considered when doing this calculation. While this may seem harsh, in some specific scenarios the additional protection against slower attacks can be worth the\ potential\ inconvenience
|
||||
caused to real users of the system.
|
||||
|
||||
One such example of this could be a public web accessible web application that houses sensitive information of it's users (let's say personal financial records).
|
||||
The application and data therein should be accessible with minimal interruption, however security is integral so delays can be tolerated up to a point.
|
||||
Under these circumstances we may have a desire to simply set ``DEFENDER_COOLOFF_TIME`` to a very large integer or even 0 for maximum protection. But this would mean that
|
||||
if a real user\ does\ get locked out of the system, we will need an administrator to manually unblock them which of course is cumbersome and costly.
|
||||
By setting ``DEFENDER_ATTEMPT_COOLOFF_TIME`` to a large enough number, let's say 600 and setting ``DEFENDER_LOCKOUT_COOLOFF_TIME`` to a list of increasing integers (ie. [60, 120, 300, 600, 0]) we can
|
||||
protect our theoretical application comprably to if we had simply set ``DEFENDER_COOLOFF_TIME`` to 600 while disrupting our users significantly less.
|
||||
|
||||
|
||||
Adapting to other authentication methods
|
||||
----------------------------------------
|
||||
|
||||
|
|
@ -434,7 +478,9 @@ There's sample ``BasicAuthenticationDefender`` class based on ``djangorestframew
|
|||
"Your account is locked for {cooloff_time_seconds} seconds" \
|
||||
"".format(
|
||||
failure_limit=config.FAILURE_LIMIT,
|
||||
cooloff_time_seconds=config.COOLOFF_TIME
|
||||
cooloff_time_seconds=config.LOCKOUT_COOLOFF_TIME[
|
||||
defender_utils.get_lockout_cooloff_time(username=self.get_username_from_request(request))
|
||||
]
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(_(detail))
|
||||
|
||||
|
|
@ -488,8 +534,8 @@ Below is a sample ``BasicAuthenticationDefender`` class based on ``rest_framewor
|
|||
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.utils.http import urlsafe_base64_decode as uid_decoder
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.encoding import force_text
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.encoding import force_str
|
||||
from rest_framework import serializers, exceptions, HTTP_HEADER_ENCODING
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from defender import utils as defender_utils
|
||||
|
|
@ -520,7 +566,7 @@ Below is a sample ``BasicAuthenticationDefender`` class based on ``rest_framewor
|
|||
detail = "You have attempted to login {failure_limit} times with no success. "
|
||||
.format(
|
||||
failure_limit=config.FAILURE_LIMIT,
|
||||
cooloff_time_seconds=config.COOLOFF_TIME
|
||||
cooloff_time_seconds=config.LOCKOUT_COOLOFF_TIME[defender_utils.get_lockout_cooloff_time(username=userid)]
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(_(detail))
|
||||
|
||||
|
|
@ -683,7 +729,7 @@ The views block based on email address submitted on the password reset view. Thi
|
|||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Confirm the user isn’t already blocked by IP before allowing form POST.
|
||||
|
||||
|
||||
Also, force log this form POST as a single entry in the Defender cache, against the submitted email address.
|
||||
"""
|
||||
if def_utils.is_already_locked(request):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
VERSION = (0, 9, 1)
|
||||
VERSION = (0, 9, 8)
|
||||
|
||||
__version__ = ".".join((map(str, VERSION)))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from celery import Celery
|
|||
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
|
||||
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "redis_cache.RedisCache", "LOCATION": "localhost:6379",}
|
||||
"default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://localhost:6379",}
|
||||
}
|
||||
|
||||
SITE_ID = 1
|
||||
|
|
|
|||
|
|
@ -54,8 +54,31 @@ REVERSE_PROXY_HEADER = get_setting(
|
|||
)
|
||||
|
||||
try:
|
||||
# how long to wait before the bad login attempt gets forgotten. in seconds.
|
||||
# how long to wait before the bad login attempt/lockout gets forgotten, in seconds.
|
||||
COOLOFF_TIME = int(get_setting("DEFENDER_COOLOFF_TIME", 300)) # seconds
|
||||
try:
|
||||
# how long to wait before the bad login attempt gets forgotten, in seconds.
|
||||
ATTEMPT_COOLOFF_TIME = int(get_setting("DEFENDER_ATTEMPT_COOLOFF_TIME", COOLOFF_TIME)) # measured in seconds
|
||||
except ValueError: # pragma: no cover
|
||||
raise Exception("DEFENDER_ATTEMPT_COOLOFF_TIME needs to be an integer") # pragma: no cover
|
||||
|
||||
try:
|
||||
# how long to wait before a lockout gets forgotten, in seconds.
|
||||
LOCKOUT_COOLOFF_TIMES = [int(get_setting("DEFENDER_LOCKOUT_COOLOFF_TIME", COOLOFF_TIME))] # measured in seconds
|
||||
except TypeError: # pragma: no cover
|
||||
try: # pragma: no cover
|
||||
cooloff_times = get_setting("DEFENDER_LOCKOUT_COOLOFF_TIME", [COOLOFF_TIME]) # measured in seconds
|
||||
for index, cooloff_time in enumerate(cooloff_times): # pragma: no cover
|
||||
cooloff_times[index] = int(cooloff_time) # pragma: no cover
|
||||
|
||||
if not len(cooloff_times): # pragma: no cover
|
||||
raise TypeError() # pragma: no cover
|
||||
|
||||
LOCKOUT_COOLOFF_TIMES = cooloff_times
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
raise Exception("DEFENDER_LOCKOUT_COOLOFF_TIME needs to be an integer or list of integers having at least one element") # pragma: no cover
|
||||
except ValueError: # pragma: no cover
|
||||
raise Exception("DEFENDER_LOCKOUT_COOLOFF_TIME needs to be an integer or list of integers having at least one element") # pragma: no cover
|
||||
except ValueError: # pragma: no cover
|
||||
raise Exception("DEFENDER_COOLOFF_TIME needs to be an integer") # pragma: no cover
|
||||
|
||||
|
|
|
|||
|
|
@ -31,50 +31,5 @@ def get_redis_connection():
|
|||
except AttributeError:
|
||||
# django_redis.cache.RedisCache case (django-redis package)
|
||||
return cache.client.get_client(True)
|
||||
else: # pragma: no cover
|
||||
redis_config = parse_redis_url(
|
||||
config.DEFENDER_REDIS_URL, config.DEFENDER_REDIS_PASSWORD_QUOTE)
|
||||
return redis.StrictRedis(
|
||||
host=redis_config.get("HOST"),
|
||||
port=redis_config.get("PORT"),
|
||||
db=redis_config.get("DB"),
|
||||
password=redis_config.get("PASSWORD"),
|
||||
ssl=redis_config.get("SSL"),
|
||||
)
|
||||
|
||||
|
||||
def parse_redis_url(url, password_quote=None):
|
||||
"""Parses a redis URL."""
|
||||
|
||||
# create config with some sane defaults
|
||||
redis_config = {
|
||||
"DB": 0,
|
||||
"PASSWORD": None,
|
||||
"HOST": "localhost",
|
||||
"PORT": 6379,
|
||||
"SSL": False,
|
||||
}
|
||||
|
||||
if not url:
|
||||
return redis_config
|
||||
|
||||
url = urlparse.urlparse(url)
|
||||
# Remove query strings.
|
||||
path = url.path[1:]
|
||||
path = path.split("?", 2)[0]
|
||||
|
||||
if path:
|
||||
redis_config.update({"DB": int(path)})
|
||||
if url.password:
|
||||
password = url.password
|
||||
if password_quote:
|
||||
password = urlparse.unquote(password)
|
||||
redis_config.update({"PASSWORD": password})
|
||||
if url.hostname:
|
||||
redis_config.update({"HOST": url.hostname})
|
||||
if url.port:
|
||||
redis_config.update({"PORT": int(url.port)})
|
||||
if url.scheme in ["https", "rediss"]:
|
||||
redis_config.update({"SSL": True})
|
||||
|
||||
return redis_config
|
||||
else: # pragma: no cover)
|
||||
return redis.StrictRedis.from_url(config.DEFENDER_REDIS_URL)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from defender import config
|
||||
from defender.connection import get_redis_connection
|
||||
from .models import AccessAttempt
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def store_login_attempt(
|
||||
|
|
@ -13,3 +19,58 @@ def store_login_attempt(
|
|||
path_info=path_info,
|
||||
login_valid=login_valid,
|
||||
)
|
||||
|
||||
|
||||
def get_approx_lockouts_cache_key(ip_address, username):
|
||||
"""get cache key for approximate number of account lockouts"""
|
||||
return "{0}:approx_lockouts:ip:{1}:user:{2}".format(
|
||||
config.CACHE_PREFIX, ip_address or "", username.lower() if username else ""
|
||||
)
|
||||
|
||||
|
||||
def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=None):
|
||||
"""Get the approximate number of account lockouts in a period of ACCESS_ATTEMPT_EXPIRATION hours.
|
||||
This is approximate because we do not consider the time between these failed
|
||||
login attempts to be relevant.
|
||||
|
||||
Args:
|
||||
ip_address (str, optional): IP address to search for. Can be used in conjunction with username for filtering when DISABLE_IP_LOCKOUT is False. Defaults to None.
|
||||
username (str, optional): Username to search for. Can be used in conjunction with ip_address for filtering when DISABLE_USERNAME_LOCKOUT is False. Defaults to None.
|
||||
|
||||
Returns:
|
||||
int: The minimum of the count of logged failure attempts and the length of the LOCKOUT_COOLOFF_TIMES - 1, or 0 dependant on either configuration or argument parameters (ie. both ip_address and username being None).
|
||||
"""
|
||||
if not config.STORE_ACCESS_ATTEMPTS or not (ip_address or username):
|
||||
# If we're not storing login attempts OR both ip_address and username are
|
||||
# None we should return 0.
|
||||
return 0
|
||||
|
||||
q = Q(attempt_time__gte=timezone.now() - timedelta(hours=config.ACCESS_ATTEMPT_EXPIRATION))
|
||||
failure_limit = config.FAILURE_LIMIT
|
||||
if (ip_address and username and config.LOCKOUT_BY_IP_USERNAME \
|
||||
and not config.DISABLE_IP_LOCKOUT and not config.DISABLE_USERNAME_LOCKOUT
|
||||
):
|
||||
q = q & Q(ip_address=ip_address) & Q(username=username)
|
||||
elif ip_address and not config.DISABLE_IP_LOCKOUT:
|
||||
failure_limit = config.IP_FAILURE_LIMIT
|
||||
q = q & Q(ip_address=ip_address)
|
||||
elif username and not config.DISABLE_USERNAME_LOCKOUT:
|
||||
failure_limit = config.USERNAME_FAILURE_LIMIT
|
||||
q = q & Q(username=username)
|
||||
else:
|
||||
# If we've made it this far and didn't hit one of the other if or elif
|
||||
# conditions, we're in an inappropriate context.
|
||||
raise Exception("Invalid state requested")
|
||||
|
||||
cache_key = get_approx_lockouts_cache_key(ip_address, username)
|
||||
|
||||
redis_client = get_redis_connection()
|
||||
cached_value = redis_client.get(cache_key)
|
||||
if cached_value is not None:
|
||||
return int(cached_value)
|
||||
|
||||
lockouts = AccessAttempt.objects.filter(q).count() // failure_limit
|
||||
|
||||
redis_client.set(cache_key, int(lockouts), 60)
|
||||
|
||||
return lockouts
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ def watch_login(status_code=302, msg="", get_username=utils.get_username_from_re
|
|||
# if the request is currently under lockout, do not proceed to the
|
||||
# login function, go directly to lockout url, do not pass go,
|
||||
# do not collect messages about this login attempt
|
||||
if utils.is_already_locked(request):
|
||||
return utils.lockout_response(request)
|
||||
username = get_username(request)
|
||||
|
||||
if utils.is_already_locked(request, username=username):
|
||||
return utils.lockout_response(request, username=username)
|
||||
|
||||
# call the login function
|
||||
response = func(request, *args, **kwargs)
|
||||
|
|
@ -44,13 +46,13 @@ def watch_login(status_code=302, msg="", get_username=utils.get_username_from_re
|
|||
# ideally make this background task, but to keep simple,
|
||||
# keeping it inline for now.
|
||||
utils.add_login_attempt_to_db(
|
||||
request, not login_unsuccessful, get_username
|
||||
request, not login_unsuccessful, username=username
|
||||
)
|
||||
|
||||
if utils.check_request(request, login_unsuccessful, get_username):
|
||||
if utils.check_request(request, login_unsuccessful, username=username):
|
||||
return response
|
||||
|
||||
return utils.lockout_response(request)
|
||||
return utils.lockout_response(request, username=username)
|
||||
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,13 @@
|
|||
{% extends "admin/index.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if not is_popup %}
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
›
|
||||
{% for app in app_list %}
|
||||
{{ app.name }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
{% extends "admin/app_index.html" %}
|
||||
|
||||
{% block content %}
|
||||
{{ block.super }}
|
||||
{% url 'defender_blocks_view' as blocks_url %}
|
||||
{% if blocks_url %}
|
||||
<div class="app-defender module">
|
||||
<table><tr scope='row'><td colspan='3'>
|
||||
<h4><a href='{% url 'defender_blocks_view' %}'>Blocked Users</a></h4>
|
||||
<h4><a href='{{ blocks_url }}'>Blocked Users</a></h4>
|
||||
</td></tr></table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content%}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@
|
|||
<div class="breadcrumbs">
|
||||
<a href="{% url "admin:index" %}">Home</a> ›
|
||||
<a href="{% url "admin:app_list" "defender" %}">Defender</a> ›
|
||||
{{ title }}
|
||||
</div>
|
||||
{% endblock breadcrumbs %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
|
||||
<h1>Blocked Logins</h1>
|
||||
<p>Here is a list of IP's and usernames that are blocked</p>
|
||||
|
||||
<div class="module">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ MIDDLEWARE = (
|
|||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"defender.middleware.FailedLoginMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
)
|
||||
|
||||
ROOT_URLCONF = "defender.test_urls"
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@ import string
|
|||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.auth.models import AnonymousUser, User
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.testcases import TestCase
|
||||
from redis.client import Redis
|
||||
from django.urls import reverse
|
||||
|
||||
import redis
|
||||
|
||||
from defender.data import get_approx_account_lockouts_from_login_attempts, get_approx_lockouts_cache_key
|
||||
|
||||
from . import utils
|
||||
from . import config
|
||||
from .signals import (
|
||||
|
|
@ -19,7 +23,7 @@ from .signals import (
|
|||
username_block as username_block_signal,
|
||||
username_unblock as username_unblock_signal,
|
||||
)
|
||||
from .connection import parse_redis_url, get_redis_connection
|
||||
from .connection import get_redis_connection
|
||||
from .decorators import watch_login
|
||||
from .models import AccessAttempt
|
||||
from .test import DefenderTestCase, DefenderTransactionTestCase
|
||||
|
|
@ -286,7 +290,7 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
"""
|
||||
self.test_failure_limit_by_ip_once()
|
||||
# Wait for the cooling off period
|
||||
time.sleep(config.COOLOFF_TIME)
|
||||
time.sleep(config.LOCKOUT_COOLOFF_TIMES[0])
|
||||
|
||||
if config.MOCK_REDIS:
|
||||
# mock redis require that we expire on our own
|
||||
|
|
@ -427,6 +431,7 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
self.assertTemplateUsed(response, "defender/lockout.html")
|
||||
|
||||
@patch("defender.config.COOLOFF_TIME", 0)
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [0])
|
||||
def test_failed_login_no_cooloff(self):
|
||||
""" failed login no cooloff """
|
||||
for i in range(0, config.FAILURE_LIMIT):
|
||||
|
|
@ -470,72 +475,6 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
self.assertEqual(utils.is_valid_ip("::ffff:192.0.2.128"), True)
|
||||
self.assertEqual(utils.is_valid_ip("::ffff:8.8.8.8"), True)
|
||||
|
||||
def test_parse_redis_url(self):
|
||||
""" test the parse_redis_url method """
|
||||
# full regular
|
||||
conf = parse_redis_url("redis://user:password@localhost2:1234/2", False)
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 2)
|
||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# full non local
|
||||
conf = parse_redis_url(
|
||||
"redis://user:pass@www.localhost.com:1234/2", False)
|
||||
self.assertEqual(conf.get("HOST"), "www.localhost.com")
|
||||
self.assertEqual(conf.get("DB"), 2)
|
||||
self.assertEqual(conf.get("PASSWORD"), "pass")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# no user name
|
||||
conf = parse_redis_url("redis://password@localhost2:1234/2", False)
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 2)
|
||||
self.assertEqual(conf.get("PASSWORD"), None)
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# no user name 2 with colon
|
||||
conf = parse_redis_url("redis://:password@localhost2:1234/2", False)
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 2)
|
||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# Empty
|
||||
conf = parse_redis_url(None, False)
|
||||
self.assertEqual(conf.get("HOST"), "localhost")
|
||||
self.assertEqual(conf.get("DB"), 0)
|
||||
self.assertEqual(conf.get("PASSWORD"), None)
|
||||
self.assertEqual(conf.get("PORT"), 6379)
|
||||
|
||||
# no db
|
||||
conf = parse_redis_url("redis://:password@localhost2:1234", False)
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 0)
|
||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# no password
|
||||
conf = parse_redis_url("redis://localhost2:1234/0", False)
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 0)
|
||||
self.assertEqual(conf.get("PASSWORD"), None)
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# password with special character and set the password_quote = True
|
||||
conf = parse_redis_url("redis://:calmkart%23%40%21@localhost:6379/0", True)
|
||||
self.assertEqual(conf.get("HOST"), "localhost")
|
||||
self.assertEqual(conf.get("DB"), 0)
|
||||
self.assertEqual(conf.get("PASSWORD"), "calmkart#@!")
|
||||
self.assertEqual(conf.get("PORT"), 6379)
|
||||
|
||||
# password without special character and set the password_quote = True
|
||||
conf = parse_redis_url("redis://:password@localhost2:1234", True)
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 0)
|
||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
@patch("defender.config.DEFENDER_REDIS_NAME", "default")
|
||||
def test_get_redis_connection_django_conf(self):
|
||||
""" get the redis connection """
|
||||
|
|
@ -926,6 +865,119 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
data_out = utils.get_blocked_ips()
|
||||
self.assertEqual(data_out, [])
|
||||
|
||||
@patch("defender.config.USERNAME_FAILURE_LIMIT", 3)
|
||||
@patch("defender.config.DISABLE_IP_LOCKOUT", True)
|
||||
def test_login_blocked_for_non_standard_login_views_different_username(self):
|
||||
"""
|
||||
Check that a view with custom username blocked correctly
|
||||
"""
|
||||
|
||||
@watch_login(status_code=401, get_username=lambda request: request.POST.get("email"))
|
||||
def fake_api_401_login_different_username(request):
|
||||
""" Fake the api login with 401 """
|
||||
return HttpResponse("Invalid", status=401)
|
||||
|
||||
wrong_email = "email@localhost"
|
||||
|
||||
request_factory = RequestFactory()
|
||||
request = request_factory.post("api/login", data={"email": wrong_email})
|
||||
request.user = AnonymousUser()
|
||||
request.session = SessionStore()
|
||||
|
||||
for _ in range(3):
|
||||
fake_api_401_login_different_username(request)
|
||||
|
||||
data_out = utils.get_blocked_usernames()
|
||||
self.assertEqual(data_out, [])
|
||||
|
||||
fake_api_401_login_different_username(request)
|
||||
|
||||
data_out = utils.get_blocked_usernames()
|
||||
self.assertEqual(data_out, [wrong_email])
|
||||
|
||||
# Ensure that `watch_login` correctly extract username from request
|
||||
# during `is_already_locked` check and don't cause 500 errors
|
||||
status_code = fake_api_401_login_different_username(request)
|
||||
self.assertNotEqual(status_code, 500)
|
||||
|
||||
@patch("defender.config.ATTEMPT_COOLOFF_TIME", "a")
|
||||
def test_bad_attempt_cooloff_configuration(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.ATTEMPT_COOLOFF_TIME", ["a"])
|
||||
def test_bad_attempt_cooloff_configuration_with_list(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", "a")
|
||||
def test_bad_lockout_cooloff_configuration(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [300, "a"])
|
||||
def test_bad_list_lockout_cooloff_configuration(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [300, dict(a="a")])
|
||||
def test_bad_list_with_dict_lockout_cooloff_configuration(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [3, 6])
|
||||
@patch("defender.config.IP_FAILURE_LIMIT", 3)
|
||||
def test_lockout_cooloff_correctly_scales_with_ip_when_set(self):
|
||||
self.test_ip_failure_limit()
|
||||
self.assertEqual(utils.get_lockout_cooloff_time(ip_address="127.0.0.1"), 3)
|
||||
utils.reset_failed_attempts(ip_address="127.0.0.1")
|
||||
self.test_ip_failure_limit()
|
||||
self.assertEqual(utils.get_lockout_cooloff_time(ip_address="127.0.0.1"), 6)
|
||||
time.sleep(config.LOCKOUT_COOLOFF_TIMES[1])
|
||||
if config.MOCK_REDIS:
|
||||
# mock redis require that we expire on our own
|
||||
get_redis_connection().do_expire() # pragma: no cover
|
||||
self.test_valid_login()
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [3, 6])
|
||||
@patch("defender.config.USERNAME_FAILURE_LIMIT", 3)
|
||||
def test_lockout_cooloff_correctly_scales_with_username_when_set(self):
|
||||
self.test_username_failure_limit()
|
||||
self.assertEqual(utils.get_lockout_cooloff_time(username=VALID_USERNAME), 3)
|
||||
utils.reset_failed_attempts(username=VALID_USERNAME)
|
||||
self.test_username_failure_limit()
|
||||
self.assertEqual(utils.get_lockout_cooloff_time(username=VALID_USERNAME), 6)
|
||||
time.sleep(config.LOCKOUT_COOLOFF_TIMES[1])
|
||||
if config.MOCK_REDIS:
|
||||
# mock redis require that we expire on our own
|
||||
get_redis_connection().do_expire() # pragma: no cover
|
||||
self.test_valid_login()
|
||||
|
||||
@patch("defender.config.STORE_ACCESS_ATTEMPTS", False)
|
||||
def test_approx_account_lockout_count_default_case_no_store(self):
|
||||
self.assertEqual(get_approx_account_lockouts_from_login_attempts(ip_address="127.0.0.1"), 0)
|
||||
|
||||
def test_approx_account_lockout_count_default_case_empty_args(self):
|
||||
self.assertEqual(get_approx_account_lockouts_from_login_attempts(), 0)
|
||||
|
||||
@patch("defender.config.DISABLE_IP_LOCKOUT", True)
|
||||
def test_approx_account_lockout_count_default_case_invalid_args_pt1(self):
|
||||
with self.assertRaises(Exception):
|
||||
get_approx_account_lockouts_from_login_attempts(ip_address="127.0.0.1")
|
||||
|
||||
@patch("defender.config.DISABLE_USERNAME_LOCKOUT", True)
|
||||
def test_approx_account_lockout_count_default_case_invalid_args_pt2(self):
|
||||
with self.assertRaises(Exception):
|
||||
get_approx_account_lockouts_from_login_attempts(username=VALID_USERNAME)
|
||||
|
||||
def test_approx_account_lockout_uses_redis_cache(self):
|
||||
get_approx_account_lockouts_from_login_attempts(
|
||||
ip_address="127.0.0.1", username=VALID_USERNAME
|
||||
)
|
||||
|
||||
redis_client = get_redis_connection()
|
||||
cached_value = redis_client.get(
|
||||
get_approx_lockouts_cache_key(
|
||||
ip_address="127.0.0.1", username=VALID_USERNAME
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(cached_value)
|
||||
|
||||
|
||||
class SignalTest(DefenderTestCase):
|
||||
""" Test that signals are properly sent when blocking usernames and IPs.
|
||||
|
|
@ -1075,3 +1127,112 @@ class TestUtils(DefenderTestCase):
|
|||
|
||||
utils.add_login_attempt_to_db(request, True, username=username)
|
||||
self.assertEqual(AccessAttempt.objects.filter(username=username).count(), 1)
|
||||
|
||||
def test_ip_address_strip_port_number(self):
|
||||
""" Test the strip_port_number() method """
|
||||
# IPv4 with/without port
|
||||
self.assertEqual(utils.strip_port_number("192.168.1.1"), "192.168.1.1")
|
||||
self.assertEqual(utils.strip_port_number(
|
||||
"192.168.1.1:8000"), "192.168.1.1")
|
||||
|
||||
# IPv6 with/without port
|
||||
self.assertEqual(utils.strip_port_number(
|
||||
"2001:db8:85a3:0:0:8a2e:370:7334"), "2001:db8:85a3:0:0:8a2e:370:7334")
|
||||
self.assertEqual(utils.strip_port_number(
|
||||
"[2001:db8:85a3:0:0:8a2e:370:7334]:123456"), "2001:db8:85a3:0:0:8a2e:370:7334")
|
||||
|
||||
@patch("defender.config.BEHIND_REVERSE_PROXY", True)
|
||||
def test_get_ip_strips_port_number(self):
|
||||
""" make sure the IP address is stripped of its port number """
|
||||
req = HttpRequest()
|
||||
req.META["HTTP_X_FORWARDED_FOR"] = "1.2.3.4:123456"
|
||||
self.assertEqual(utils.get_ip(req), "1.2.3.4")
|
||||
|
||||
req = HttpRequest()
|
||||
req.META["HTTP_X_FORWARDED_FOR"] = "[2001:db8::1]:123456"
|
||||
self.assertEqual(utils.get_ip(req), "2001:db8::1")
|
||||
|
||||
def test_remove_prefix(self):
|
||||
""" test the remove_prefix() method """
|
||||
self.assertEqual(utils.remove_prefix(
|
||||
"defender:blocked:ip:192.168.24.24", "defender:blocked:"), "ip:192.168.24.24")
|
||||
self.assertEqual(utils.remove_prefix(
|
||||
"defender:blocked:username:johndoe", "defender:blocked:"), "username:johndoe")
|
||||
self.assertEqual(utils.remove_prefix(
|
||||
"defender:blocked:username:johndoe", "blocked:username:"),
|
||||
"defender:blocked:username:johndoe")
|
||||
|
||||
def test_whitespace_block_circumvention(self):
|
||||
username = "johndoe"
|
||||
req = HttpRequest()
|
||||
req.POST["username"] = f"{username} " # username with appended whitespace
|
||||
req.META["HTTP_X_REAL_IP"] = "1.2.3.4"
|
||||
|
||||
utils.block_username(username)
|
||||
|
||||
self.assertTrue(utils.is_already_locked(req))
|
||||
|
||||
|
||||
class TestRedisConnection(TestCase):
|
||||
""" Test the redis connection parsing """
|
||||
REDIS_URL_PLAIN = "redis://localhost:6379/0"
|
||||
REDIS_URL_PASS = "redis://:mypass@localhost:6379/0"
|
||||
REDIS_URL_NAME_PASS = "redis://myname:mypass2@localhost:6379/0"
|
||||
|
||||
@patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_PLAIN)
|
||||
@patch("defender.config.MOCK_REDIS", False)
|
||||
def test_get_redis_connection(self):
|
||||
""" get redis connection plain """
|
||||
redis_client = get_redis_connection()
|
||||
self.assertIsInstance(redis_client, Redis)
|
||||
redis_client.set('test', 0)
|
||||
result = int(redis_client.get('test'))
|
||||
self.assertEqual(result, 0)
|
||||
redis_client.delete('test')
|
||||
|
||||
@patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_PASS)
|
||||
@patch("defender.config.MOCK_REDIS", False)
|
||||
def test_get_redis_connection_with_password(self):
|
||||
""" get redis connection with password """
|
||||
|
||||
connection = redis.Redis()
|
||||
connection.config_set('requirepass', 'mypass')
|
||||
|
||||
redis_client = get_redis_connection()
|
||||
self.assertIsInstance(redis_client, Redis)
|
||||
redis_client.set('test2', 0)
|
||||
result = int(redis_client.get('test2'))
|
||||
self.assertEqual(result, 0)
|
||||
redis_client.delete('test2')
|
||||
# clean up
|
||||
redis_client.config_set('requirepass', '')
|
||||
|
||||
@patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_NAME_PASS)
|
||||
@patch("defender.config.MOCK_REDIS", False)
|
||||
def test_get_redis_connection_with_acl(self):
|
||||
""" get redis connection with password and name ACL """
|
||||
connection = redis.Redis()
|
||||
|
||||
if connection.info().get('redis_version') < '6':
|
||||
# redis versions before 6 don't have acl, so skip.
|
||||
return
|
||||
|
||||
connection.acl_setuser(
|
||||
'myname',
|
||||
enabled=True,
|
||||
passwords=["+" + "mypass2", ],
|
||||
keys="*",
|
||||
commands=["+@all", ])
|
||||
|
||||
try:
|
||||
redis_client = get_redis_connection()
|
||||
self.assertIsInstance(redis_client, Redis)
|
||||
redis_client.set('test3', 0)
|
||||
result = int(redis_client.get('test3'))
|
||||
self.assertEqual(result, 0)
|
||||
redis_client.delete('test3')
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
# clean up
|
||||
connection.acl_deluser('myname')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from ipaddress import ip_address
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponseRedirect
|
||||
|
|
@ -9,7 +12,11 @@ from django.utils.module_loading import import_string
|
|||
|
||||
from .connection import get_redis_connection
|
||||
from . import config
|
||||
from .data import store_login_attempt
|
||||
from .data import (
|
||||
get_approx_account_lockouts_from_login_attempts,
|
||||
get_approx_lockouts_cache_key,
|
||||
store_login_attempt,
|
||||
)
|
||||
from .signals import (
|
||||
send_username_block_signal,
|
||||
send_ip_block_signal,
|
||||
|
|
@ -43,15 +50,51 @@ def get_ip_address_from_request(request):
|
|||
return "127.0.0.1"
|
||||
|
||||
|
||||
ipv4_with_port = re.compile(r"^(\d+\.\d+\.\d+\.\d+):\d+")
|
||||
ipv6_with_port = re.compile(r"^\[([^\]]+)\]:\d+")
|
||||
|
||||
|
||||
def strip_port_number(ip_address_string):
|
||||
""" strips port number from IPv4 or IPv6 address """
|
||||
ip_address = None
|
||||
|
||||
if ipv4_with_port.match(ip_address_string):
|
||||
match = ipv4_with_port.match(ip_address_string)
|
||||
ip_address = match[1]
|
||||
elif ipv6_with_port.match(ip_address_string):
|
||||
match = ipv6_with_port.match(ip_address_string)
|
||||
ip_address = match[1]
|
||||
|
||||
"""
|
||||
If it's not a valid IP address, we prefer to return
|
||||
the string as-is instead of returning a potentially
|
||||
corrupted string:
|
||||
"""
|
||||
if is_valid_ip(ip_address):
|
||||
return ip_address
|
||||
|
||||
return ip_address_string
|
||||
|
||||
|
||||
def get_ip(request):
|
||||
""" get the ip address from the request """
|
||||
if config.BEHIND_REVERSE_PROXY:
|
||||
ip_address = request.META.get(config.REVERSE_PROXY_HEADER, "")
|
||||
ip_address = ip_address.split(",", 1)[0].strip()
|
||||
|
||||
if ip_address == "":
|
||||
ip_address = get_ip_address_from_request(request)
|
||||
else:
|
||||
"""
|
||||
Some reverse proxies will include a port number with the
|
||||
IP address; as this port may change from request to request,
|
||||
and thus make it appear to be different IP addresses, we'll
|
||||
want to remove the port number, if present:
|
||||
"""
|
||||
ip_address = strip_port_number(ip_address)
|
||||
else:
|
||||
ip_address = get_ip_address_from_request(request)
|
||||
|
||||
return ip_address
|
||||
|
||||
|
||||
|
|
@ -89,20 +132,38 @@ def get_username_blocked_cache_key(username):
|
|||
)
|
||||
|
||||
|
||||
def remove_prefix(string, prefix):
|
||||
if string.startswith(prefix):
|
||||
return string[len(prefix):]
|
||||
return string
|
||||
|
||||
|
||||
|
||||
def strip_keys(key_list):
|
||||
""" Given a list of keys, remove the prefix and remove just
|
||||
the data we care about.
|
||||
|
||||
for example:
|
||||
|
||||
['defender:blocked:ip:ken', 'defender:blocked:ip:joffrey']
|
||||
[
|
||||
'defender:blocked:ip:192.168.24.24',
|
||||
'defender:blocked:ip:::ffff:192.168.24.24',
|
||||
'defender:blocked:username:joffrey'
|
||||
]
|
||||
|
||||
would result in:
|
||||
|
||||
['ken', 'joffrey']
|
||||
|
||||
[
|
||||
'192.168.24.24',
|
||||
'::ffff:192.168.24.24',
|
||||
'joffrey'
|
||||
]
|
||||
"""
|
||||
return [key.split(":")[-1] for key in key_list]
|
||||
return [
|
||||
# key.removeprefix(f"{config.CACHE_PREFIX}:blocked:").partition(":")[2]
|
||||
remove_prefix(key, f"{config.CACHE_PREFIX}:blocked:").partition(":")[2]
|
||||
for key in key_list
|
||||
]
|
||||
|
||||
|
||||
def get_blocked_ips():
|
||||
|
|
@ -129,8 +190,8 @@ def increment_key(key):
|
|||
""" given a key increment the value """
|
||||
pipe = REDIS_SERVER.pipeline()
|
||||
pipe.incr(key, 1)
|
||||
if config.COOLOFF_TIME:
|
||||
pipe.expire(key, config.COOLOFF_TIME)
|
||||
if config.ATTEMPT_COOLOFF_TIME:
|
||||
pipe.expire(key, config.ATTEMPT_COOLOFF_TIME)
|
||||
new_value = pipe.execute()[0]
|
||||
return new_value
|
||||
|
||||
|
|
@ -138,7 +199,7 @@ def increment_key(key):
|
|||
def username_from_request(request):
|
||||
""" unloads username from default POST request """
|
||||
if config.USERNAME_FORM_FIELD in request.POST:
|
||||
return request.POST[config.USERNAME_FORM_FIELD][:255]
|
||||
return request.POST[config.USERNAME_FORM_FIELD][:255].strip()
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -167,6 +228,15 @@ def get_user_attempts(request, get_username=get_username_from_request, username=
|
|||
# return the larger of the two.
|
||||
return max(ip_count, username_count)
|
||||
|
||||
def get_lockout_cooloff_time(ip_address=None, username=None):
|
||||
if not config.LOCKOUT_COOLOFF_TIMES:
|
||||
return 0
|
||||
index = max(0, min(
|
||||
len(config.LOCKOUT_COOLOFF_TIMES) - 1,
|
||||
get_approx_account_lockouts_from_login_attempts(ip_address, username) - 1
|
||||
))
|
||||
return config.LOCKOUT_COOLOFF_TIMES[index]
|
||||
|
||||
|
||||
def block_ip(ip_address):
|
||||
""" given the ip, block it """
|
||||
|
|
@ -178,8 +248,9 @@ def block_ip(ip_address):
|
|||
return
|
||||
already_blocked = is_source_ip_already_locked(ip_address)
|
||||
key = get_ip_blocked_cache_key(ip_address)
|
||||
if config.COOLOFF_TIME:
|
||||
REDIS_SERVER.set(key, "blocked", config.COOLOFF_TIME)
|
||||
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address)
|
||||
if cooloff_time:
|
||||
REDIS_SERVER.set(key, "blocked", cooloff_time)
|
||||
else:
|
||||
REDIS_SERVER.set(key, "blocked")
|
||||
if not already_blocked:
|
||||
|
|
@ -196,8 +267,9 @@ def block_username(username):
|
|||
return
|
||||
already_blocked = is_user_already_locked(username)
|
||||
key = get_username_blocked_cache_key(username)
|
||||
if config.COOLOFF_TIME:
|
||||
REDIS_SERVER.set(key, "blocked", config.COOLOFF_TIME)
|
||||
cooloff_time = get_lockout_cooloff_time(username=username)
|
||||
if cooloff_time:
|
||||
REDIS_SERVER.set(key, "blocked", cooloff_time)
|
||||
else:
|
||||
REDIS_SERVER.set(key, "blocked")
|
||||
if not already_blocked:
|
||||
|
|
@ -263,6 +335,10 @@ def unblock_ip(ip_address, pipe=None):
|
|||
pipe.execute()
|
||||
send_ip_unblock_signal(ip_address)
|
||||
|
||||
redis_cache_key = get_approx_lockouts_cache_key(ip_address, None)
|
||||
redis_client = get_redis_connection()
|
||||
redis_client.delete(redis_cache_key)
|
||||
|
||||
|
||||
def unblock_username(username, pipe=None):
|
||||
""" unblock the given Username """
|
||||
|
|
@ -277,6 +353,10 @@ def unblock_username(username, pipe=None):
|
|||
pipe.execute()
|
||||
send_username_unblock_signal(username)
|
||||
|
||||
redis_cache_key = get_approx_lockouts_cache_key(None, username)
|
||||
redis_client = get_redis_connection()
|
||||
redis_client.delete(redis_cache_key)
|
||||
|
||||
|
||||
def reset_failed_attempts(ip_address=None, username=None):
|
||||
""" reset the failed attempts for these ip's and usernames
|
||||
|
|
@ -289,15 +369,21 @@ def reset_failed_attempts(ip_address=None, username=None):
|
|||
unblock_ip(ip_address, pipe=pipe)
|
||||
unblock_username(username, pipe=pipe)
|
||||
|
||||
redis_cache_key = get_approx_lockouts_cache_key(ip_address, username)
|
||||
redis_client = get_redis_connection()
|
||||
redis_client.delete(redis_cache_key)
|
||||
|
||||
pipe.execute()
|
||||
|
||||
|
||||
def lockout_response(request):
|
||||
def lockout_response(request, username):
|
||||
""" if we are locked out, here is the response """
|
||||
ip_address = get_ip(request)
|
||||
if config.LOCKOUT_TEMPLATE:
|
||||
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address, username=username)
|
||||
context = {
|
||||
"cooloff_time_seconds": config.COOLOFF_TIME,
|
||||
"cooloff_time_minutes": config.COOLOFF_TIME / 60,
|
||||
"cooloff_time_seconds": cooloff_time,
|
||||
"cooloff_time_minutes": cooloff_time / 60,
|
||||
"failure_limit": config.FAILURE_LIMIT,
|
||||
}
|
||||
return render(request, config.LOCKOUT_TEMPLATE, context)
|
||||
|
|
@ -305,7 +391,7 @@ def lockout_response(request):
|
|||
if config.LOCKOUT_URL:
|
||||
return HttpResponseRedirect(config.LOCKOUT_URL)
|
||||
|
||||
if config.COOLOFF_TIME:
|
||||
if get_lockout_cooloff_time(ip_address=ip_address, username=username):
|
||||
return HttpResponse(
|
||||
"Account locked: too many login attempts. " "Please try again later."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from django.shortcuts import render
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.urls import reverse
|
||||
|
||||
|
|
@ -13,10 +14,12 @@ def block_view(request):
|
|||
blocked_ip_list = get_blocked_ips()
|
||||
blocked_username_list = get_blocked_usernames()
|
||||
|
||||
context = {
|
||||
context = admin.site.index(request).context_data
|
||||
context.update({
|
||||
"blocked_ip_list": blocked_ip_list,
|
||||
"blocked_username_list": blocked_username_list,
|
||||
}
|
||||
"title": "Blocked logins",
|
||||
})
|
||||
return render(request, "defender/admin/blocks.html", context)
|
||||
|
||||
|
||||
|
|
|
|||
18
docs/conf.py
18
docs/conf.py
|
|
@ -13,16 +13,24 @@
|
|||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath("."))
|
||||
from pkg_resources import get_distribution
|
||||
try:
|
||||
from importlib import metadata
|
||||
except ImportError:
|
||||
# Running on pre-3.8 Python; use importlib-metadata package
|
||||
import importlib_metadata as metadata
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "django-defender"
|
||||
copyright = "2014, Ken Cochrane"
|
||||
copyright = "2024, Ken Cochrane"
|
||||
author = "Ken Cochrane"
|
||||
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = get_distribution("django-defender").version
|
||||
try:
|
||||
release = metadata.version("django-defender")
|
||||
except metadata.PackageNotFoundError:
|
||||
# package is not installed
|
||||
release = "0.0.0"
|
||||
|
||||
# The short X.Y version.
|
||||
version = ".".join(release.split(".")[:2])
|
||||
|
|
@ -38,7 +46,7 @@ master_doc = "index"
|
|||
extensions = []
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
templates_path = []
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
|
|
@ -56,4 +64,4 @@ html_theme = "sphinx_rtd_theme"
|
|||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ["_static"]
|
||||
html_static_path = []
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ from django.conf.urls.static import static
|
|||
admin.autodiscover()
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r"^admin/", admin.site.urls),
|
||||
re_path(r"^admin/defender/", include("defender.urls")),
|
||||
re_path(r"^admin/", admin.site.urls),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
-e .
|
||||
coverage
|
||||
mockredispy
|
||||
django-redis-cache
|
||||
django-redis>=5,<6
|
||||
redis>=5,<6
|
||||
importlib-metadata<5.0
|
||||
celery
|
||||
sphinx_rtd_theme==2.0.0
|
||||
|
|
|
|||
18
setup.py
18
setup.py
|
|
@ -33,16 +33,22 @@ setup(
|
|||
"Framework :: Django",
|
||||
"Framework :: Django :: 3.2",
|
||||
"Framework :: Django :: 4.0",
|
||||
"Framework :: Django :: 4.1",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Framework :: Django :: 5.1",
|
||||
"Framework :: Django :: 5.2",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3 :: Only',
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||||
|
|
@ -58,12 +64,12 @@ setup(
|
|||
include_package_data=True,
|
||||
packages=find_packages(),
|
||||
package_data=get_package_data("defender"),
|
||||
python_requires='~=3.5',
|
||||
install_requires=["Django", "redis"],
|
||||
python_requires="~=3.8",
|
||||
install_requires=["Django", "redis>=4.0.0"],
|
||||
tests_require=[
|
||||
"mockredispy>=2.9.0.11,<3.0",
|
||||
"coverage",
|
||||
"celery",
|
||||
"django-redis-cache",
|
||||
"django-redis",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
27
tox.ini
27
tox.ini
|
|
@ -1,23 +1,29 @@
|
|||
[tox]
|
||||
envlist =
|
||||
# list of supported Django/Python versions:
|
||||
py{37,38,39,py3}-dj{32}
|
||||
py{38,39,310}-dj{40,main}
|
||||
py38-{lint,docs}
|
||||
py{38,39,py3}-dj{32}
|
||||
py{38,39,310,311,312}-dj{42}
|
||||
py{310,311,312}-dj{50,51,52,main}
|
||||
py312-{lint,docs}
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.7: py37
|
||||
3.8: py38
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
3.11: py311
|
||||
3.12: py312
|
||||
pypy3: pypy3
|
||||
|
||||
[testenv]
|
||||
deps =
|
||||
setuptools
|
||||
-rrequirements.txt
|
||||
dj32: django>=3.2,<4.0
|
||||
dj40: django>=4.0,<4.1
|
||||
dj42: django>=4.2,<5.0
|
||||
dj50: django>=5.0,<5.1
|
||||
dj51: django>=5.1,<5.2
|
||||
dj52: django>=5.2,<5.3
|
||||
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||
usedevelop = True
|
||||
commands =
|
||||
|
|
@ -29,19 +35,22 @@ ignore_outcome =
|
|||
ignore_errors =
|
||||
djmain: True
|
||||
|
||||
[testenv:py38-docs]
|
||||
basepython = python3.8
|
||||
[testenv:py312-docs]
|
||||
basepython = python3.12
|
||||
deps =
|
||||
-rrequirements.txt
|
||||
Sphinx
|
||||
sphinx_rtd_theme
|
||||
setuptools
|
||||
commands = sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html
|
||||
|
||||
[testenv:py38-lint]
|
||||
basepython = python3.8
|
||||
[testenv:py312-lint]
|
||||
basepython = python3.12
|
||||
deps =
|
||||
twine
|
||||
check-manifest
|
||||
setuptools
|
||||
setuptools_scm
|
||||
commands =
|
||||
check-manifest -v
|
||||
python setup.py sdist
|
||||
|
|
|
|||
Loading…
Reference in a new issue