Compare commits

...

24 commits

Author SHA1 Message Date
Hesam Noorin
daa6235caf
Upgrade to Django 5.2 & Python 3.12 (#249)
Some checks failed
Test / build (3.10, 5) (push) Has been cancelled
Test / build (3.10, 6) (push) Has been cancelled
Test / build (3.10, 7) (push) Has been cancelled
Test / build (3.11, 5) (push) Has been cancelled
Test / build (3.11, 6) (push) Has been cancelled
Test / build (3.11, 7) (push) Has been cancelled
Test / build (3.12, 5) (push) Has been cancelled
Test / build (3.12, 6) (push) Has been cancelled
Test / build (3.12, 7) (push) Has been cancelled
Test / build (3.13, 5) (push) Has been cancelled
Test / build (3.13, 6) (push) Has been cancelled
Test / build (3.13, 7) (push) Has been cancelled
Test / build (3.9, 5) (push) Has been cancelled
Test / build (3.9, 6) (push) Has been cancelled
Test / build (3.9, 7) (push) Has been cancelled
* feat: add support for Django 5.0, 5.1, and 5.2
* fix: resolve Python 3.12 build failures in docs and lint environments
2026-02-01 11:28:21 -05:00
Yurii Parfinenko
289af19ce9
Use redis cache in get_approx_account_lockouts_from_login_attempts (#250)
Some checks failed
Test / build (3.10, 5) (push) Has been cancelled
Test / build (3.10, 6) (push) Has been cancelled
Test / build (3.10, 7) (push) Has been cancelled
Test / build (3.11, 5) (push) Has been cancelled
Test / build (3.11, 6) (push) Has been cancelled
Test / build (3.11, 7) (push) Has been cancelled
Test / build (3.12, 5) (push) Has been cancelled
Test / build (3.12, 6) (push) Has been cancelled
Test / build (3.12, 7) (push) Has been cancelled
Test / build (3.13, 5) (push) Has been cancelled
Test / build (3.13, 6) (push) Has been cancelled
Test / build (3.13, 7) (push) Has been cancelled
Test / build (3.9, 5) (push) Has been cancelled
Test / build (3.9, 6) (push) Has been cancelled
Test / build (3.9, 7) (push) Has been cancelled
* Use redis cache in `get_approx_account_lockouts_from_login_attempts`

* use django_redis in ci

* Add `django_redis` and `redis` to requirements.txt

* Fix an issue detected by tests: clear redis cache upon block reset

* Remove the unnecessary `if`
2026-01-29 12:53:21 -05:00
Attila
37e5dd3123
Fixed circumventing blocking by appending whitespace to username (#248) 2025-07-01 11:23:24 -04:00
Ken Cochrane
e420d76463
Update test.yml
Updated Github Actions to remove python versions 3.7 and 3.8 and added 3.11, 3.12, 3.13
2025-07-01 11:22:15 -04:00
Ken Cochrane
cc35032a0c Added missing sphinx theme to requirements file 2024-02-15 17:10:42 -05:00
Ken Cochrane
f2dede8c76 fix docs 2024-02-15 17:07:34 -05:00
Ken Cochrane
4e00500537 fix the docs so they can build 2024-02-15 16:53:49 -05:00
Ken Cochrane
83ad7ce338 Bump 0.9.8 2024-02-15 16:40:06 -05:00
Adam
07555abd29
Improved the "Blocked Logins" page's admin integration (#239) 2024-02-14 18:10:03 -05:00
Adam
c290b5a673
Updated app_index.html (#238) 2024-02-14 18:07:30 -05:00
Adam
4bea010b65
Prevent the "Reverse for 'defender_blocks_view' not found" error (#237) 2024-02-14 18:06:30 -05:00
Ben Lopatin
a972dae7fc
Update DEFENDER_REDIS_NAME documentation (#235)
Suggesting that this uses the name of the _client_ is misleading and confusing, as that would be the name of a backend (e.g. RedisCache). The referencing code uses DEFENDER_REDIS_NAME to look up the named cache from `CACHES` instead.
2024-01-17 15:33:20 -05:00
Roman Gorbil
1e0aa91952
Fix watch_login with custom username (#228)
Previously using of custom `get_username` function with disabled IP
lockout caused unhandled exception
Exception("Invalid state requested")
2023-11-09 06:41:49 -06:00
dkr-sahar
ba548fa9c3
Use redis parse_url method instead of a custom one (#234)
* Use redis parse_url method instead of a custom one

The custom method defined here has no real advantage

- the redis lib implements it better and have support for many use cases
- maintaining this implementation is error-prone and unnecessary work for overworked open-source contributors :)

Especially, when you want to pass query parameters here, they are not supported (for eg a custom certificate authority)

* remove test about url parsing
* remove unused imports
2023-10-12 13:20:58 -04:00
marius-mather
f6c73e093b
Update tox.ini with Django 4.2, Python 3.11 (#233) 2023-10-03 08:24:30 -04:00
Shen Li
8d4c6840e9
Replace datetime.now with timezone.now (#232)
Use `timezone.now` instead of `datetime.now` when constructing datetime objects. `timezone.now` ensures the timezone-awareness to be consistent with `settings.USE_TZ`
2023-07-13 16:58:47 -04:00
Ken Cochrane
2a0469669a
Update test.yml
remove pypy from unit tests since it is break for an unknown reason
2023-07-13 16:37:12 -04:00
Ken Cochrane
91dfbde556
Update test.yml
Changed pypy from 3.8 to 3.9
2023-07-13 13:51:52 -04:00
Ken Cochrane
cc6145b84e updated github actions to latest versions 2023-02-27 17:53:31 -05:00
Ken Cochrane
6111eb81da Bump version 0.9.7 2023-02-27 17:39:23 -05:00
Ken Cochrane
b0f90e690a
fixing issue #219 don't add redis username by default (#227)
* fixing issue #219 don't add Redis username by default
2023-02-23 09:59:52 -05:00
Dashgin
a4b3f9f332 remove_prefix method working same for all python versions 2023-02-21 11:01:20 -05:00
Dashgin
d90dfa8db7 added test for remove_prefix method 2023-02-21 11:01:20 -05:00
Dashgin
428968b238 Bugfix strip_keys method (returns wrong response method when there is string containing in key_list) 2023-02-21 11:01:20 -05:00
20 changed files with 345 additions and 203 deletions

View file

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

View file

@ -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']
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
View 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

View file

@ -2,6 +2,24 @@
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
=====

View file

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

View file

@ -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
@ -381,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
@ -534,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

View file

@ -1,3 +1,3 @@
VERSION = (0, 9, 6)
VERSION = (0, 9, 8)
__version__ = ".".join((map(str, VERSION)))

View file

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

View file

@ -31,54 +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"),
username=redis_config.get("USERNAME"),
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,
"USERNAME": "default",
"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.username:
redis_config.update({"USERNAME": url.username})
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)

View file

@ -1,8 +1,10 @@
from datetime import datetime, timedelta
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(
@ -18,6 +20,14 @@ def store_login_attempt(
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
@ -30,16 +40,12 @@ def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=No
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).
"""
# TODO: Possibly add logic to temporarily store this info in the cache
# to help mitigate any potential performance impact this could have.
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=datetime.now() - timedelta(hours=config.ACCESS_ATTEMPT_EXPIRATION))
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
@ -56,4 +62,15 @@ def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=No
# conditions, we're in an inappropriate context.
raise Exception("Invalid state requested")
return AccessAttempt.objects.filter(q).count() // failure_limit
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

View file

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

View file

@ -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>
&rsaquo;
{% 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%}

View file

@ -12,13 +12,13 @@
<div class="breadcrumbs">
<a href="{% url "admin:index" %}">Home</a> &rsaquo;
<a href="{% url "admin:app_list" "defender" %}">Defender</a> &rsaquo;
{{ 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">

View file

@ -3,17 +3,17 @@ import string
import time
from unittest.mock import patch
from datetime import datetime, timedelta
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.sessions.backends.db import SessionStore
from django.db.models import Q
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
from defender.data import get_approx_account_lockouts_from_login_attempts
import redis
from defender.data import get_approx_account_lockouts_from_login_attempts, get_approx_lockouts_cache_key
from . import utils
from . import config
@ -23,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
@ -475,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 """
@ -931,6 +865,41 @@ 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)
@ -990,11 +959,24 @@ class AccessAttemptTest(DefenderTestCase):
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):
@ -1169,3 +1151,88 @@ class TestUtils(DefenderTestCase):
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')

View file

@ -1,6 +1,7 @@
from ipaddress import ip_address
import logging
import re
import sys
from django.http import HttpResponse
from django.http import HttpResponseRedirect
@ -11,7 +12,11 @@ from django.utils.module_loading import import_string
from .connection import get_redis_connection
from . import config
from .data import get_approx_account_lockouts_from_login_attempts, 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,
@ -62,7 +67,7 @@ def strip_port_number(ip_address_string):
"""
If it's not a valid IP address, we prefer to return
the string as-is instead of returning a potentially
the string as-is instead of returning a potentially
corrupted string:
"""
if is_valid_ip(ip_address):
@ -127,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():
@ -176,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
@ -312,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 """
@ -326,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
@ -338,13 +369,16 @@ 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)
username = get_username_from_request(request)
if config.LOCKOUT_TEMPLATE:
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address, username=username)
context = {

View file

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

View file

@ -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 = []

View file

@ -1,6 +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

View file

@ -34,16 +34,21 @@ setup(
"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",
@ -59,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",
],
)

28
tox.ini
View file

@ -1,24 +1,29 @@
[tox]
envlist =
# list of supported Django/Python versions:
py{37,38,39,py3}-dj{32}
py{38,39,310}-dj{40,41,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
dj41: django>=4.1,<4.2
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 =
@ -30,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