Compare commits

...

98 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
Ken Cochrane
ab6c1a6358 fixed bad django 4.0 classifier 2021-07-18 09:58:34 -04:00
Ken Cochrane
5d625dd273 release version 0.9.1 2021-07-18 09:31:51 -04:00
Jonathan Willitts
5f5b34d50e Fix failing tests to work with Django (4.0) main dev branch 2021-04-29 06:50:36 -04:00
Ken Cochrane
d413e60847 released version 0.9.0 2021-04-25 19:36:10 -04:00
Hasan Ramezani
7eb24c5036 Add support for Django 3.2 2021-04-07 20:16:02 +04:30
Hasan Ramezani
04252ba415 Drop support for Django 3.0. 2021-04-07 20:16:02 +04:30
Jannis Leidel
743b538263 Don't run Django main test on Python < 3.8. 2021-03-10 11:47:32 +02:00
Jannis Leidel
9ff7f8e9fc Don't run Django main test on Python < 3.8. 2021-03-10 11:47:32 +02:00
Jannis Leidel
e762d154ed Rename Django's dev branch to main.
More information: https://groups.google.com/g/django-developers/c/tctDuKUGosc/
Refs: https://github.com/django/django/pull/14048
2021-03-10 11:47:32 +02:00
Jannis Leidel
b4a5f886d4
Migrate to GitHub Actions. (#174)
* Add GitHub Actions test workflow.

* Add release workflow.

* Remove Travis cruft and update changelog.

* Fix deps.

* Remove Celery version restriction.

* Relax installation requirements a bit.

* Update README.rst

* Update tox.ini
2020-12-21 14:18:53 +01:00
Hasan Ramezani
9a322c4729 Add support for Python 3.9 2020-11-27 16:32:46 +01:00
Hasan Ramezani
cc06ab33fd Drop support Django < 2.2 and add support for Django > 3 2020-11-27 16:32:46 +01:00
Hasan Ramezani
5e6e52fcbb Drop Python2.7 support. 2020-11-23 17:30:46 +01:00
Hasan Ramezani
1f9b3dec7c Fix version. 2020-11-23 09:08:40 +01:00
Kalil de Lima
cbe59ddb7b
Merge pull request #169 from kaozdl/fix/redis_password_quote
FIX: make password quote optional in the funciton signature
2020-10-27 16:53:42 -03:00
Jorge
7124e4b156 make password quote optional in the funciton signature 2020-10-27 12:35:39 -03:00
Ken Cochrane
7064c9b012 Added changelog for 0.8.0 2020-10-17 09:59:24 -04:00
Martin Braun
1366adc59f Change setup.py to allow for Django 3.1.x versions 2020-10-16 15:22:10 -04:00
Mohammed Jasir
0ad3690e6f Update README.rst
Added code snippet in order to incorporate defender lockout mechanism with the combination of django-rest-auth module and djangorestframework.
2020-10-16 15:20:25 -04:00
Ken Cochrane
621b78c5d5 Fix Django-redis-cache version
The django-redis-cache version 2.1.3 is broken in pypi, let's roll back to 2.1.1 until it is fixed.
2020-10-16 15:19:31 -04:00
Ivan Pereira
6354df0a8a
dynamic load celery (#163) 2020-09-04 15:11:19 -04:00
Ken Cochrane
df0b9db26f
Updated README to show support for Django 3
Updated README to show support for Django 3
2020-08-09 20:16:36 -04:00
Flavio Martins
4fcee56ff2
Fix redis requirement (#160)
Allow redis 3.5.*
2020-06-19 06:51:12 -04:00
Fabrício Rissetto
426f01a39e Update readme 2020-03-22 17:21:09 -04:00
calmkart
71312eb841
FIX: support for special character in redis password(such like '@') (#155)
* FIX: if special character in redis password, we can set DEFENDER_REDIS_PASSWORD_QUOTE to True, and use quote password

* MOD:add test cases with password_quota = True
2020-03-13 08:13:54 -04:00
40 changed files with 1174 additions and 429 deletions

40
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Release
on:
push:
tags:
- '*'
jobs:
build:
if: github.repository == 'jazzband/django-defender'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.8
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -U setuptools twine wheel
- name: Build package
run: |
python setup.py --version
python setup.py sdist --format=gztar bdist_wheel
twine check dist/*
- name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
repository_url: https://jazzband.co/projects/django-defender/upload

54
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
redis-version: [5, 6, 7]
steps:
- uses: actions/checkout@v3
- name: Start Redis
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@v4
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
echo "::set-output name=dir::$(pip cache dir)"
- name: Cache
uses: actions/cache@v3
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }}
restore-keys: |
${{ matrix.python-version }}-v1-
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
- name: Tox tests
run: |
tox -v
- name: Upload coverage
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

@ -1,28 +0,0 @@
language: python
dist: xenial
cache: pip
python:
- '2.7'
- '3.5'
- '3.6'
- '3.7'
- '3.8'
- pypy3
services:
- redis-server
install: pip install tox-travis codecov
script: tox
after_success:
- codecov
deploy:
provider: pypi
user: jazzband
server: https://jazzband.co/projects/django-defender/upload
distributions: sdist bdist_wheel
skip_existing: true
password:
secure: d/PVFgBDMpG6UvBOxXBLH4vc7+tiPsjhq4YLgXfX0iXbdNHUnnCyDLg/XUQ7x0e7kRj+lhmBzytZxu3IpOY+5MuWk4JTqwoBHGuUlmTC9XEVvioqd/nFW7qVe36YKZHKuMDlmwBNQarJZdSBUFAoCfklZKI09sXA1qc8E3BLAb8=
on:
tags: true
repo: jazzband/django-defender
python: 3.7

View file

@ -2,6 +2,74 @@
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
-----
- Fix failing tests for Django main development branch (Django 4.0) [@JonathanWillitts]
0.9.0
-----
- Move CI to GitHub Actions.
- Drop support for Django 3.0
- Add support for Django 3.2
0.8.0
-----
- FIX: Change setup.py to allow for Django 3.1.x versions [@s4ke]
- FIX: dynamic load celery [@balsagoth]
- FIX: Redis requirement updated [@flaviomartins]
- FIX: if special character in redis password, we can set DEFENDER_REDIS_PASSWORD_QUOTE to True, and use quote password [@calmkart]
0.7.0
-----

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

@ -6,15 +6,23 @@ django-defender
:target: https://jazzband.co/
:alt: Jazzband
.. image:: https://travis-ci.org/jazzband/django-defender.svg
:target: https://travis-ci.org/jazzband/django-defender
:alt: Build Status
.. image:: https://img.shields.io/pypi/pyversions/django-defender.svg
:alt: Supported Python versions
:target: https://pypi.org/project/django-defender/
.. image:: https://img.shields.io/pypi/djversions/django-defender.svg
:target: https://pypi.org/project/django-defender/
:alt: Supported Django versions
.. image:: https://github.com/jazzband/django-defender/workflows/Test/badge.svg
:target: https://github.com/jazzband/django-defender/actions
:alt: GitHub Actions
.. image:: https://codecov.io/gh/jazzband/django-defender/branch/master/graph/badge.svg
:target: https://codecov.io/gh/jazzband/django-defender
:alt: Coverage
.. image:: https://readthedocs.org/projects/python-dockerflow/badge/?version=latest
.. image:: https://readthedocs.org/projects/django-defender/badge/?version=latest
:alt: Documentation Status
:target: https://django-defender.readthedocs.io/en/latest/?badge=latest
@ -100,9 +108,9 @@ Admin pages
Requirements
------------
* Python: 2.7, 3.5, 3.6, 3.7, 3.8, PyPy
* Django: 1.11, 2.1, 2.2
* 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
@ -161,8 +169,8 @@ following to your ``urls.py``
.. code-block:: python
urlpatterns = [
url(r'^admin/', include(admin.site.urls)), # normal admin
url(r'^admin/defender/', include('defender.urls')), # defender admin
path('admin/defender/', include('defender.urls')), # defender admin
path('admin/', admin.site.urls), # normal admin
# your own patterns follow...
]
@ -342,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.
@ -357,7 +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_NAME``\ : String: the name of your cache client on the CACHES django setting. If set, ``DEFENDER_REDIS_URL`` will be ignored.
* ``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 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
@ -374,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
----------------------------------------
@ -387,7 +443,7 @@ There's sample ``BasicAuthenticationDefender`` class based on ``djangorestframew
import base64
import binascii
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from rest_framework import HTTP_HEADER_ENCODING, exceptions
from rest_framework.authentication import (
@ -422,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))
@ -444,17 +502,264 @@ There's sample ``BasicAuthenticationDefender`` class based on ``djangorestframew
utils.add_login_attempt_to_db(request,
login_valid=not login_unsuccessful,
get_username=self.get_username_from_request)
# add the failed attempt to Redis in case of a failed login or resets the attempt count in case of success
utils.check_request(request,
login_unsuccessful=login_unsuccessful,
get_username=self.get_username_from_request)
if login_unsuccessful:
raise login_exception
user_not_blocked = utils.check_request(request,
login_unsuccessful=login_unsuccessful,
get_username=self.get_username_from_request)
if user_not_blocked and not login_unsuccessful:
return response
raise login_exception
return response
To make it work add ``BasicAuthenticationDefender`` to ``DEFAULT_AUTHENTICATION_CLASSES`` above all other authentication methods in your ``settings.py``.
Adapting to other authentication methods :- django-rest-auth in djangorestframework
------------------------------------------------------------------------------------
``defender`` can be incorporated with the combination of ``django-rest-framework`` and ``django-rest-auth`` which can be used to authenticate users.
Reference
**********
* https://www.django-rest-framework.org/
* https://django-rest-auth.readthedocs.io/en/latest/
Below is a sample ``BasicAuthenticationDefender`` class based on ``rest_framework.authentication.TokenAuthentication`` which uses ``django-rest-auth`` library for user authentication.
.. code-block:: python
import base64
import binascii
from django.conf import settings
from django.contrib.auth import get_user_model, authenticate
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 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
from defender import config
from rest_framework.authentication import (
get_authorization_header,
)
# Get the UserModel
UserModel = get_user_model()
class BasicAuthenticationDefender(serializers.Serializer):
username = serializers.CharField(required=False, allow_blank=True)
email = serializers.EmailField(required=False, allow_blank=True)
password = serializers.CharField(style={'input_type': 'password'})
def authenticate(self, **kwargs):
request = self.context['request']
if hasattr(settings, 'ACCOUNT_AUTHENTICATION_METHOD'):
login_field = settings.ACCOUNT_AUTHENTICATION_METHOD
else:
login_field = 'username'
userid = self.username_from_request(request, login_field)
if defender_utils.is_already_locked(request, username=userid):
detail = "You have attempted to login {failure_limit} times with no success. "
.format(
failure_limit=config.FAILURE_LIMIT,
cooloff_time_seconds=config.LOCKOUT_COOLOFF_TIME[defender_utils.get_lockout_cooloff_time(username=userid)]
)
raise exceptions.AuthenticationFailed(_(detail))
login_unsuccessful = False
login_exception = None
try:
response = authenticate(request, **kwargs)
if response == None:
login_unsuccessful = True
msg = _('Unable to log in with provided credentials.')
# raise exceptions.ValidationError(msg)
login_exception = exceptions.ValidationError(msg)
except exceptions.AuthenticationFailed as e:
login_unsuccessful = True
login_exception = e
defender_utils.add_login_attempt_to_db(request,
login_valid=not login_unsuccessful,
username=userid)
user_not_blocked = defender_utils.check_request(request,
login_unsuccessful=login_unsuccessful,
username=userid)
if user_not_blocked and not login_unsuccessful:
return response
raise login_exception
def _validate_email(self, email, password):
user = None
if email and password:
user = self.authenticate(email=email, password=password)
else:
msg = _('Must include "email" and "password".')
raise exceptions.ValidationError(msg)
return user
def _validate_username(self, username, password):
user = None
if username and password:
user = self.authenticate(username=username, password=password)
else:
msg = _('Must include "username" and "password".')
raise exceptions.ValidationError(msg)
return user
def _validate_username_email(self, username, email, password):
user = None
if email and password:
user = self.authenticate(email=email, password=password)
elif username and password:
user = self.authenticate(username=username, password=password)
else:
msg = _('Must include either "username" or "email" and "password".')
raise exceptions.ValidationError(msg)
return user
def validate(self, attrs):
username = attrs.get('username')
email = attrs.get('email')
password = attrs.get('password')
user = None
if 'allauth' in settings.INSTALLED_APPS:
from allauth.account import app_settings
# Authentication through email
if app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL:
user = self._validate_email(email, password)
# Authentication through username
elif app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.USERNAME:
user = self._validate_username(username, password)
# Authentication through either username or email
else:
user = self._validate_username_email(username, email, password)
else:
# Authentication without using allauth
if email:
try:
username = UserModel.objects.get(
email__iexact=email).username()
except UserModel.DoesNotExist:
pass
if username:
user = self._validate_username_email(username, '', password)
# Did we get back an active user?
if user:
if not user.is_active:
msg = _('User account is disabled.')
raise exceptions.ValidationError(msg)
else:
msg = _('Unable to log in with provided credentials.')
raise exceptions.ValidationError(msg)
# If required, is the email verified?
if 'rest_auth.registration' in settings.INSTALLED_APPS:
from allauth.account import app_settings
if app_settings.EMAIL_VERIFICATION == app_settings.EmailVerificationMethod.MANDATORY:
email_address = user.emailaddress_set.get(email=user.email)
if not email_address.verified:
raise serializers.ValidationError(
_('E-mail is not verified.'))
attrs['user'] = user
return attrs
def username_from_request(self, request, login_field):
user_data = request._data
return user_data[login_field]
To make it work add ``BasicAuthenticationDefender`` to ``REST_AUTH_SERIALIZERS`` dictionary in your ``settings.py`` under the key ``LOGIN_SERIALIZER``.
For example, in your settings.py add the below line,
.. code-block:: python
REST_AUTH_SERIALIZERS = {
'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
--------------
@ -484,13 +789,13 @@ like:
.. code-block:: bash
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py test defender --settings=defender.test_settings
PYTHONPATH=$PYTHONPATH:$PWD django-admin test defender --settings=defender.test_settings
With Code coverage:
.. code-block:: bash
PYTHONPATH=$PYTHONPATH:$PWD coverage run --source=defender $(which django-admin.py) test defender --settings=defender.test_settings
PYTHONPATH=$PYTHONPATH:$PWD coverage run --source=defender $(which django-admin) test defender --settings=defender.test_settings
Releasing

View file

@ -1,3 +1,3 @@
VERSION = (0, 7, 0)
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

@ -1,11 +1,13 @@
import os
import django
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
@ -43,6 +45,7 @@ TEMPLATES = [
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.request",
],
},
},
@ -55,7 +58,7 @@ LOGIN_REDIRECT_URL = "/admin"
DEFENDER_LOGIN_FAILURE_LIMIT = 10
DEFENDER_COOLOFF_TIME = 2
DEFENDER_REDIS_URL = "redis://localhost:6379/1"
# don't use mock redis in unit tests, we will use real redis on travis.
# don't use mock redis in unit tests, we will use real redis on CI.
DEFENDER_MOCK_REDIS = False
# Celery settings:
@ -64,7 +67,7 @@ BROKER_BACKEND = "memory"
BROKER_URL = "memory://"
# set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "defender.travis_settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "defender.ci_settings")
app = Celery("defender")

View file

@ -1,5 +1,5 @@
from django.conf import settings
from django.utils.translation import ugettext_lazy
from django.utils.translation import gettext_lazy
def get_setting(variable, default=None):
@ -11,6 +11,9 @@ def get_setting(variable, default=None):
# redis server host
DEFENDER_REDIS_URL = get_setting("DEFENDER_REDIS_URL")
# redis password quote for special character
DEFENDER_REDIS_PASSWORD_QUOTE = get_setting("DEFENDER_REDIS_PASSWORD_QUOTE", False)
# reuse declared cache from django settings
DEFENDER_REDIS_NAME = get_setting("DEFENDER_REDIS_NAME")
@ -51,14 +54,37 @@ 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
LOCKOUT_TEMPLATE = get_setting("DEFENDER_LOCKOUT_TEMPLATE")
ERROR_MESSAGE = ugettext_lazy(
ERROR_MESSAGE = gettext_lazy(
"Please enter a correct username and password. "
"Note that both fields are case-sensitive."
)

View file

@ -1,12 +1,9 @@
from django.core.cache import caches
from django.core.cache.backends.base import InvalidCacheBackendError
import urllib.parse as urlparse
import redis
try:
import urlparse
except ImportError: # pragma: no cover
import urllib.parse as urlparse # pragma: no cover # Python3 # pylint: disable=import-error,no-name-in-module,line-too-long
from django.core.cache import caches
from django.core.cache.backends.base import InvalidCacheBackendError
from . import config
@ -34,46 +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)
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):
"""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:
redis_config.update({"PASSWORD": url.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,4 +1,3 @@
from __future__ import print_function
from datetime import timedelta
from django.core.management.base import BaseCommand

View file

@ -1,33 +1,27 @@
try:
from django.utils.deprecation import MiddlewareMixin as MIDDLEWARE_BASE_CLASS
except ImportError:
MIDDLEWARE_BASE_CLASS = object
from django.contrib.auth import views as auth_views
from django.contrib.auth.views import LoginView
from django.utils.decorators import method_decorator
from .decorators import watch_login
class FailedLoginMiddleware(MIDDLEWARE_BASE_CLASS):
class FailedLoginMiddleware:
""" Failed login middleware """
patched = False
def __init__(self, *args, **kwargs):
super(FailedLoginMiddleware, self).__init__(*args, **kwargs)
def __init__(self, get_response):
self.get_response = get_response
# Watch the auth login.
# Monkey-patch only once - otherwise we would be recording
# failed attempts multiple times!
if not FailedLoginMiddleware.patched:
# Django 1.11 turned the `login` function view into the
# `LoginView` class-based view
try:
from django.contrib.auth.views import LoginView
our_decorator = watch_login()
watch_login_method = method_decorator(our_decorator)
LoginView.dispatch = watch_login_method(LoginView.dispatch)
except ImportError: # Django < 1.11
auth_views.login = watch_login()(auth_views.login)
our_decorator = watch_login()
watch_login_method = method_decorator(our_decorator)
LoginView.dispatch = watch_login_method(LoginView.dispatch)
FailedLoginMiddleware.patched = True
def __call__(self, request):
response = self.get_response(request)
return response

View file

@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations

View file

@ -1,16 +1,6 @@
from __future__ import unicode_literals
import django
from django.db import models
if django.VERSION[0] < 3:
from django.utils.encoding import python_2_unicode_compatible
else:
# noop stub
def python_2_unicode_compatible(cls):
return cls
@python_2_unicode_compatible
class AccessAttempt(models.Model):
""" Access Attempt log """

View file

@ -1,9 +1,9 @@
from django.dispatch import Signal
username_block = Signal(providing_args=["username"])
username_unblock = Signal(providing_args=["username"])
ip_block = Signal(providing_args=["ip_address"])
ip_unblock = Signal(providing_args=["ip_address"])
username_block = Signal() # (providing_args=["username"])
username_unblock = Signal() # (providing_args=["username"])
ip_block = Signal() # (providing_args=["ip_address"])
ip_unblock = Signal() # (providing_args=["ip_address"])
class BlockSignal:

View file

@ -1,101 +0,0 @@
# -*- coding: utf-8 -*-
from south.utils import datetime_utils as datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
"""Initial Migration for Defender"""
def forwards(self, orm):
""" Adding model 'AccessAttempt' """
db.create_table(
"defender_accessattempt",
(
("id", self.gf("django.db.models.fields.AutoField")(primary_key=True)),
(
"user_agent",
self.gf("django.db.models.fields.CharField")(max_length=255),
),
(
"ip_address",
self.gf("django.db.models.fields.GenericIPAddressField")(
max_length=39, null=True
),
),
(
"username",
self.gf("django.db.models.fields.CharField")(
max_length=255, null=True
),
),
(
"http_accept",
self.gf("django.db.models.fields.CharField")(max_length=1025),
),
(
"path_info",
self.gf("django.db.models.fields.CharField")(max_length=255),
),
(
"attempt_time",
self.gf("django.db.models.fields.DateTimeField")(
auto_now_add=True, blank=True
),
),
(
"login_valid",
self.gf("django.db.models.fields.BooleanField")(default=False),
),
),
)
db.send_create_signal("defender", ["AccessAttempt"])
def backwards(self, orm):
# Deleting model 'AccessAttempt'
db.delete_table("defender_accessattempt")
models = {
"defender.accessattempt": {
"Meta": {"ordering": "[u'-attempt_time']", "object_name": "AccessAttempt"},
"attempt_time": (
"django.db.models.fields.DateTimeField",
[],
{"auto_now_add": "True", "blank": "True"},
),
"http_accept": (
"django.db.models.fields.CharField",
[],
{"max_length": "1025"},
),
"id": ("django.db.models.fields.AutoField", [], {"primary_key": "True"}),
"ip_address": (
"django.db.models.fields.GenericIPAddressField",
[],
{"max_length": "39", "null": "True"},
),
"login_valid": (
"django.db.models.fields.BooleanField",
[],
{"default": "False"},
),
"path_info": (
"django.db.models.fields.CharField",
[],
{"max_length": "255"},
),
"user_agent": (
"django.db.models.fields.CharField",
[],
{"max_length": "255"},
),
"username": (
"django.db.models.fields.CharField",
[],
{"max_length": "255", "null": "True"},
),
}
}
complete_apps = ["defender"]

View file

@ -1,12 +1,7 @@
from .data import store_login_attempt
# not sure how to get this to look better. ideally we want to dynamically
# apply the celery decorator based on the USE_CELERY setting.
from celery import shared_task
from . import config
@shared_task()
def add_login_attempt_task(
user_agent, ip_address, username, http_accept, path_info, login_valid
):
@ -14,3 +9,7 @@ def add_login_attempt_task(
store_login_attempt(
user_agent, ip_address, username, http_accept, path_info, login_valid
)
if config.USE_CELERY:
from celery import shared_task
add_login_attempt_task = shared_task(add_login_attempt_task)

View file

@ -1,25 +1,13 @@
{% extends "admin/index.html" %}
{% load i18n %}
{% if not is_popup %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
&rsaquo;
{% for app in app_list %}
{{ app.name }}
{% endfor %}
</div>
{% endblock %}
{% endif %}
{% block sidebar %}{% endblock %}
{% extends "admin/app_index.html" %}
{% block content %}
{{ block.super }}
{% url 'defender_blocks_view' as blocks_url %}
{% if blocks_url %}
<div class="app-defender module">
<table><tr scope='row'><td colspan='3'>
<h4><a href='{% url 'defender_blocks_view' %}'>Blocked Users</a></h4>
<h4><a href='{{ blocks_url }}'>Blocked Users</a></h4>
</td></tr></table>
</div>
{% endif %}
{% endblock content%}

View file

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

View file

@ -3,12 +3,12 @@ from django.test.testcases import TestCase, TransactionTestCase
from .connection import get_redis_connection
class DefenderTestCaseMixin(object):
class DefenderTestCaseMixin:
"""Mixin used to provide a common tearDown method"""
def tearDown(self):
"""cleanup django-defender cache after each test"""
super(DefenderTestCaseMixin, self).tearDown()
super().tearDown()
get_redis_connection().flushdb()

View file

@ -1,6 +1,9 @@
import os
import django
from celery import Celery
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
@ -11,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"
@ -38,6 +42,7 @@ TEMPLATES = [
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.request",
],
},
},

View file

@ -1,6 +1,6 @@
from django.conf.urls import url, include
from django.urls import path
from django.contrib import admin
from .urls import urlpatterns as original_urlpatterns
urlpatterns = [url(r"^admin/", admin.site.urls),] + original_urlpatterns
urlpatterns = [path("admin/", admin.site.urls),] + original_urlpatterns

View file

@ -1,26 +1,19 @@
import random
import string
import time
from distutils.version import StrictVersion
from unittest.mock import patch
# Python 3 has mock in the stdlib
try:
from mock import patch
except ImportError:
from unittest.mock import patch
from django import get_version
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
try:
from django.urls import reverse
except ImportError:
from django.core.urlresolvers 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
@ -30,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
@ -38,8 +31,6 @@ from .test import DefenderTestCase, DefenderTransactionTestCase
LOGIN_FORM_KEY = '<form action="/admin/login/" method="post" id="login-form">'
ADMIN_LOGIN_URL = reverse("admin:login")
DJANGO_VERSION = StrictVersion(get_version())
VALID_USERNAME = VALID_PASSWORD = "valid"
UPPER_USERNAME = "VALID"
@ -292,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
@ -405,12 +396,7 @@ class AccessAttemptTest(DefenderTestCase):
# Check if we are in the same login page
self.assertContains(response, LOGIN_FORM_KEY)
# RFC 7231 allows relative URIs in Location header.
# Django from version 1.9 is support this:
# https://docs.djangoproject.com/en/1.9/releases/1.9/#http-redirects-no-longer-forced-to-absolute-uris
lockout_url = "http://testserver/o/login/"
if DJANGO_VERSION >= StrictVersion("1.9"):
lockout_url = "/o/login/"
lockout_url = "/o/login/"
# So, we shouldn't have gotten a lock-out yet.
# But we should get one now, check redirect make sure it is valid.
@ -445,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):
@ -488,57 +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")
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")
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")
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")
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)
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")
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")
self.assertEqual(conf.get("HOST"), "localhost2")
self.assertEqual(conf.get("DB"), 0)
self.assertEqual(conf.get("PASSWORD"), None)
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 """
@ -722,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"
@ -921,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.
@ -1070,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,15 +1,15 @@
from django.conf.urls import url
from django.urls import path, re_path
from .views import block_view, unblock_ip_view, unblock_username_view
urlpatterns = [
url(r"^blocks/$", block_view, name="defender_blocks_view"),
url(
r"^blocks/ip/(?P<ip_address>[A-Za-z0-9-._]+)/unblock$",
path("blocks/", block_view, name="defender_blocks_view"),
re_path(
"blocks/ip/(?P<ip_address>[A-Za-z0-9-._]+)/unblock",
unblock_ip_view,
name="defender_unblock_ip_view",
),
url(
r"^blocks/username/(?P<username>[\w]+[^\/]*)/unblock$",
path(
"blocks/username/<path:username>/unblock",
unblock_username_view,
name="defender_unblock_username_view",
),

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,11 +1,9 @@
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
try:
from django.urls import reverse
except ImportError:
from django.core.urlresolvers import reverse
from .utils import get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username
@ -16,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

@ -8,7 +8,7 @@ This is just a simple example app, used for testing and showing how things work
```
mkdir -p exampleapp/static exampleapp/media/static
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py collectstatic --noinput --settings=exampleapp.settings
PYTHONPATH=$PYTHONPATH:$PWD django-admin collectstatic --noinput --settings=exampleapp.settings
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py runserver --settings=exampleapp.settings
PYTHONPATH=$PYTHONPATH:$PWD django-admin runserver --settings=exampleapp.settings
```

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"
@ -57,7 +58,7 @@ LOGIN_REDIRECT_URL = "/admin"
DEFENDER_LOGIN_FAILURE_LIMIT = 1
DEFENDER_COOLOFF_TIME = 60
DEFENDER_REDIS_URL = "redis://localhost:6379/1"
# don't use mock redis in unit tests, we will use real redis on travis.
# don't use mock redis in unit tests, we will use real redis on CI.
DEFENDER_MOCK_REDIS = False
# Let's use custom function and strip username string from request.
DEFENDER_GET_USERNAME_FROM_REQUEST_PATH = (
@ -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,6 +1,8 @@
-e .
coverage
mockredispy
django-redis-cache
celery<5
mock; python_version < '3.0'
django-redis>=5,<6
redis>=5,<6
importlib-metadata<5.0
celery
sphinx_rtd_theme==2.0.0

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
from setuptools import setup, find_packages
@ -32,15 +31,24 @@ setup(
classifiers=[
"Development Status :: 5 - Production/Stable",
"Framework :: Django",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.0",
"Framework :: Django :: 4.1",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2.7",
"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",
@ -56,12 +64,12 @@ setup(
include_package_data=True,
packages=find_packages(),
package_data=get_package_data("defender"),
install_requires=["Django>=1.8,<=3.1", "redis<=3.5"],
python_requires="~=3.8",
install_requires=["Django", "redis>=4.0.0"],
tests_require=[
"mock",
"mockredispy>=2.9.0.11,<3.0",
"coverage",
"celery",
"django-redis-cache",
"django-redis",
],
)

52
tox.ini
View file

@ -1,48 +1,56 @@
[tox]
envlist =
# list of supported Django/Python versioons:
# https://docs.djangoproject.com/en/2.2/faq/install/#what-python-version-can-i-use-with-django
py{27,35,36,37,py3}-django111
py35-django(21,22)
py{36,37,py3}-django{21,22,master}
py38-django22
py38-{lint,docs}
# list of supported Django/Python versions:
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}
[travis]
[gh-actions]
python =
2.7: py27
3.5: py35
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
py27: mock
django111: django>=1.11,<2.0
django21: django>=2.1,<2.2
django22: django>=2.2,<2.3
djangomaster: https://github.com/django/django/archive/master.tar.gz
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 =
{envbindir}/coverage run --source=defender {envbindir}/django-admin.py test defender --settings=defender.travis_settings
{envbindir}/coverage run --source=defender {envbindir}/django-admin test defender --settings=defender.ci_settings
{envbindir}/coverage report -m
{envbindir}/coverage xml
ignore_outcome =
djmain: True
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