Compare commits

...

19 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
20 changed files with 232 additions and 199 deletions

View file

@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: 3.8 python-version: 3.8
@ -33,7 +33,7 @@ jobs:
- name: Upload packages to Jazzband - name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 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: with:
user: jazzband user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }} password: ${{ secrets.JAZZBAND_RELEASE_KEY }}

View file

@ -9,11 +9,11 @@ jobs:
fail-fast: false fail-fast: false
max-parallel: 5 max-parallel: 5
matrix: matrix:
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8'] python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
redis-version: [5, 6, 7] redis-version: [5, 6, 7]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Start Redis - name: Start Redis
uses: supercharge/redis-github-action@1.5.0 uses: supercharge/redis-github-action@1.5.0
@ -21,7 +21,7 @@ jobs:
redis-version: ${{ matrix.redis-version }} redis-version: ${{ matrix.redis-version }}
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@ -31,7 +31,7 @@ jobs:
echo "::set-output name=dir::$(pip cache dir)" echo "::set-output name=dir::$(pip cache dir)"
- name: Cache - name: Cache
uses: actions/cache@v2 uses: actions/cache@v3
with: with:
path: ${{ steps.pip-cache.outputs.dir }} path: ${{ steps.pip-cache.outputs.dir }}
key: key:
@ -49,6 +49,6 @@ jobs:
tox -v tox -v
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v1 uses: codecov/codecov-action@v3
with: with:
name: Python ${{ matrix.python-version }} 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,18 @@
Changes 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 0.9.7
===== =====

View file

@ -7,6 +7,7 @@ include CODE_OF_CONDUCT.md
include requirements.txt include requirements.txt
include tox.ini include tox.ini
include .pre-commit-config.yaml include .pre-commit-config.yaml
include .readthedocs.yaml
recursive-include docs * recursive-include docs *
recursive-include exampleapp * recursive-include exampleapp *
recursive-include defender/templates *.html recursive-include defender/templates *.html

View file

@ -108,8 +108,8 @@ Admin pages
Requirements Requirements
------------ ------------
* Python: 3.7, 3.8, 3.9, 3.10, PyPy * Python: 3.8, 3.9, 3.10, 3.11, 3.12, PyPy
* Django: 3.x, 4.x * Django: 3.2, 4.2, 5.0, 5.1, 5.2
* Redis: 5.x, 6.x, 7.x * Redis: 5.x, 6.x, 7.x
@ -381,7 +381,7 @@ These should be defined in your ``settings.py`` file.
(Example with password: ``redis://:mypassword@localhost:6379/0``\ ) (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. * ``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``\ ] [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``\ ] [Default: ``None``\ ]
* ``DEFENDER_STORE_ACCESS_ATTEMPTS``\ : Boolean: If you want to store the login * ``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 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.forms import PasswordResetForm, SetPasswordForm
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
from django.utils.http import urlsafe_base64_decode as uid_decoder from django.utils.http import urlsafe_base64_decode as uid_decoder
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_text from django.utils.encoding import force_str
from rest_framework import serializers, exceptions, HTTP_HEADER_ENCODING from rest_framework import serializers, exceptions, HTTP_HEADER_ENCODING
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from defender import utils as defender_utils from defender import utils as defender_utils

View file

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

View file

@ -7,7 +7,7 @@ from celery import Celery
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}} DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
CACHES = { CACHES = {
"default": {"BACKEND": "redis_cache.RedisCache", "LOCATION": "localhost:6379",} "default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://localhost:6379",}
} }
SITE_ID = 1 SITE_ID = 1

View file

@ -31,55 +31,5 @@ def get_redis_connection():
except AttributeError: except AttributeError:
# django_redis.cache.RedisCache case (django-redis package) # django_redis.cache.RedisCache case (django-redis package)
return cache.client.get_client(True) return cache.client.get_client(True)
else: # pragma: no cover else: # pragma: no cover)
redis_config = parse_redis_url( return redis.StrictRedis.from_url(config.DEFENDER_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,
"PASSWORD": None,
"HOST": "localhost",
"PORT": 6379,
"SSL": False,
}
if not url:
return redis_config
purl = urlparse.urlparse(url)
# Remove query strings.
path = purl.path[1:]
path = path.split("?", 2)[0]
if path:
redis_config.update({"DB": int(path)})
if purl.password:
password = purl.password
if password_quote:
password = urlparse.unquote(password)
redis_config.update({"PASSWORD": password})
if purl.hostname:
redis_config.update({"HOST": purl.hostname})
if purl.username:
redis_config.update({"USERNAME": purl.username})
if purl.port:
redis_config.update({"PORT": int(purl.port)})
if purl.scheme in ["https", "rediss"]:
redis_config.update({"SSL": True})
return redis_config

View file

@ -1,8 +1,10 @@
from datetime import datetime, timedelta from datetime import timedelta
from defender import config from defender import config
from defender.connection import get_redis_connection
from .models import AccessAttempt from .models import AccessAttempt
from django.db.models import Q from django.db.models import Q
from django.utils import timezone
def store_login_attempt( def store_login_attempt(
@ -18,6 +20,14 @@ def store_login_attempt(
login_valid=login_valid, 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): 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. """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 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: 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). 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 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 # If we're not storing login attempts OR both ip_address and username are
# None we should return 0. # None we should return 0.
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 failure_limit = config.FAILURE_LIMIT
if (ip_address and username and config.LOCKOUT_BY_IP_USERNAME \ if (ip_address and username and config.LOCKOUT_BY_IP_USERNAME \
and not config.DISABLE_IP_LOCKOUT and not config.DISABLE_USERNAME_LOCKOUT 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. # conditions, we're in an inappropriate context.
raise Exception("Invalid state requested") 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 # if the request is currently under lockout, do not proceed to the
# login function, go directly to lockout url, do not pass go, # login function, go directly to lockout url, do not pass go,
# do not collect messages about this login attempt # do not collect messages about this login attempt
if utils.is_already_locked(request): username = get_username(request)
return utils.lockout_response(request)
if utils.is_already_locked(request, username=username):
return utils.lockout_response(request, username=username)
# call the login function # call the login function
response = func(request, *args, **kwargs) 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, # ideally make this background task, but to keep simple,
# keeping it inline for now. # keeping it inline for now.
utils.add_login_attempt_to_db( 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 response
return utils.lockout_response(request) return utils.lockout_response(request, username=username)
return response return response

View file

@ -1,25 +1,13 @@
{% extends "admin/index.html" %} {% extends "admin/app_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 %}
{% block content %} {% block content %}
{{ block.super }} {{ block.super }}
{% url 'defender_blocks_view' as blocks_url %}
{% if blocks_url %}
<div class="app-defender module"> <div class="app-defender module">
<table><tr scope='row'><td colspan='3'> <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> </td></tr></table>
</div> </div>
{% endif %}
{% endblock content%} {% endblock content%}

View file

@ -12,13 +12,13 @@
<div class="breadcrumbs"> <div class="breadcrumbs">
<a href="{% url "admin:index" %}">Home</a> &rsaquo; <a href="{% url "admin:index" %}">Home</a> &rsaquo;
<a href="{% url "admin:app_list" "defender" %}">Defender</a> &rsaquo; <a href="{% url "admin:app_list" "defender" %}">Defender</a> &rsaquo;
{{ title }}
</div> </div>
{% endblock breadcrumbs %} {% endblock breadcrumbs %}
{% block content %} {% block content %}
<div id="content-main"> <div id="content-main">
<h1>Blocked Logins</h1>
<p>Here is a list of IP's and usernames that are blocked</p> <p>Here is a list of IP's and usernames that are blocked</p>
<div class="module"> <div class="module">

View file

@ -3,11 +3,8 @@ import string
import time import time
from unittest.mock import patch from unittest.mock import patch
from datetime import datetime, timedelta
from django.contrib.auth.models import AnonymousUser, User from django.contrib.auth.models import AnonymousUser, User
from django.contrib.sessions.backends.db import SessionStore from django.contrib.sessions.backends.db import SessionStore
from django.db.models import Q
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.test.testcases import TestCase from django.test.testcases import TestCase
@ -16,7 +13,7 @@ from django.urls import reverse
import redis import redis
from defender.data import get_approx_account_lockouts_from_login_attempts from defender.data import get_approx_account_lockouts_from_login_attempts, get_approx_lockouts_cache_key
from . import utils from . import utils
from . import config from . import config
@ -26,7 +23,7 @@ from .signals import (
username_block as username_block_signal, username_block as username_block_signal,
username_unblock as username_unblock_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 .decorators import watch_login
from .models import AccessAttempt from .models import AccessAttempt
from .test import DefenderTestCase, DefenderTransactionTestCase from .test import DefenderTestCase, DefenderTransactionTestCase
@ -478,74 +475,6 @@ class AccessAttemptTest(DefenderTestCase):
self.assertEqual(utils.is_valid_ip("::ffff:192.0.2.128"), True) self.assertEqual(utils.is_valid_ip("::ffff:192.0.2.128"), True)
self.assertEqual(utils.is_valid_ip("::ffff:8.8.8.8"), 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)
self.assertEqual(conf.get("USERNAME"), "user")
# 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)
self.assertEqual(conf.get("USERNAME"), "user")
# 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") @patch("defender.config.DEFENDER_REDIS_NAME", "default")
def test_get_redis_connection_django_conf(self): def test_get_redis_connection_django_conf(self):
""" get the redis connection """ """ get the redis connection """
@ -936,6 +865,41 @@ class AccessAttemptTest(DefenderTestCase):
data_out = utils.get_blocked_ips() data_out = utils.get_blocked_ips()
self.assertEqual(data_out, []) 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") @patch("defender.config.ATTEMPT_COOLOFF_TIME", "a")
def test_bad_attempt_cooloff_configuration(self): def test_bad_attempt_cooloff_configuration(self):
self.assertRaises(Exception) self.assertRaises(Exception)
@ -1001,6 +965,19 @@ class AccessAttemptTest(DefenderTestCase):
with self.assertRaises(Exception): with self.assertRaises(Exception):
get_approx_account_lockouts_from_login_attempts(username=VALID_USERNAME) 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): class SignalTest(DefenderTestCase):
""" Test that signals are properly sent when blocking usernames and IPs. """ Test that signals are properly sent when blocking usernames and IPs.
@ -1185,6 +1162,16 @@ class TestUtils(DefenderTestCase):
"defender:blocked:username:johndoe", "blocked:username:"), "defender:blocked:username:johndoe", "blocked:username:"),
"defender:blocked:username:johndoe") "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): class TestRedisConnection(TestCase):
""" Test the redis connection parsing """ """ Test the redis connection parsing """

View file

@ -12,7 +12,11 @@ from django.utils.module_loading import import_string
from .connection import get_redis_connection from .connection import get_redis_connection
from . import config 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 ( from .signals import (
send_username_block_signal, send_username_block_signal,
send_ip_block_signal, send_ip_block_signal,
@ -195,7 +199,7 @@ def increment_key(key):
def username_from_request(request): def username_from_request(request):
""" unloads username from default POST request """ """ unloads username from default POST request """
if config.USERNAME_FORM_FIELD in request.POST: 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 return None
@ -331,6 +335,10 @@ def unblock_ip(ip_address, pipe=None):
pipe.execute() pipe.execute()
send_ip_unblock_signal(ip_address) 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): def unblock_username(username, pipe=None):
""" unblock the given Username """ """ unblock the given Username """
@ -345,6 +353,10 @@ def unblock_username(username, pipe=None):
pipe.execute() pipe.execute()
send_username_unblock_signal(username) 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): def reset_failed_attempts(ip_address=None, username=None):
""" reset the failed attempts for these ip's and usernames """ reset the failed attempts for these ip's and usernames
@ -357,13 +369,16 @@ def reset_failed_attempts(ip_address=None, username=None):
unblock_ip(ip_address, pipe=pipe) unblock_ip(ip_address, pipe=pipe)
unblock_username(username, 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() pipe.execute()
def lockout_response(request): def lockout_response(request, username):
""" if we are locked out, here is the response """ """ if we are locked out, here is the response """
ip_address = get_ip(request) ip_address = get_ip(request)
username = get_username_from_request(request)
if config.LOCKOUT_TEMPLATE: if config.LOCKOUT_TEMPLATE:
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address, username=username) cooloff_time = get_lockout_cooloff_time(ip_address=ip_address, username=username)
context = { context = {

View file

@ -1,5 +1,6 @@
from django.shortcuts import render from django.shortcuts import render
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.contrib import admin
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.urls import reverse from django.urls import reverse
@ -13,10 +14,12 @@ def block_view(request):
blocked_ip_list = get_blocked_ips() blocked_ip_list = get_blocked_ips()
blocked_username_list = get_blocked_usernames() blocked_username_list = get_blocked_usernames()
context = { context = admin.site.index(request).context_data
context.update({
"blocked_ip_list": blocked_ip_list, "blocked_ip_list": blocked_ip_list,
"blocked_username_list": blocked_username_list, "blocked_username_list": blocked_username_list,
} "title": "Blocked logins",
})
return render(request, "defender/admin/blocks.html", context) return render(request, "defender/admin/blocks.html", context)

View file

@ -13,16 +13,24 @@
# import os # import os
# import sys # import sys
# sys.path.insert(0, os.path.abspath(".")) # 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 information -----------------------------------------------------
project = "django-defender" project = "django-defender"
copyright = "2014, Ken Cochrane" copyright = "2024, Ken Cochrane"
author = "Ken Cochrane" author = "Ken Cochrane"
# The full version, including alpha/beta/rc tags. # 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. # The short X.Y version.
version = ".".join(release.split(".")[:2]) version = ".".join(release.split(".")[:2])
@ -38,7 +46,7 @@ master_doc = "index"
extensions = [] extensions = []
# Add any paths that contain templates here, relative to this directory. # 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 # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # 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, # 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, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # 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 . -e .
coverage coverage
mockredispy mockredispy
django-redis-cache django-redis>=5,<6
redis>=5,<6
importlib-metadata<5.0 importlib-metadata<5.0
celery celery
sphinx_rtd_theme==2.0.0

View file

@ -34,16 +34,21 @@ setup(
"Framework :: Django :: 3.2", "Framework :: Django :: 3.2",
"Framework :: Django :: 4.0", "Framework :: Django :: 4.0",
"Framework :: Django :: 4.1", "Framework :: Django :: 4.1",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python", "Programming Language :: Python",
'Programming Language :: Python :: 3', "Programming Language :: Python :: 3",
'Programming Language :: Python :: 3 :: Only', "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
@ -59,12 +64,12 @@ setup(
include_package_data=True, include_package_data=True,
packages=find_packages(), packages=find_packages(),
package_data=get_package_data("defender"), package_data=get_package_data("defender"),
python_requires='~=3.7', python_requires="~=3.8",
install_requires=["Django", "redis"], install_requires=["Django", "redis>=4.0.0"],
tests_require=[ tests_require=[
"mockredispy>=2.9.0.11,<3.0", "mockredispy>=2.9.0.11,<3.0",
"coverage", "coverage",
"celery", "celery",
"django-redis-cache", "django-redis",
], ],
) )

28
tox.ini
View file

@ -1,24 +1,29 @@
[tox] [tox]
envlist = envlist =
# list of supported Django/Python versions: # list of supported Django/Python versions:
py{37,38,39,py3}-dj{32} py{38,39,py3}-dj{32}
py{38,39,310}-dj{40,41,main} py{38,39,310,311,312}-dj{42}
py38-{lint,docs} py{310,311,312}-dj{50,51,52,main}
py312-{lint,docs}
[gh-actions] [gh-actions]
python = python =
3.7: py37
3.8: py38 3.8: py38
3.9: py39 3.9: py39
3.10: py310 3.10: py310
3.11: py311
3.12: py312
pypy3: pypy3 pypy3: pypy3
[testenv] [testenv]
deps = deps =
setuptools
-rrequirements.txt -rrequirements.txt
dj32: django>=3.2,<4.0 dj32: django>=3.2,<4.0
dj40: django>=4.0,<4.1 dj42: django>=4.2,<5.0
dj41: django>=4.1,<4.2 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 djmain: https://github.com/django/django/archive/main.tar.gz
usedevelop = True usedevelop = True
commands = commands =
@ -30,19 +35,22 @@ ignore_outcome =
ignore_errors = ignore_errors =
djmain: True djmain: True
[testenv:py38-docs] [testenv:py312-docs]
basepython = python3.8 basepython = python3.12
deps = deps =
-rrequirements.txt -rrequirements.txt
Sphinx Sphinx
sphinx_rtd_theme sphinx_rtd_theme
setuptools
commands = sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html commands = sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html
[testenv:py38-lint] [testenv:py312-lint]
basepython = python3.8 basepython = python3.12
deps = deps =
twine twine
check-manifest check-manifest
setuptools
setuptools_scm
commands = commands =
check-manifest -v check-manifest -v
python setup.py sdist python setup.py sdist