Compare commits

..

73 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
Ken Cochrane
ac36751561 bump version to 0.9.6 2022-11-29 08:21:40 -05:00
djmore4
73d442e31b More updates after feedback from a colleage 2022-11-13 11:09:53 -05:00
djmore4
3e9d68dd5c Initial README.rst update after @kencochrane's comment 2022-11-13 11:09:53 -05:00
djmore4
afa2066ba0 Add pragma: no cover comments even though some of this stuff is covered... 2022-11-13 11:09:53 -05:00
djmore4
374971bfc5 Remove incorrect tests 2022-11-13 11:09:53 -05:00
djmore4
5139005106 Fix test name and correct logic in data.py 2022-11-13 11:09:53 -05:00
djmore4
359ee90082 I think we're finally done here 2022-11-13 11:09:53 -05:00
djmore4
b9b067472c Whoops, they worked I guess. Let's see if they still do or we need more changes. Also improve coverage 2022-11-13 11:09:53 -05:00
djmore4
de1c876b99 Using assertEquals and an exception to test where the logic is going wrong 2022-11-13 11:09:53 -05:00
djmore4
717d44aa7d Update README further and make another adjustment to the tests 2022-11-13 11:09:53 -05:00
djmore4
1bf9d6e7d1 Refactor once again 2022-11-13 11:09:53 -05:00
djmore4
a280c90bc0 Refactor once again 2022-11-13 11:09:53 -05:00
djmore4
7121db4b0f another different approach 2022-11-13 11:09:53 -05:00
djmore4
be523281ab Hopefully this clears up the issues in the tests 2022-11-13 11:09:53 -05:00
djmore4
2d288b247a Try some new things 2022-11-13 11:09:53 -05:00
djmore4
5db32ed0be Add importlib-metadata<5.0 to the requirements.txt 2022-11-13 11:09:53 -05:00
djmore4
bbe7687abd Added changes and fixed small bug 2022-11-13 11:09:53 -05:00
djmore4
177f2ecce8 Add new config options and update logic/tests to account for them 2022-11-13 11:09:53 -05:00
Hasan Ramezani
dffa7c3ba3 Confirm support for Django 4.1 2022-08-27 07:42:29 -04:00
Dashgin
7bb1359514 Change order of defender urls in documentation and example app 2022-06-19 18:47:55 -04:00
Aleksi Häkli
0a5011d450
Version 0.9.5 2022-06-06 13:17:35 +03:00
cbh
bb122f24b9 add username option to redis config 2022-06-06 13:15:55 +03:00
Jona Andersen
0b96c53245 Bump to 0.9.4 2022-05-01 16:09:52 -04:00
Jona Andersen
44ecbee250 Strip port number from IP address in X-Forwarded-For 2022-05-01 14:22:28 -04:00
hashlash
45c4575ccd Revert PyPy version pinpoint 2022-04-23 08:01:39 -04:00
Aleksi Häkli
b646e10e6c
Version 0.9.3 2022-04-18 15:52:02 +03:00
Aleksi Häkli
f812343491
Deprecate Python 3.6 support from package specifiers 2022-04-18 15:51:26 +03:00
Aleksi Häkli
8fa63e864f Version 0.9.2 2022-04-17 13:00:08 +03:00
Aleksi Häkli
bc161ff518
Deprecate Python 3.6 support 2022-04-15 19:28:16 +03:00
Aleksi Häkli
65f2c2fe3f
Deprecate Python 3.6 support 2022-04-15 19:25:00 +03:00
Aleksi Häkli
3c7ccc4e99
Deprecate Python 3.6 support 2022-04-15 19:23:05 +03:00
Aleksi Häkli
38fd8b6f16
Deprecate Python 3.6 support 2022-04-15 19:22:28 +03:00
Hasan Ramezani
95ccba251d Remove Python3.5 from setup classifiers 2022-04-13 17:46:42 +03:00
Hasan Ramezani
fdf37feb54 Drop Django 2.2 support 2022-04-13 17:46:42 +03:00
hashlash
6f806b046a Fix password quotation docs
- The `quote` function is inside `urllib.parse` module, not it's parent `urllib` module.
- Use `quote` instead of `quote_plus` since the parsing in `defender.connections` use `unquote` instead of `unquote_plus`.
2022-03-02 21:13:52 -05:00
hashlash
ef3e9869c2 Fix admin url example docs 2022-03-02 21:13:17 -05:00
Thomas J Bradley
41a68f2f71 Update README with password reset example
Show an example of how to adapt Django Defender for preventing brute-force submissions against the password-reset view.

- Logs submissions against the email address, instead of the default username, because email address is the only thing always available for the whole process.
- From: https://github.com/jazzband/django-defender/issues/204
2022-03-02 13:42:20 -05:00
hashlash
41d2f6aab7 Pinpoint PyPy version 2022-03-02 06:37:01 -05:00
hashlash
b90e545d20 Fix nested list in DEFENDER_LOCKOUT_TEMPLATE 2022-02-20 08:02:27 -05:00
Hasan Ramezani
43e6fcdf13 Confirm support for Python 3.10. 2021-12-16 16:40:44 -05:00
Hasan Ramezani
079d78bee3 Confirm support Django 4.0. 2021-12-16 16:40:44 -05:00
Hasan Ramezani
7c544d1cf8 Drop Django 3.1 support. 2021-12-16 16:40:44 -05:00
Williams Mendez
006ecf1dff Update exampleapp to use new routing/templates settings
The exampleapp is still running deprecated code.
2021-12-14 13:01:43 +01:00
Williams Mendez
c8a2586892 Define AppConfig.default_auto_field to match the initial migration
This patch removes a warning but also prevents creating migrations in projects
where DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField".
2021-12-13 21:00:46 +01:00
Hugo van Kemenade
78302f6b35 CI: Replace deprecated pypy3 with pypy-3.8
pypy3 is deprecated and is not available in newer images:
https://github.com/actions/setup-python/issues/244#issuecomment-925966022

Instead explicitly specify the version:
https://github.com/actions/setup-python#specifying-a-pypy-version

Committed via https://github.com/asottile/all-repos
2021-12-13 20:43:00 +01:00
Jazzband Bot
70c549dd2c
Jazzband: Synced file(s) with jazzband/.github (#193)
* Jazzband: Created local 'CODE_OF_CONDUCT.md' from remote 'CODE_OF_CONDUCT.md'

* Add code of conduct to package manifest template.

Co-authored-by: Jannis Leidel <jannis@leidel.info>
2021-10-23 00:25:31 +02:00
Hasan Ramezani
f358e06a53 Add pre-commit basic config. 2021-10-13 21:47:34 +03:30
Hasan Ramezani
55ab5c6961 Replace assertEquals with assertEqual. 2021-10-13 21:47:34 +03:30
Ryan Bales
e5edaf3b5d bugfix for IP backdoor to DEFENDER_LOCK_OUT_BY_IP_AND_USERNAME 2021-08-01 15:19:34 -04:00
27 changed files with 803 additions and 222 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.6', '3.7', '3.8', '3.9', 'pypy3']
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 }}

1
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1 @@
repos: []

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,55 @@
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
-----
- Drop Python 3.6 support from package specifiers.
0.9.2
-----
- Drop Python 3.6 support.
- Drop Django 3.1 support.
- Confirm support for Django 4.0
- Confirm support for Python 3.10
- Drop Django 2.2 support.
0.9.1
-----

46
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,46 @@
# Code of Conduct
As contributors and maintainers of the Jazzband projects, and in the interest of
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating documentation,
submitting pull requests or patches, and other activities.
We are committed to making participation in the Jazzband a harassment-free experience
for everyone, regardless of the level of experience, gender, gender identity and
expression, sexual orientation, disability, personal appearance, body size, race,
ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery
- Personal attacks
- Trolling or insulting/derogatory comments
- Public or private harassment
- Publishing other's private information, such as physical or electronic addresses,
without explicit permission
- Other unethical or unprofessional conduct
The Jazzband roadies have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are not
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
By adopting this Code of Conduct, the roadies commit themselves to fairly and
consistently applying these principles to every aspect of managing the jazzband
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
removed from the Jazzband roadies.
This code of conduct applies both within project spaces and in public spaces when an
individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
investigated and will result in a response that is deemed necessary and appropriate to
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
reporter of an incident.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
[homepage]: https://contributor-covenant.org
[version]: https://contributor-covenant.org/version/1/3/0/

View file

@ -3,8 +3,11 @@ include CHANGES.rst
include CONTRIBUTING.rst
include LICENSE
include README.rst
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.6, 3.7, 3.8, 3.9, PyPy
* Django: 2.2, 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/', include(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,12 +350,26 @@ 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
* ``cooloff_time_minutes``\ : The cool off time in minutes
* ``failure_limit``\ : The number of failures before you get blocked.
* ``DEFENDER_USERNAME_FORM_FIELD``\ : String: the name of the form field that contains your
users usernames. [Default: ``username``\ ]
* ``DEFENDER_CACHE_PREFIX``\ : String: The cache prefix for your defender keys.
@ -365,9 +379,9 @@ These should be defined in your ``settings.py`` file.
* ``DEFENDER_REDIS_URL``\ : String: the redis url for defender.
[Default: ``redis://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.quote_plus("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``\ ]
* ``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
@ -384,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
----------------------------------------
@ -432,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))
@ -486,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
@ -518,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))
@ -650,6 +698,69 @@ For example, in your settings.py add the below line,
'LOGIN_SERIALIZER': '<path to your basic authentication defender python file>.BasicAuthenticationDefender',
}
Adapting for password reset forms
---------------------------------
``defender`` can be adapted for Djangos ``PasswordResetView`` to prevent too many submissions.
We need to create some new views that subclass Djangos built-in ``LoginView``, ``PasswordResetView`` & ``PasswordResetConfirmView``  then use these views in our ``urls.py`` as replacements for Djangos built-ins.
The views block based on email address submitted on the password reset view. This is different than the default implementation (which uses username), so we have to be careful to clean up after ourselves on sign-in & completed password reset.
.. code-block:: python
from defender import utils as def_utils
from django.contrib.auth import views as auth_views
class UserSignIn(auth_views.LoginView):
def form_valid(self, form):
"""Force clear all the cached Defender statues for the authenticated users email address."""
super_valid = super().form_valid(form)
def_utils.check_request(self.request, False, username=form.get_user().email)
return super_valid
class PasswordResetBruteForceProtectedView(auth_views.PasswordResetView):
def get(self, request, *args, **kwargs):
"""Confirm the user isnt already blocked by IP before showing the password reset view."""
if def_utils.is_already_locked(request):
return def_utils.lockout_response(request)
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""
Confirm the user isnt 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):
return def_utils.lockout_response(request)
def_utils.check_request(
request, login_unsuccessful=True, username=request.POST.get("email")
)
return super().post(request, *args, **kwargs)
class PasswordResetConfirmBruceForceProtectedView(auth_views.PasswordResetConfirmView):
def get(self, request, *args, **kwargs):
"""Confirm the user isnt already blocked by IP before showing the password confirm view."""
if def_utils.is_already_locked(request):
return def_utils.lockout_response(request)
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""Confirm the user isnt already blocked by IP before allowing form POST for the password change confirmation."""
if def_utils.is_already_locked(request):
return def_utils.lockout_response(request)
return super().post(request, *args, **kwargs)
def form_valid(self, form):
"""Force clear all the cached Defender statues for the users email address after successfully changing their password."""
super_valid = super().form_valid(form)
def_utils.check_request(
self.request, login_unsuccessful=False, username=self.user.email
)
return super_valid
Django signals
--------------

View file

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

6
defender/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class DefenderAppConfig(AppConfig):
name = "defender"
default_auto_field = "django.db.models.AutoField"

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
@ -45,12 +45,11 @@ TEMPLATES = [
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.request",
],
},
},
]
if django.VERSION > (3, 1):
TEMPLATES[0]["OPTIONS"]["context_processors"].append("django.template.context_processors.request")
SECRET_KEY = os.environ.get("SECRET_KEY", "too-secret-for-test")

View file

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

View file

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

View file

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

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

@ -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"
@ -41,12 +42,11 @@ TEMPLATES = [
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.request",
],
},
},
]
if django.VERSION > (3, 1):
TEMPLATES[0]["OPTIONS"]["context_processors"].append("django.template.context_processors.request")
SECRET_KEY = os.environ.get("SECRET_KEY", "too-secret-for-test")

View file

@ -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
@ -279,14 +283,14 @@ class AccessAttemptTest(DefenderTestCase):
"""
Test that the lowercase(None) returns None.
"""
self.assertEquals(utils.lower_username(None), None)
self.assertEqual(utils.lower_username(None), None)
def test_cooling_off(self):
""" Tests if the cooling time allows a user to login
"""
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 """
@ -719,6 +658,14 @@ class AccessAttemptTest(DefenderTestCase):
response = self._login()
self.assertContains(response, LOGIN_FORM_KEY)
# Successful login should not clear IP lock
self._login(username=VALID_USERNAME, password=VALID_PASSWORD)
# We should still be locked out for the locked
# username using the same IP
response = self._login(username=username)
self.assertContains(response, self.LOCKED_MESSAGE)
# We shouldn't get a lockout message when attempting to use a
# different ip address
ip = "74.125.239.60"
@ -918,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.
@ -1067,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')

View file

@ -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,24 +353,37 @@ 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
"""
pipe = REDIS_SERVER.pipeline()
unblock_ip(ip_address, pipe=pipe)
# Because IP is shared, a reset should never clear an IP block
# when using IP/username as block
if not config.LOCKOUT_BY_IP_USERNAME:
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)
@ -302,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."
)

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

@ -28,6 +28,7 @@ MIDDLEWARE = (
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"defender.middleware.FailedLoginMiddleware",
"django.contrib.messages.middleware.MessageMiddleware"
)
ROOT_URLCONF = "exampleapp.urls"
@ -80,3 +81,18 @@ app.config_from_object("django.conf:settings")
app.autodiscover_tasks(lambda: INSTALLED_APPS)
DEBUG = True
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]

View file

@ -1,4 +1,4 @@
from django.conf.urls import patterns, include
from django.urls import include, re_path
from django.conf import settings
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
@ -6,11 +6,10 @@ from django.conf.urls.static import static
admin.autodiscover()
urlpatterns = patterns(
"",
(r"^admin/", include(admin.site.urls)),
(r"^admin/defender/", include("defender.urls")),
)
urlpatterns = [
re_path(r"^admin/defender/", include("defender.urls")),
re_path(r"^admin/", admin.site.urls),
]
urlpatterns += staticfiles_urlpatterns()

View file

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

View file

@ -31,20 +31,24 @@ setup(
classifiers=[
"Development Status :: 5 - Production/Stable",
"Framework :: Django",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.1",
"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.5",
"Programming Language :: Python :: 3.6",
"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",
@ -60,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",
],
)

32
tox.ini
View file

@ -1,24 +1,29 @@
[tox]
envlist =
# list of supported Django/Python versions:
py{36,37,38,39,py3}-dj{22,31,32}
py{38,39}-djmain
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.6: py36
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
dj22: django>=2.2,<2.3
dj31: django>=3.1,<3.2
dj32: django>=3.2,<3.3
dj32: django>=3.2,<4.0
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