Compare commits

...

141 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
Ken Cochrane
8daa2d168d Added changes for release 0.7.0 2020-02-23 15:23:52 -05:00
David Nugent
6756a87f77 require django >= 1.8 2020-02-23 14:25:15 -05:00
David Nugent
9431e16cdd Fix requirement versions 2020-02-23 14:25:15 -05:00
David Nugent
cb59d1c481 Fixes for django 3.0+
As python 2i is no longer supported, stub reference to python_2_unicode_compatible
2020-02-23 14:25:15 -05:00
Jannis Leidel
7b1961ebf9
remove unwanted .eggs directory. 2019-11-25 11:13:07 +01:00
Jannis Leidel
ed857f7481
Run lints and other checks. (#149) 2019-11-25 11:11:57 +01:00
Jannis Leidel
130fe397a4
Add forgotten import. 2019-11-25 08:47:43 +01:00
Jannis Leidel
87c6c2bdab
Merge pull request #148 from jazzband/project-releaess
Add Tox config and prepare for project releases.
2019-11-24 20:30:00 +01:00
Jannis Leidel
c814aa9d71
Add coverage to missing requirements. 2019-11-24 15:20:15 +01:00
Jannis Leidel
d83c4dd286
Fix paths. 2019-11-24 15:17:10 +01:00
Jannis Leidel
68d0b41ee5
Use setuptools-scm. 2019-11-24 15:14:12 +01:00
Jannis Leidel
396ea4d471
Add deploy section. 2019-11-24 15:12:31 +01:00
Jannis Leidel
b89b394c8e
Move to Codecov. 2019-11-24 15:06:20 +01:00
Jannis Leidel
2452891454
Django Master doesn't run on Python 3.5. 2019-11-24 15:05:16 +01:00
Jannis Leidel
cac4600f56
Use requirements file. 2019-11-24 14:56:09 +01:00
Jannis Leidel
9654128727
Install as editable. 2019-11-24 14:49:23 +01:00
Jannis Leidel
f7bda0c276
Add Tox config and prepare for project releases. 2019-11-24 14:47:18 +01:00
Aleksi Häkli
a1d526f318
PEP8 formatting (#147)
Run black with Python 2.7 as target version
to unify the code styling and make it more
linter and style guide compliant
2019-11-15 20:22:14 +02:00
Aleksi Häkli
afa47bcbf0
Add documentation link to README 2019-11-13 23:08:52 +02:00
Aleksi Häkli
7d4d845585
Add Python version classifiers to setup.py 2019-11-13 23:01:11 +02:00
Aleksi Häkli
19e572654f
Add WIP changes to changelog 2019-11-13 22:56:46 +02:00
Aleksi Häkli
dd1caa2a50
Remove deprecated versions from README 2019-11-13 22:49:47 +02:00
Aleksi Häkli
1a001301e7
Remove EOL Python 3.4 and Django 2.0 from test matrix 2019-11-13 22:44:39 +02:00
Aleksi Häkli
32c8c747aa
Disable Fussyfox as it provides false negatives 2019-11-13 22:29:40 +02:00
Aleksi Häkli
266e413f27
Change heading level for contribution section 2019-11-13 22:27:25 +02:00
Aleksi Häkli
e8eefc1d3c
Clean up docs for a simpler structure 2019-11-13 22:23:51 +02:00
Aleksi Häkli
f63aeeec3f
Explicit master_doc for RTD Sphinx 2019-11-13 22:14:16 +02:00
Aleksi Häkli
5706ba4b21
Fix code blocks in README 2019-11-13 22:08:42 +02:00
Aleksi Häkli
df9d380feb
RTD HTML and PDF documentation (#146)
Convert Markdown to reStructuredText
Include converted documents in the docs folder
2019-11-13 21:58:47 +02:00
Aleksi Häkli
eb4f2ef94b Initial documentation shims 2019-11-10 11:08:31 -05:00
Aleksi Häkli
d27bd95119 Remove .landscape.yaml as service is defunct 2019-11-10 11:06:43 -05:00
Aleksi Häkli
01dd2bc354 Wrap Celery version specifier in quotes 2019-11-10 11:06:15 -05:00
Aleksi Häkli
ef3673cd4f Add Python 3.7, 3.8 and PyPy3 to test version matrix 2019-11-10 11:06:15 -05:00
Aleksi Häkli
cafa92860e Remove deprecated empty first URL 2019-11-08 09:03:12 -05:00
Aleksi Häkli
d6e905706b Update README
- Remove non-functional Landscape.io badge (site is unreachable)
- Add Jazzband badge
- Update title styling
- Update code examples for Django version compatibility
2019-11-08 09:03:12 -05:00
Aleksi Häkli
f439b7b647 Create CONTRIBUTING guidelines 2019-11-07 15:38:30 -05:00
Aleksi Häkli
da7aa9d41b Update README links 2019-11-07 15:37:39 -05:00
Ken Cochrane
e7f7cba6c5
Create .fussyfox.yml
Added the `.fussyfox.yml` file
2019-11-07 15:35:22 -05:00
horida
ce95906488 send user/ip blocked signal only once 2019-09-16 08:47:35 -04:00
horida
fcfa88d679 Add unblock signals 2019-09-08 10:05:04 -04:00
Ken Cochrane
2a5527ac4c bump version to 0.6.2
Signed-off-by: Ken Cochrane <KenCochrane@gmail.com>
2019-06-04 18:02:03 -04:00
Salar Rahmanian
fbad0face1 Added support for redis client 3.2.1 2019-06-04 15:17:31 -04:00
Chris Ledet
7ca76bb975 Add and test support for Django 2.2 2019-05-30 08:34:42 -04:00
54 changed files with 2567 additions and 1516 deletions

View file

@ -1,3 +1,3 @@
[run]
omit = *_settings.py, defender/*migrations/*, defender/exampleapp/*, *test.py,
omit = *_settings.py, defender/*migrations/*, exampleapp/*, *test.py,
*__init__.py, *tests.py, *urls.py

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

5
.gitignore vendored
View file

@ -22,6 +22,7 @@ var/
.installed.cfg
*.egg
MANIFEST
.eggs
# PyInstaller
# Usually these files are written by a python script from a template
@ -55,5 +56,5 @@ docs/_build/
target/
# exampleapp
defender/exampleapp/static/
defender/exampleapp/media/
exampleapp/static/
exampleapp/media/

View file

@ -1,11 +0,0 @@
doc-warnings: no
test-warnings: no
strictness: high
max-line-length: 80
uses:
- django
- celery
autodetect: yes
ignore-patterns:
- .*_settings.py$
- defender/exampleapp/*

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,41 +0,0 @@
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
env:
- DJANGO=1.11
- DJANGO=2.0
- DJANGO=2.1
services:
- redis-server
install:
- pip install -q django~=$DJANGO.0
- pip install coveralls
- pip install mockredispy
- pip install django-redis-cache
- pip install 'celery<5'
- python setup.py develop
script:
- PYTHONPATH=$PYTHONPATH:$PWD coverage run --source=defender $(which django-admin.py) test defender --settings=defender.travis_settings
- coverage report -m
matrix:
exclude:
- python: "2.7"
env: DJANGO=2.0
- python: "2.7"
env: DJANGO=2.1
- python: "3.4"
env: DJANGO=2.0
- python: "3.4"
env: DJANGO=2.1
after_success:
- coveralls --verbose

101
CHANGES
View file

@ -1,101 +0,0 @@
0.6.0
=====
- remove Python 3.3 [@fr0mhell]
- remove Django 1.8-1.10 [@fr0mhell]
- add Celery v4 [@fr0mhell]
- update travis config [@fr0mhell]
- update admin URL [@fr0mhell]
0.5.5
=====
- Added new setting ``DEFENDER_GET_USERNAME_FROM_REQUEST_PATH` for control how username is accessed from request [@andrewshkovskii]
- Added new argument ``get_username` for ``decorators.watch_login`` to propagate ``get_username`` argument to other utils functions calls done in ``watch_login`` [@andrewshkovskii]
0.5.4
=====
- Added 2 new setting variables for more granular failure limit control [@williamboman]
- Added ssl option when instantiating StrictRedis [@mjrimrie]
- Send signals when blocking username or ip [@williamboman]
0.5.3
=====
- Remove mockredis as install requirement, make only test requirement [@blueyed]
0.5.2
=====
- Fix regex in 'unblock_username_view' to handle special symbols [@ruthus18]
- Fix django requires version for 1.11.x [@kencochrane]
- remove hiredis dependency [@ericbuckley]
- Correctly get raw client when using django_redis cache. [@cburger]
- replace django.core.urlresolvers with django.urls For Django 2.0 [@s-wirth]
- add username kwarg for providing username directly rather than via callback arg [@williamboman]
- Only use the username if it is actually provided [@cobusc]
0.5.1
=====
- Middleware fix for django >= 1.10 #93 [@Temeez]
- Force the username to lowercase #90 [@MattBlack85]
0.5.0
=====
- Better support for Django 1.11 [@dukebody]
- Added support to share redis config with django.core.cache [@Franr]
- Allow decoration of functions beyond the admin login [@MattBlack85]
- Doc improvements [@dukebody]
- Allow usernames with plus signs in unblock view [@dukebody]
- Code cleanup [@KenCochrane]
0.4.3
=====
- Flex version requirements for dependencies
- Better support for Django 1.10
0.4.2
=====
Better support for Django 1.9
0.4.1
=====
minor refactor to make it easier to retrieve username.
0.4.0
=====
added ``DEFENDER_DISABLE_IP_LOCKOUT`` and added support for Python 3.5
0.3.2
=====
added ``DEFENDER_LOCK_OUT_BY_IP_AND_USERNAME``, and changed settings to support
django 1.8.
0.3.1
=====
fixed the management command name
0.3
===
- Added management command ``cleanup_django_defender`` to clean up access
attempt table.
- Added ``DEFENDER_STORE_ACCESS_ATTEMPTS`` config to say if you want to
store attempts to DB or not.
- Added ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION`` config to specify how long
to store the access attempt records in the db, before the management command
cleans them up.
- changed the Django admin page to remove some filters which were making the
page load slow with lots of login attempts in the database.
0.2.2
=====
Another bug fix release for more missing files in distribution
0.2.1
=====
Bug fixes for packing missing files
0.2
===
Added fixes to include possible security issue
0.1
===
Initial Version

223
CHANGES.rst Normal file
View file

@ -0,0 +1,223 @@
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
-----
- Add support for Django 3.0 [@deeprave]
- Remove support from deprecated Python 3.4 and Django 2.0. [@aleksihakli]
- Add Read the Docs documentation. [@aleksihakli]
- Add support for Python 3.7, Python 3.8, PyPy3. [@aleksihakli]
0.6.2
-----
- Add and test support for Django 2.2 [@chrisledet]
- Add support for redis client 3.2.1 [@softinio]
0.6.1
-----
- Add redispy 3.2.0 compatibility [@nrth]
0.6.0
-----
- Remove Python 3.3 [@fr0mhell]
- Remove Django 1.8-1.10 [@fr0mhell]
- Add Celery v4 [@fr0mhell]
- Update travis config [@fr0mhell]
- Update admin URL [@fr0mhell]
0.5.5
-----
- Add new setting ``DEFENDER_GET_USERNAME_FROM_REQUEST_PATH`` for control how username is accessed from request [@andrewshkovskii]
- Add new argument ``get_username`` for ``decorators.watch_login`` to propagate ``get_username`` argument to other utils functions calls done in ``watch_login`` [@andrewshkovskii]
0.5.4
-----
- Add 2 new setting variables for more granular failure limit control [@williamboman]
- Add ssl option when instantiating StrictRedis [@mjrimrie]
- Send signals when blocking username or ip [@williamboman]
0.5.3
-----
- Remove mockredis as install requirement, make only test requirement [@blueyed]
0.5.2
-----
- Fix regex in 'unblock_username_view' to handle special symbols [@ruthus18]
- Fix django requires version for 1.11.x [@kencochrane]
- Remove hiredis dependency [@ericbuckley]
- Correctly get raw client when using django_redis cache. [@cburger]
- Replace django.core.urlresolvers with django.urls For Django 2.0 [@s-wirth]
- Add username kwarg for providing username directly rather than via callback arg [@williamboman]
- Only use the username if it is actually provided [@cobusc]
0.5.1
-----
- Middleware fix for django >- 1.10 #93 [@Temeez]
- Force the username to lowercase #90 [@MattBlack85]
0.5.0
-----
- Better support for Django 1.11 [@dukebody]
- Add support to share redis config with django.core.cache [@Franr]
- Allow decoration of functions beyond the admin login [@MattBlack85]
- Doc improvements [@dukebody]
- Allow usernames with plus signs in unblock view [@dukebody]
- Code cleanup [@KenCochrane]
0.4.3
-----
- Flex version requirements for dependencies
- Better support for Django 1.10
0.4.2
-----
- Better support for Django 1.9
0.4.1
-----
- Minor refactor to make it easier to retrieve username.
0.4.0
-----
- Add ``DEFENDER_DISABLE_IP_LOCKOUT`` and added support for Python 3.5
0.3.2
-----
- Add ``DEFENDER_LOCK_OUT_BY_IP_AND_USERNAME``, and changed settings to support django 1.8.
0.3.1
-----
- Fix the management command name
0.3
---
- Add management command ``cleanup_django_defender`` to clean up access attempt table.
- Add ``DEFENDER_STORE_ACCESS_ATTEMPTS`` config to say if you want to store attempts to DB or not.
- Add ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION`` config to specify how long to store the access attempt records in the db, before the management command cleans them up.
- Change the Django admin page to remove some filters which were making the page load slow with lots of login attempts in the database.
0.2.2
-----
- Another bug fix release for more missing files in distribution
0.2.1
-----
- Bug fixes for packing missing files
0.2
---
- Add fixes to include possible security issue
0.1
---
- Initial Version

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/

10
CONTRIBUTING.rst Normal file
View file

@ -0,0 +1,10 @@
Contributing
------------
.. image:: https://jazzband.co/static/img/jazzband.svg
:target: https://jazzband.co/
:alt: Jazzband
This is a `Jazzband <https://jazzband.co/>`_ project. By contributing you agree to abide by the `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the `guidelines <https://jazzband.co/about/guidelines>`_.

View file

@ -1,5 +1,15 @@
include CHANGES
include README.md
include .coveragerc
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
recursive-exclude * *.py[co]
prune .eggs

512
README.md
View file

@ -1,512 +0,0 @@
django-defender
===============
A simple Django reusable app that blocks people from brute forcing login
attempts. The goal is to make this as fast as possible, so that we do not
slow down the login attempts.
We will use a cache so that it doesn't have to hit the database in order to
check the database on each login attempt. The first version will be based on
Redis, but the goal is to make this configurable so that people can use whatever
backend best fits their needs.
Build status
------------
[![Build Status](https://travis-ci.org/kencochrane/django-defender.svg)](https://travis-ci.org/kencochrane/django-defender) [![Coverage Status](https://img.shields.io/coveralls/kencochrane/django-defender.svg)](https://coveralls.io/r/kencochrane/django-defender)[![Code Health](https://landscape.io/github/kencochrane/django-defender/master/landscape.svg)](https://landscape.io/github/kencochrane/django-defender/master)
Sites using Defender:
=====================
If you are using defender on your site, submit a PR to add to the list.
- https://hub.docker.com
- https://www.mycosbuilder.com
Versions
========
- 0.6.1
- added redispy 3.2.0 compatibility [@nrth]
- 0.6.0
- remove Python 3.3 [@fr0mhell]
- remove Django 1.8-1.10 [@fr0mhell]
- add Celery v4 [@fr0mhell]
- update travis config [@fr0mhell]
- update admin URL [@fr0mhell]
- 0.5.5
- Added new setting ``DEFENDER_GET_USERNAME_FROM_REQUEST_PATH`` for control how username is accessed from request [@andrewshkovskii]
- Added new argument ``get_username`` for ``decorators.watch_login`` to propagate ``get_username`` argument to other utils functions calls done in ``watch_login`` [@andrewshkovskii]
- 0.5.4
- Added 2 new setting variables for more granular failure limit control [@williamboman]
- Added ssl option when instantiating StrictRedis [@mjrimrie]
- Send signals when blocking username or ip [@williamboman]
- 0.5.3
- Remove mockredis as install requirement, make only test requirement [@blueyed]
- 0.5.2
- Fix regex in 'unblock_username_view' to handle special symbols [@ruthus18]
- Fix django requires version for 1.11.x [@kencochrane]
- remove hiredis dependency [@ericbuckley]
- Correctly get raw client when using django_redis cache. [@cburger]
- replace django.core.urlresolvers with django.urls For Django 2.0 [@s-wirth]
- add username kwarg for providing username directly rather than via callback arg [@williamboman]
- Only use the username if it is actually provided [@cobusc]
- 0.5.1
- Middleware fix for django >= 1.10 #93 [@Temeez]
- Force the username to lowercase #90 [@MattBlack85]
- 0.5.0
- Better support for Django 1.11 [@dukebody]
- Added support to share redis config with django.core.cache [@Franr]
- Allow decoration of functions beyond the admin login [@MattBlack85]
- Doc improvements [@dukebody]
- Allow usernames with plus signs in unblock view [@dukebody]
- Code cleanup [@KenCochrane]
- 0.4.3 - Better Support for Django 1.10
- 0.4.2 - Better support for Django 1.9
- 0.4.1 - minor refactor to make it easier to retrieve username.
- 0.4.0 - added ``DEFENDER_DISABLE_IP_LOCKOUT`` and added support for Python 3.5
- 0.3.2 - added ``DEFENDER_LOCK_OUT_BY_IP_AND_USERNAME``, and changed settings
to support django 1.8.
- 0.3.1 - fixed the management command name
- 0.3
- Added management command ``cleanup_django_defender`` to clean up access
attempt table.
- Added ``DEFENDER_STORE_ACCESS_ATTEMPTS`` config to say if you want to
store attempts to DB or not.
- Added ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION`` config to specify how long
to store the access attempt records in the db, before the management command
cleans them up.
- changed the Django admin page to remove some filters which were making the
page load slow with lots of login attempts in the database.
- 0.2.2 - bug fix add missing files to pypi package
- 0.2.1 - bug fix
- 0.2 - security fix for XFF headers
- 0.1.1 - setup.py fix
- 0.1 - initial release
Features
========
- Log all login attempts to the database
- support for reverse proxies with different headers for IP addresses
- rate limit based on:
- username
- ip address
- use redis for the blacklist
- configuration
- redis server
- host
- port
- database
- password
- key_prefix
- block length
- number of incorrect attempts before block
- 95% code coverage
- full documentation
- Ability to store login attempts to the database
- Management command to clean up login attempts database table
- admin pages
- list of blocked usernames and ip's
- ability to unblock people
- list of recent login attempts
- Can be easily adapted to custom authentication method.
- Signals are sent when blocking username or IP
Long term goals
===============
- pluggable backends, so people can use something other then redis.
- email users when their account is blocked
- add a whitelist for username and ip's that we will never block (admin's, etc)
- add a permanent black list
- ip address
- scan for known proxy ip's and don't block requests coming from those
(improve the chances that a good IP is blocked)
- add management command to prune old (configurable) login attempts.
Performance:
============
The goal of defender is to make it as fast as possible so that it doesn't slow
down the login process. In order to make sure our goals are met we need a way
to test the application to make sure we are on the right track. The best
way to do this is to compare how fast a normal Django login takes with defender
and django-axes.
The normal django login, would be our baseline, and we expect it to be the
fastest of the 3 methods, because there are no additional checks happening.
The defender login would most likely be slower then the django login, and
hopefully faster then the django-axes login. The goal is to make it as little
of a difference between the regular raw login, and defender.
The django-axes login speed, will probably be the slowest of the three since it
does more checks and does a lot of database queries.
The best way to determine the speed of a login is to do a load test against an
application with each setup, and compare the login times for each type.
Types of Load tests
-------------------
In order to make sure we cover all the different types of logins, in our load
test we need to have more then one test.
1. All success:
- We will do a load test with nothing but successful logins
2. Mixed: some success some failure:
- We will load test with some successful logins and some failures to see how
the failure effect the performance.
3. All Failures:
- We will load test with all failure logins and see the difference in
performance.
We will need a sample application that we can use for the load test, with the
only difference is the configuration where we either load defender, axes, or
none of them.
We can use a hosted load testing service, or something like jmeter. Either way
we need to be consistent for all of the tests. If we use jmeter, we should have
our jmeter configuration for others to run the tests on their own.
Results
-------
We will post the results here. We will explain each test, and show the results
along with some charts.
Why not django-axes
===================
django-axes is great but it puts everything in the database, and this causes
a bottle neck when you have a lot of data. It slows down the auth requests by
as much as 200-300ms. This might not be much for some sites, but for others it
is too long.
This started out as a fork of django-axes, and is using as much of their code
as possible, and removing the parts not needed, and speeding up the lookups
to improve the login.
requirements
============
- django: 1.8.x, 1.9.x, 1.10.x, 1.11.x
- redis
- python: 2.7.x, 3.3.x, 3.4.x, 3.5.x, 3.6.x, PyPy
How it works
============
1. When someone tries to login, we first check to see if they are currently
blocked. We check the username they are trying to use, as well as the IP
address. If they are blocked, goto step 5. If not blocked go to step 2.
2. They are not blocked, so we check to see if the login was valid. If valid
go to step 6. If not valid go to step 3.
3. Login attempt wasn't valid. Add their username and IP address for this
attempt to the cache. If this brings them over the limit, add them to the
blocked list, and then goto step 5. If not over the limit goto step 4.
4. login was invalid, but not over the limit. Send them back to the login screen
to try again.
5. User is blocked: Send them to the blocked page, telling them they are
blocked, and give an estimate on when they will be unblocked.
6. Login is valid. Reset any failed login attempts, and forward to their
destination.
Cache backend:
==============
cache keys:
-----------
Counters:
- prefix:failed:ip:[ip] (count, TTL)
- prefix:failed:username:[username] (count, TTL)
Booleans (if present it is blocked):
- prefix:blocked:ip:[ip] (true, TTL)
- prefix:blocked:username:[username] (true, TTL)
Installing Django-defender
==========================
Download code, and run setup.
```
$ pip install django-defender
or
$ python setup.py install
or
$ pip install -e git+http://github.com/kencochrane/django-defender.git#egg=django_defender-dev
```
First of all, you must add this project to your list of ``INSTALLED_APPS`` in
``settings.py``::
```
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
...
'defender',
...
)
```
Next, install the ``FailedLoginMiddleware`` middleware::
```
MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'defender.middleware.FailedLoginMiddleware'
)
```
If you want to manage the blocked users via the Django admin, then add the
following to your ``urls.py``
```
urlpatterns = patterns(
'',
(r'^admin/', include(admin.site.urls)), # normal admin
(r'^admin/defender/', include('defender.urls')), # defender admin
# your own patterns follow…
)
```
Management Commands:
--------------------
``cleanup_django_defender``:
If you have a website with a lot of traffic, the AccessAttempts table will get
full pretty quickly. If you don't need to keep the data for auditing purposes
there is a management command to help you keep it clean.
It will look at your ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION`` setting to determine
which records will be deleted. Default if not specified, is 24 hours.
```bash
$ python manage.py cleanup_django_defender
```
You can set this up as a daily or weekly cron job to keep the table size down.
```bash
# run at 12:24 AM every morning.
24 0 * * * /usr/bin/python manage.py cleanup_django_defender >> /var/log/django_defender_cleanup.log
```
Admin Pages:
------------
![alt tag](https://cloud.githubusercontent.com/assets/261601/5950540/8895b570-a729-11e4-9dc3-6b00e46c8043.png)
![alt tag](https://cloud.githubusercontent.com/assets/261601/5950541/88a35194-a729-11e4-981b-3a55b44ef9d5.png)
Database tables:
----------------
You will need to create tables in your database that are necessary
for operation.
```bash
python manage.py migrate defender
```
Customizing Defender
--------------------
You have a couple options available to you to customize ``django-defender`` a bit.
These should be defined in your ``settings.py`` file.
* ``DEFENDER_LOGIN_FAILURE_LIMIT``: Int: The number of login attempts allowed before a
record is created for the failed logins. [Default: ``3``]
* ``DEFENDER_LOGIN_FAILURE_LIMIT_USERNAME``: Int: The number of login attempts allowed
on a username before a record is created for the failed logins. [Default: ``DEFENDER_LOGIN_FAILURE_LIMIT``]
* ``DEFENDER_LOGIN_FAILURE_LIMIT_IP``: Int: The number of login attempts allowed
from an IP before a record is created for the failed logins. [Default: ``DEFENDER_LOGIN_FAILURE_LIMIT``]
* ``DEFENDER_BEHIND_REVERSE_PROXY``: Boolean: Is defender behind a reverse proxy?
[Default: ``False``]
* ``DEFENDER_REVERSE_PROXY_HEADER``: String: the name of the http header with your
reverse proxy IP address [Default: ``HTTP_X_FORWARDED_FOR``]
* ``DEFENDER_LOCK_OUT_BY_IP_AND_USERNAME``: Boolean: Locks a user out based on a combination of IP and Username. This stops a user denying access to the application for all other users accessing the app from behind the same IP address. [Default: ``False``]
* ``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``]
* ``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.
[Default: ``defender``]
* ``DEFENDER_LOCKOUT_URL``: String: The URL you want to redirect to if someone is
locked out.
* ``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.
[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
[Default: ``True``]
* ``DEFENDER_USE_CELERY``: Boolean: If you want to use Celery to store the login
attempt to the database, set to True. If False, it is saved inline.
[Default: ``False``]
* ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION``: Int: Length of time in hours for how
long to keep the access attempt records in the database before the management
command cleans them up.
[Default: ``24``]
* ``DEFENDER_GET_USERNAME_FROM_REQUEST_PATH``: String: The import path of the function that access username from request.
If you want to use custom function to access and process username from request - you can specify it here.
[Default: ``defender.utils.username_from_request``]
Adapting to other authentication method
--------------------
`defender` can be used for authentication other than `Django authentication system`.
E.g. if `django-rest-framework` authentication has to be protected from brute force attack, a custom authentication method can be implemented.
There's sample `BasicAuthenticationDefender` class based on `djangorestframework.BasicAuthentication`:
```python
import base64
import binascii
from defender import utils
from defender import config
from django.utils.translation import ugettext_lazy as _
from rest_framework import HTTP_HEADER_ENCODING, exceptions
from rest_framework.authentication import (
BasicAuthentication,
get_authorization_header,
)
class BasicAuthenticationDefender(BasicAuthentication):
def get_username_from_request(self, request):
auth = get_authorization_header(request).split()
return base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':')[0]
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() != b'basic':
return None
if len(auth) == 1:
msg = _('Invalid basic header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid basic header. Credentials string should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)
if utils.is_already_locked(request, get_username=self.get_username_from_request):
detail = "You have attempted to login {failure_limit} times, with no success." \
"Your account is locked for {cooloff_time_seconds} seconds" \
"".format(
failure_limit=config.FAILURE_LIMIT,
cooloff_time_seconds=config.COOLOFF_TIME
)
raise exceptions.AuthenticationFailed(_(detail))
try:
auth_parts = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':')
except (TypeError, UnicodeDecodeError, binascii.Error):
msg = _('Invalid basic header. Credentials not correctly base64 encoded.')
raise exceptions.AuthenticationFailed(msg)
userid, password = auth_parts[0], auth_parts[2]
login_unsuccessful = False
login_exception = None
try:
response = self.authenticate_credentials(userid, password)
except exceptions.AuthenticationFailed as e:
login_unsuccessful = True
login_exception = e
utils.add_login_attempt_to_db(request,
login_valid=not login_unsuccessful,
get_username=self.get_username_from_request)
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
```
To make it works add `BasicAuthenticationDefender` to `DEFAULT_AUTHENTICATION_CLASSES` above all other authentication methods in your `settings.py`.
Django Signals
--------------------
`django-defender` will send signals when blocking a username or an IP address. To set up signal receiver functions:
```python
from django.dispatch import receiver
from defender import signals
@receiver(signals.username_block)
def username_blocked(username, **kwargs):
print("%s was blocked!" % username)
@receiver(signals.ip_block)
def ip_blocked(ip_address, **kwargs):
print("%s was blocked!" % ip_address)
```
Running Tests
=============
Tests can be run, after you clone the repository and having Django installed,
like:
```
$ PYTHONPATH=$PYTHONPATH:$PWD django-admin.py test defender --settings=defender.test_settings
```
With Code coverage:
```
PYTHONPATH=$PYTHONPATH:$PWD coverage run --source=defender $(which django-admin.py) test defender --settings=defender.test_settings
```
How to release
==============
1. python setup.py sdist
2. twine upload dist/*

805
README.rst Normal file
View file

@ -0,0 +1,805 @@
django-defender
===============
.. image:: https://jazzband.co/static/img/badge.svg
:target: https://jazzband.co/
:alt: Jazzband
.. 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/django-defender/badge/?version=latest
:alt: Documentation Status
:target: https://django-defender.readthedocs.io/en/latest/?badge=latest
A simple Django reusable app that blocks people from brute forcing login
attempts. The goal is to make this as fast as possible, so that we do not
slow down the login attempts.
We will use a cache so that it doesn't have to hit the database in order to
check the database on each login attempt. The first version will be based on
Redis, but the goal is to make this configurable so that people can use whatever
backend best fits their needs.
Sites using django-defender
---------------------------
If you are using defender on your site, submit a PR to add to the list.
* https://hub.docker.com
* https://www.mycosbuilder.com
Documentation
-------------
Documentation is available on Read the Docs:
https://django-defender.readthedocs.io
Features
--------
* Log all login attempts to the database
* Support for reverse proxies with different headers for IP addresses
* Rate limit based on
* Username
* IP address
* Use Redis for the blacklist
* Configuration
* Redis server
* Host
* Port
* Database
* Password
* Key prefix
* Block length
* Number of incorrect attempts before block
* 95% code coverage
* Full documentation
* Ability to store login attempts to the database
* Management command to clean up login attempts database table
* Admin pages
* List of blocked usernames and IP addresses
* List of recent login attempts
* Ability to unblock people
* Can be easily adapted to custom authentication method.
* Signals are sent when blocking username or IP
Admin pages
***********
.. image:: https://cloud.githubusercontent.com/assets/261601/5950540/8895b570-a729-11e4-9dc3-6b00e46c8043.png
:target: https://cloud.githubusercontent.com/assets/261601/5950540/8895b570-a729-11e4-9dc3-6b00e46c8043.png
:alt: alt tag
.. image:: https://cloud.githubusercontent.com/assets/261601/5950541/88a35194-a729-11e4-981b-3a55b44ef9d5.png
:target: https://cloud.githubusercontent.com/assets/261601/5950541/88a35194-a729-11e4-981b-3a55b44ef9d5.png
:alt: alt tag
Requirements
------------
* 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
------------
Download code, and run setup in one of the following ways depending on the method.
To install the production ready version from PyPI:
.. code-block:: bash
pip install django-defender
To install the development version from source code after download:
.. code-block:: bash
python setup.py install
To install the master branch development version from the GitHub repository:
.. code-block:: bash
pip install -e git+http://github.com/kencochran django-defender.git#egg=django_defender-dev
First of all, you must add this project to your list of ``INSTALLED_APPS`` in
``settings.py``
.. code-block:: python
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
# ...
'defender',
# ...
]
Next, install the ``FailedLoginMiddleware`` middleware
.. code-block:: python
MIDDLEWARE_CLASSES = [
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'defender.middleware.FailedLoginMiddleware',
]
If you want to manage the blocked users via the Django admin, then add the
following to your ``urls.py``
.. code-block:: python
urlpatterns = [
path('admin/defender/', include('defender.urls')), # defender admin
path('admin/', admin.site.urls), # normal admin
# your own patterns follow...
]
Migrations
**********
You will need to create tables in your database that are necessary
for operation.
.. code-block:: bash
python manage.py migrate defender
Management commands
*******************
``cleanup_django_defender``
If you have a website with a lot of traffic, the AccessAttempts table will get
full pretty quickly. If you don't need to keep the data for auditing purposes
there is a management command to help you keep it clean.
It will look at your ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION`` setting to determine
which records will be deleted. Default if not specified, is 24 hours.
.. code-block:: bash
$ python manage.py cleanup_django_defender
You can set this up as a daily or weekly cron job to keep the table size down.
.. code-block:: bash
# run at 12:24 AM every morning.
24 0 * * * /usr/bin/python manage.py cleanup_django_defender >> /var/log/django_defender_cleanup.log
Long term goals
---------------
* Pluggable backends, so people can use something other than Redis
* Email users when their account is blocked
* Add a whitelist for username and ip's that we will never block (admin's, etc)
* Add a permanent black list for IP addresses
* Scan for known proxy IPs and do not block requests coming from those
(improve the chances that a good IP is blocked)
* Add management command to prune old (configurable) login attempts.
Performance
***********
The goal of defender is to make it as fast as possible so that it doesn't slow
down the login process. In order to make sure our goals are met we need a way
to test the application to make sure we are on the right track. The best
way to do this is to compare how fast a normal Django login takes with defender
and django-axes.
The normal django login, would be our baseline, and we expect it to be the
fastest of the 3 methods, because there are no additional checks happening.
The defender login would most likely be slower then the django login, and
hopefully faster then the django-axes login. The goal is to make it as little
of a difference between the regular raw login, and defender.
The django-axes login speed, will probably be the slowest of the three since it
does more checks and does a lot of database queries.
The best way to determine the speed of a login is to do a load test against an
application with each setup, and compare the login times for each type.
Load testing
************
In order to make sure we cover all the different types of logins, in our load
test we need to have more then one test.
#. All success: We will do a load test with nothing but successful logins.
#. Mixed: some success some failure: We will load test with some successful logins and some failures to see how the failure effect the performance.
#. All Failures: We will load test with all failure logins and see the difference in performance.
We will need a sample application that we can use for the load test, with the
only difference is the configuration where we either load defender, axes, or
none of them.
We can use a hosted load testing service, or something like jmeter. Either way
we need to be consistent for all of the tests. If we use jmeter, we should have
our jmeter configuration for others to run the tests on their own.
Results of load tests
*********************
We will post the results here. We will explain each test, and show the results
along with some charts.
Why not django-axes
-------------------
django-axes is great but it puts everything in the database, and this causes
a bottle neck when you have a lot of data. It slows down the auth requests by
as much as 200-300ms. This might not be much for some sites, but for others it
is too long.
This started out as a fork of django-axes, and is using as much of their code
as possible, and removing the parts not needed, and speeding up the lookups
to improve the login.
How django-defender works
-------------------------
#. When someone tries to login, we first check to see if they are currently
blocked. We check the username they are trying to use, as well as the IP
address. If they are blocked, goto step 5. If not blocked go to step 2.
#. They are not blocked, so we check to see if the login was valid. If valid
go to step 6. If not valid go to step 3.
#. Login attempt wasn't valid. Add their username and IP address for this
attempt to the cache. If this brings them over the limit, add them to the
blocked list, and then goto step 5. If not over the limit goto step 4.
#. Login was invalid, but not over the limit. Send them back to the login screen
to try again.
#. User is blocked: Send them to the blocked page, telling them they are
blocked, and give an estimate on when they will be unblocked.
#. Login is valid. Reset any failed login attempts, and forward to their
destination.
Cache backend
-------------
Defender uses the cache to save the failed attempts.
Cache keys
**********
Counters:
* prefix:failed:ip:[ip] (count, TTL)
* prefix:failed:username:[username] (count, TTL)
Booleans (if present it is blocked):
* prefix:blocked:ip:[ip] (true, TTL)
* prefix:blocked:username:[username] (true, TTL)
Customizing django-defender
---------------------------
You have a couple options available to you to customize ``django-defender`` a bit.
These should be defined in your ``settings.py`` file.
* ``DEFENDER_LOGIN_FAILURE_LIMIT``\ : Int: The number of login attempts allowed before a
record is created for the failed logins. [Default: ``3``\ ]
* ``DEFENDER_LOGIN_FAILURE_LIMIT_USERNAME``\ : Int: The number of login attempts allowed
on a username before a record is created for the failed logins. [Default: ``DEFENDER_LOGIN_FAILURE_LIMIT``\ ]
* ``DEFENDER_LOGIN_FAILURE_LIMIT_IP``\ : Int: The number of login attempts allowed
from an IP before a record is created for the failed logins. [Default: ``DEFENDER_LOGIN_FAILURE_LIMIT``\ ]
* ``DEFENDER_BEHIND_REVERSE_PROXY``\ : Boolean: Is defender behind a reverse proxy?
[Default: ``False``\ ]
* ``DEFENDER_REVERSE_PROXY_HEADER``\ : String: the name of the http header with your
reverse proxy IP address [Default: ``HTTP_X_FORWARDED_FOR``\ ]
* ``DEFENDER_LOCK_OUT_BY_IP_AND_USERNAME``\ : Boolean: Locks a user out based on a combination of IP and Username. This stops a user denying access to the application for all other users accessing the app from behind the same IP address. [Default: ``False``\ ]
* ``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 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.
[Default: ``defender``\ ]
* ``DEFENDER_LOCKOUT_URL``\ : String: The URL you want to redirect to if someone is
locked out.
* ``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.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
[Default: ``True``\ ]
* ``DEFENDER_USE_CELERY``\ : Boolean: If you want to use Celery to store the login
attempt to the database, set to True. If False, it is saved inline.
[Default: ``False``\ ]
* ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION``\ : Int: Length of time in hours for how
long to keep the access attempt records in the database before the management
command cleans them up.
[Default: ``24``\ ]
* ``DEFENDER_GET_USERNAME_FROM_REQUEST_PATH``\ : String: The import path of the function that access username from request.
If you want to use custom function to access and process username from request - you can specify it here.
[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
----------------------------------------
``defender`` can be used for authentication other than ``Django authentication system``.
E.g. if ``django-rest-framework`` authentication has to be protected from brute force attack, a custom authentication method can be implemented.
There's sample ``BasicAuthenticationDefender`` class based on ``djangorestframework.BasicAuthentication``\ :
.. code-block:: python
import base64
import binascii
from django.utils.translation import gettext_lazy as _
from rest_framework import HTTP_HEADER_ENCODING, exceptions
from rest_framework.authentication import (
BasicAuthentication,
get_authorization_header,
)
from defender import utils
from defender import config
class BasicAuthenticationDefender(BasicAuthentication):
def get_username_from_request(self, request):
auth = get_authorization_header(request).split()
return base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':')[0]
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() != b'basic':
return None
if len(auth) == 1:
msg = _('Invalid basic header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid basic header. Credentials string should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)
if utils.is_already_locked(request, get_username=self.get_username_from_request):
detail = "You have attempted to login {failure_limit} times, with no success." \
"Your account is locked for {cooloff_time_seconds} seconds" \
"".format(
failure_limit=config.FAILURE_LIMIT,
cooloff_time_seconds=config.LOCKOUT_COOLOFF_TIME[
defender_utils.get_lockout_cooloff_time(username=self.get_username_from_request(request))
]
)
raise exceptions.AuthenticationFailed(_(detail))
try:
auth_parts = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':')
except (TypeError, UnicodeDecodeError, binascii.Error):
msg = _('Invalid basic header. Credentials not correctly base64 encoded.')
raise exceptions.AuthenticationFailed(msg)
userid, password = auth_parts[0], auth_parts[2]
login_unsuccessful = False
login_exception = None
try:
response = self.authenticate_credentials(userid, password)
except exceptions.AuthenticationFailed as e:
login_unsuccessful = True
login_exception = e
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
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
--------------
``django-defender`` will send signals when blocking a username or an IP address. To set up signal receiver functions:
.. code-block:: python
from django.dispatch import receiver
from defender import signals
@receiver(signals.username_block)
def username_blocked(username, **kwargs):
print("%s was blocked!" % username)
@receiver(signals.ip_block)
def ip_blocked(ip_address, **kwargs):
print("%s was blocked!" % ip_address)
Running tests
-------------
Tests can be run, after you clone the repository and having Django installed,
like:
.. code-block:: bash
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) test defender --settings=defender.test_settings
Releasing
---------
#. ``python setup.py sdist``
#. ``twine upload dist/*``

View file

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

View file

@ -4,29 +4,26 @@ from .models import AccessAttempt
class AccessAttemptAdmin(admin.ModelAdmin):
""" Access attempt admin config """
list_display = (
'attempt_time',
'ip_address',
'user_agent',
'username',
'path_info',
'login_valid',
"attempt_time",
"ip_address",
"user_agent",
"username",
"path_info",
"login_valid",
)
search_fields = [
'ip_address',
'username',
"ip_address",
"username",
]
date_hierarchy = 'attempt_time'
date_hierarchy = "attempt_time"
fieldsets = (
(None, {
'fields': ('path_info', 'login_valid')
}),
('Meta Data', {
'fields': ('user_agent', 'ip_address')
})
(None, {"fields": ("path_info", "login_valid")}),
("Meta Data", {"fields": ("user_agent", "ip_address")}),
)

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"

77
defender/ci_settings.py Normal file
View file

@ -0,0 +1,77 @@
import os
import django
from celery import Celery
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
CACHES = {
"default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://localhost:6379",}
}
SITE_ID = 1
MIDDLEWARE = (
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"defender.middleware.FailedLoginMiddleware",
)
ROOT_URLCONF = "defender.test_urls"
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.admin",
"defender",
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.request",
],
},
},
]
SECRET_KEY = os.environ.get("SECRET_KEY", "too-secret-for-test")
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 CI.
DEFENDER_MOCK_REDIS = False
# Celery settings:
CELERY_ALWAYS_EAGER = True
BROKER_BACKEND = "memory"
BROKER_URL = "memory://"
# set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "defender.ci_settings")
app = Celery("defender")
# Using a string here means the worker will not have to
# pickle the object when using Windows.
app.config_from_object("django.conf:settings")
app.autodiscover_tasks(lambda: INSTALLED_APPS)

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):
@ -9,76 +9,104 @@ def get_setting(variable, default=None):
# redis server host
DEFENDER_REDIS_URL = get_setting('DEFENDER_REDIS_URL')
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')
DEFENDER_REDIS_NAME = get_setting("DEFENDER_REDIS_NAME")
MOCK_REDIS = get_setting('DEFENDER_MOCK_REDIS', False)
MOCK_REDIS = get_setting("DEFENDER_MOCK_REDIS", False)
# see if the user has overridden the failure limit
FAILURE_LIMIT = get_setting('DEFENDER_LOGIN_FAILURE_LIMIT', 3)
USERNAME_FAILURE_LIMIT = get_setting('DEFENDER_LOGIN_FAILURE_LIMIT_USERNAME', FAILURE_LIMIT)
IP_FAILURE_LIMIT = get_setting('DEFENDER_LOGIN_FAILURE_LIMIT_IP', FAILURE_LIMIT)
FAILURE_LIMIT = get_setting("DEFENDER_LOGIN_FAILURE_LIMIT", 3)
USERNAME_FAILURE_LIMIT = get_setting(
"DEFENDER_LOGIN_FAILURE_LIMIT_USERNAME", FAILURE_LIMIT
)
IP_FAILURE_LIMIT = get_setting("DEFENDER_LOGIN_FAILURE_LIMIT_IP", FAILURE_LIMIT)
# If this is True, the lockout checks to evaluate if the IP failure limit and
# the username failure limit has been reached before issuing the lockout.
LOCKOUT_BY_IP_USERNAME = get_setting(
'DEFENDER_LOCK_OUT_BY_IP_AND_USERNAME', False)
LOCKOUT_BY_IP_USERNAME = get_setting("DEFENDER_LOCK_OUT_BY_IP_AND_USERNAME", False)
# if this is True, The users IP address will not get locked when
# there are too many login attempts.
DISABLE_IP_LOCKOUT = get_setting('DEFENDER_DISABLE_IP_LOCKOUT', False)
DISABLE_IP_LOCKOUT = get_setting("DEFENDER_DISABLE_IP_LOCKOUT", False)
# If this is True, usernames will not get locked when
# there are too many login attempts.
DISABLE_USERNAME_LOCKOUT = get_setting(
'DEFENDER_DISABLE_USERNAME_LOCKOUT', False)
DISABLE_USERNAME_LOCKOUT = get_setting("DEFENDER_DISABLE_USERNAME_LOCKOUT", False)
# use a specific username field to retrieve from login POST data
USERNAME_FORM_FIELD = get_setting('DEFENDER_USERNAME_FORM_FIELD', 'username')
USERNAME_FORM_FIELD = get_setting("DEFENDER_USERNAME_FORM_FIELD", "username")
# see if the django app is sitting behind a reverse proxy
BEHIND_REVERSE_PROXY = get_setting('DEFENDER_BEHIND_REVERSE_PROXY', False)
BEHIND_REVERSE_PROXY = get_setting("DEFENDER_BEHIND_REVERSE_PROXY", False)
# the prefix for these keys in your cache.
CACHE_PREFIX = get_setting('DEFENDER_CACHE_PREFIX', 'defender')
CACHE_PREFIX = get_setting("DEFENDER_CACHE_PREFIX", "defender")
# if the django app is behind a reverse proxy, look for the
# ip address using this HTTP header value
REVERSE_PROXY_HEADER = get_setting('DEFENDER_REVERSE_PROXY_HEADER',
'HTTP_X_FORWARDED_FOR')
REVERSE_PROXY_HEADER = get_setting(
"DEFENDER_REVERSE_PROXY_HEADER", "HTTP_X_FORWARDED_FOR"
)
try:
# how long to wait before the bad login attempt gets forgotten. in seconds.
COOLOFF_TIME = int(get_setting('DEFENDER_COOLOFF_TIME', 300)) # 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
raise Exception("DEFENDER_COOLOFF_TIME needs to be an integer") # pragma: no cover
LOCKOUT_TEMPLATE = get_setting('DEFENDER_LOCKOUT_TEMPLATE')
LOCKOUT_TEMPLATE = get_setting("DEFENDER_LOCKOUT_TEMPLATE")
ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. "
"Note that both fields are case-sensitive.")
ERROR_MESSAGE = gettext_lazy(
"Please enter a correct username and password. "
"Note that both fields are case-sensitive."
)
LOCKOUT_URL = get_setting('DEFENDER_LOCKOUT_URL')
LOCKOUT_URL = get_setting("DEFENDER_LOCKOUT_URL")
USE_CELERY = get_setting('DEFENDER_USE_CELERY', False)
USE_CELERY = get_setting("DEFENDER_USE_CELERY", False)
STORE_ACCESS_ATTEMPTS = get_setting('DEFENDER_STORE_ACCESS_ATTEMPTS', True)
STORE_ACCESS_ATTEMPTS = get_setting("DEFENDER_STORE_ACCESS_ATTEMPTS", True)
# Used by the management command to decide how long to keep access attempt
# recods. Number is # of hours.
try:
ACCESS_ATTEMPT_EXPIRATION = int(get_setting(
'DEFENDER_ACCESS_ATTEMPT_EXPIRATION', 24))
ACCESS_ATTEMPT_EXPIRATION = int(
get_setting("DEFENDER_ACCESS_ATTEMPT_EXPIRATION", 24)
)
except ValueError: # pragma: no cover
raise Exception(
'DEFENDER_ACCESS_ATTEMPT_EXPIRATION'
' needs to be an integer') # pragma: no cover
"DEFENDER_ACCESS_ATTEMPT_EXPIRATION" " needs to be an integer"
) # pragma: no cover
GET_USERNAME_FROM_REQUEST_PATH = get_setting(
'DEFENDER_GET_USERNAME_FROM_REQUEST_PATH',
'defender.utils.username_from_request'
"DEFENDER_GET_USERNAME_FROM_REQUEST_PATH", "defender.utils.username_from_request"
)

View file

@ -1,32 +1,29 @@
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
# Register database schemes in URLs.
urlparse.uses_netloc.append("redis")
INVALID_CACHE_ERROR_MSG = 'The cache {} was not found on the django cache' \
' settings.'
INVALID_CACHE_ERROR_MSG = "The cache {} was not found on the django cache" " settings."
def get_redis_connection():
""" Get the redis connection if not using mock """
if config.MOCK_REDIS: # pragma: no cover
import mockredis
return mockredis.mock_strict_redis_client() # pragma: no cover
elif config.DEFENDER_REDIS_NAME: # pragma: no cover
try:
cache = caches[config.DEFENDER_REDIS_NAME]
except InvalidCacheBackendError:
raise KeyError(INVALID_CACHE_ERROR_MSG.format(
config.DEFENDER_REDIS_NAME))
raise KeyError(INVALID_CACHE_ERROR_MSG.format(config.DEFENDER_REDIS_NAME))
# every redis backend implement it own way to get the low level client
try:
# redis_cache.RedisCache case (django-redis-cache package)
@ -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,8 +1,15 @@
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(user_agent, ip_address, username,
http_accept, path_info, login_valid):
def store_login_attempt(
user_agent, ip_address, username, http_accept, path_info, login_valid
):
""" Store the login attempt to the db. """
AccessAttempt.objects.create(
user_agent=user_agent,
@ -12,3 +19,58 @@ def store_login_attempt(user_agent, ip_address, username,
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

@ -3,8 +3,7 @@ from . import utils
import functools
def watch_login(status_code=302, msg='',
get_username=utils.get_username_from_request):
def watch_login(status_code=302, msg="", get_username=utils.get_username_from_request):
"""
Used to decorate the django.contrib.admin.site.login method or
any other function you want to protect by brute forcing.
@ -12,46 +11,51 @@ def watch_login(status_code=302, msg='',
indicate a failure and/or a string that will be checked within the
response body.
"""
def decorated_login(func):
@functools.wraps(func)
def wrapper(request, *args, **kwargs):
# 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)
if request.method == 'POST':
if request.method == "POST":
# see if the login was successful
if status_code == 302: # standard Django login view
login_unsuccessful = (
response and
not response.has_header('location') and
response.status_code != status_code
response
and not response.has_header("location")
and response.status_code != status_code
)
else:
# If msg is not passed the last condition will be evaluated
# always to True so the first 2 will decide the result.
login_unsuccessful = (
response and response.status_code == status_code
and msg in response.content.decode('utf-8')
response
and response.status_code == status_code
and msg in response.content.decode("utf-8")
)
# 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)
utils.add_login_attempt_to_db(
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
return wrapper
return decorated_login

View file

@ -1,83 +0,0 @@
import os
from celery import Celery
PROJECT_DIR = lambda base: os.path.abspath(
os.path.join(os.path.dirname(__file__), base).replace('\\', '/'))
MEDIA_ROOT = PROJECT_DIR(os.path.join('media'))
MEDIA_URL = '/media/'
STATIC_ROOT = PROJECT_DIR(os.path.join('static'))
STATIC_URL = '/static/'
STATICFILES_DIRS = (
PROJECT_DIR(os.path.join('media', 'static')),
)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': PROJECT_DIR('defender.sb'),
}
}
SITE_ID = 1
MIDDLEWARE = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'defender.middleware.FailedLoginMiddleware',
)
ROOT_URLCONF = 'defender.exampleapp.urls'
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.admin',
'django.contrib.staticfiles',
'defender',
]
# List of finder classes that know how to find static files in
# various locations.
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
)
SECRET_KEY = os.environ.get('SECRET_KEY', 'too-secret-for-test')
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.
DEFENDER_MOCK_REDIS = False
# Let's use custom function and strip username string from request.
DEFENDER_GET_USERNAME_FROM_REQUEST_PATH = (
'defender.exampleapp.utils.strip_username_from_request'
)
# Celery settings:
CELERY_ALWAYS_EAGER = True
BROKER_BACKEND = 'memory'
BROKER_URL = 'memory://'
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'defender.exampleapp.settings')
app = Celery('defender')
# Using a string here means the worker will not have to
# pickle the object when using Windows.
app.config_from_object('django.conf:settings')
app.autodiscover_tasks(lambda: INSTALLED_APPS)
DEBUG = True

View file

@ -1,4 +1,3 @@
from __future__ import print_function
from datetime import timedelta
from django.core.management.base import BaseCommand
@ -10,6 +9,7 @@ from ... import config
class Command(BaseCommand):
""" clean up management command """
help = "Cleans up django-defender AccessAttempt table"
def handle(self, **options):
@ -31,5 +31,6 @@ class Command(BaseCommand):
print(
"Finished. Removed {0} AccessAttempt entries.".format(
attempts_to_clean_count)
attempts_to_clean_count
)
)

View file

@ -1,31 +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,31 +1,39 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
""" Initial migrations """
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='AccessAttempt',
name="AccessAttempt",
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('user_agent', models.CharField(max_length=255)),
('ip_address', models.GenericIPAddressField(null=True, verbose_name='IP Address')),
('username', models.CharField(max_length=255, null=True)),
('http_accept', models.CharField(max_length=1025, verbose_name='HTTP Accept')),
('path_info', models.CharField(max_length=255, verbose_name='Path')),
('attempt_time', models.DateTimeField(auto_now_add=True)),
('login_valid', models.BooleanField(default=False)),
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("user_agent", models.CharField(max_length=255)),
(
"ip_address",
models.GenericIPAddressField(null=True, verbose_name="IP Address"),
),
("username", models.CharField(max_length=255, null=True)),
(
"http_accept",
models.CharField(max_length=1025, verbose_name="HTTP Accept"),
),
("path_info", models.CharField(max_length=255, verbose_name="Path")),
("attempt_time", models.DateTimeField(auto_now_add=True)),
("login_valid", models.BooleanField(default=False)),
],
options={
'ordering': ['-attempt_time'],
},
options={"ordering": ["-attempt_time"],},
bases=(models.Model,),
),
]

View file

@ -1,43 +1,22 @@
from __future__ import unicode_literals
from django.db import models
from django.utils.encoding import python_2_unicode_compatible
@python_2_unicode_compatible
class AccessAttempt(models.Model):
""" Access Attempt log """
user_agent = models.CharField(
max_length=255,
)
ip_address = models.GenericIPAddressField(
verbose_name='IP Address',
null=True,
)
username = models.CharField(
max_length=255,
null=True,
)
http_accept = models.CharField(
verbose_name='HTTP Accept',
max_length=1025,
)
path_info = models.CharField(
verbose_name='Path',
max_length=255,
)
attempt_time = models.DateTimeField(
auto_now_add=True,
)
login_valid = models.BooleanField(
default=False,
)
user_agent = models.CharField(max_length=255,)
ip_address = models.GenericIPAddressField(verbose_name="IP Address", null=True,)
username = models.CharField(max_length=255, null=True,)
http_accept = models.CharField(verbose_name="HTTP Accept", max_length=1025,)
path_info = models.CharField(verbose_name="Path", max_length=255,)
attempt_time = models.DateTimeField(auto_now_add=True,)
login_valid = models.BooleanField(default=False,)
class Meta:
ordering = ['-attempt_time']
ordering = ["-attempt_time"]
def __str__(self):
""" unicode value for this model """
return "{0} @ {1} | {2}".format(self.username,
self.attempt_time,
self.login_valid)
return "{0} @ {1} | {2}".format(
self.username, self.attempt_time, self.login_valid
)

View file

@ -1,17 +1,31 @@
from django.dispatch import Signal
username_block = Signal(providing_args=['username'])
ip_block = 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:
"""
Providing a sender is mandatory when sending signals, hence
this empty sender class.
"""
pass
def send_username_block_signal(username):
username_block.send(sender=BlockSignal, username=username)
def send_username_unblock_signal(username):
username_unblock.send(sender=BlockSignal, username=username)
def send_ip_block_signal(ip_address):
ip_block.send(sender=BlockSignal, ip_address=ip_address)
def send_ip_unblock_signal(ip_address):
ip_unblock.send(sender=BlockSignal, ip_address=ip_address)

View file

@ -1,45 +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(u'defender_accessattempt', (
(u'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(u'defender', ['AccessAttempt'])
def backwards(self, orm):
# Deleting model 'AccessAttempt'
db.delete_table(u'defender_accessattempt')
models = {
u'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'}),
u'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,14 +1,15 @@
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):
def add_login_attempt_task(
user_agent, ip_address, username, http_accept, path_info, login_valid
):
""" Create a record for the login attempt """
store_login_attempt(user_agent, ip_address, username,
http_accept, path_info, login_valid)
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,20 +3,22 @@ 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()
class DefenderTransactionTestCase(DefenderTestCaseMixin, TransactionTestCase):
"""Helper TransactionTestCase that cleans the cache after each test"""
pass
class DefenderTestCase(DefenderTestCaseMixin, TestCase):
"""Helper TestCase that cleans the cache after each test"""
pass

View file

@ -1,56 +1,56 @@
import os
import django
from celery import Celery
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
SITE_ID = 1
MIDDLEWARE = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'defender.middleware.FailedLoginMiddleware',
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"defender.middleware.FailedLoginMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
)
ROOT_URLCONF = 'defender.test_urls'
ROOT_URLCONF = "defender.test_urls"
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.admin',
'defender',
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.admin",
"defender",
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.debug',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.request",
],
},
},
]
SECRET_KEY = os.environ.get('SECRET_KEY', 'too-secret-for-test')
SECRET_KEY = os.environ.get("SECRET_KEY", "too-secret-for-test")
LOGIN_REDIRECT_URL = '/admin'
LOGIN_REDIRECT_URL = "/admin"
DEFENDER_LOGIN_FAILURE_LIMIT = 10
DEFENDER_COOLOFF_TIME = 2
@ -60,15 +60,15 @@ DEFENDER_MOCK_REDIS = True
# celery settings
CELERY_ALWAYS_EAGER = True
BROKER_BACKEND = 'memory'
BROKER_URL = 'memory://'
BROKER_BACKEND = "memory"
BROKER_URL = "memory://"
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'defender.test_settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "defender.test_settings")
app = Celery('defender')
app = Celery("defender")
# Using a string here means the worker will not have to
# pickle the object when using Windows.
app.config_from_object('django.conf:settings')
app.config_from_object("django.conf:settings")
app.autodiscover_tasks(lambda: INSTALLED_APPS)

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,81 +0,0 @@
import os
from celery import Celery
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
CACHES = {
'default': {
'BACKEND': 'redis_cache.RedisCache',
'LOCATION': 'localhost:6379',
}
}
SITE_ID = 1
MIDDLEWARE = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'defender.middleware.FailedLoginMiddleware',
)
ROOT_URLCONF = 'defender.test_urls'
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.admin',
'defender',
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.debug',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
],
},
},
]
SECRET_KEY = os.environ.get('SECRET_KEY', 'too-secret-for-test')
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.
DEFENDER_MOCK_REDIS = False
# Celery settings:
CELERY_ALWAYS_EAGER = True
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')
app = Celery('defender')
# Using a string here means the worker will not have to
# pickle the object when using Windows.
app.config_from_object('django.conf:settings')
app.autodiscover_tasks(lambda: INSTALLED_APPS)

View file

@ -1,12 +1,16 @@
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$', unblock_ip_view,
name="defender_unblock_ip_view"),
url(r'^blocks/username/(?P<username>[\w]+[^\/]*)/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",
),
path(
"blocks/username/<path:username>/unblock",
unblock_username_view,
name="defender_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,8 +12,17 @@ from django.utils.module_loading import import_string
from .connection import get_redis_connection
from . import config
from .data import store_login_attempt
from .signals import send_username_block_signal, send_ip_block_signal
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,
send_username_unblock_signal,
send_ip_unblock_signal,
)
REDIS_SERVER = get_redis_connection()
@ -32,21 +44,57 @@ def is_valid_ip(ip_address):
def get_ip_address_from_request(request):
""" Makes the best attempt to get the client's real IP or return
the loopback """
remote_addr = request.META.get('REMOTE_ADDR', '')
remote_addr = request.META.get("REMOTE_ADDR", "")
if remote_addr and is_valid_ip(remote_addr):
return remote_addr.strip()
return '127.0.0.1'
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 = request.META.get(config.REVERSE_PROXY_HEADER, "")
ip_address = ip_address.split(",", 1)[0].strip()
if ip_address == '':
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
@ -67,8 +115,9 @@ def get_ip_attempt_cache_key(ip_address):
def get_username_attempt_cache_key(username):
""" get the cache key by username """
return "{0}:failed:username:{1}".format(config.CACHE_PREFIX,
lower_username(username))
return "{0}:failed:username:{1}".format(
config.CACHE_PREFIX, lower_username(username)
)
def get_ip_blocked_cache_key(ip_address):
@ -78,8 +127,16 @@ def get_ip_blocked_cache_key(ip_address):
def get_username_blocked_cache_key(username):
""" get the cache key by username """
return "{0}:blocked:username:{1}".format(config.CACHE_PREFIX,
lower_username(username))
return "{0}:blocked:username:{1}".format(
config.CACHE_PREFIX, lower_username(username)
)
def remove_prefix(string, prefix):
if string.startswith(prefix):
return string[len(prefix):]
return string
def strip_keys(key_list):
@ -88,14 +145,25 @@ def strip_keys(key_list):
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():
@ -104,8 +172,7 @@ def get_blocked_ips():
# There are no blocked IP's since we disabled them.
return []
key = get_ip_blocked_cache_key("*")
key_list = [redis_key.decode('utf-8')
for redis_key in REDIS_SERVER.keys(key)]
key_list = [redis_key.decode("utf-8") for redis_key in REDIS_SERVER.keys(key)]
return strip_keys(key_list)
@ -115,8 +182,7 @@ def get_blocked_usernames():
# There are no blocked usernames since we disabled them.
return []
key = get_username_blocked_cache_key("*")
key_list = [redis_key.decode('utf-8')
for redis_key in REDIS_SERVER.keys(key)]
key_list = [redis_key.decode("utf-8") for redis_key in REDIS_SERVER.keys(key)]
return strip_keys(key_list)
@ -124,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
@ -133,13 +199,11 @@ 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
get_username_from_request = import_string(
config.GET_USERNAME_FROM_REQUEST_PATH
)
get_username_from_request = import_string(config.GET_USERNAME_FROM_REQUEST_PATH)
def get_user_attempts(request, get_username=get_username_from_request, username=None):
@ -164,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 """
@ -173,12 +246,15 @@ def block_ip(ip_address):
if config.DISABLE_IP_LOCKOUT:
# no need to block, we disabled it.
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')
send_ip_block_signal(ip_address)
REDIS_SERVER.set(key, "blocked")
if not already_blocked:
send_ip_block_signal(ip_address)
def block_username(username):
@ -189,12 +265,15 @@ def block_username(username):
if config.DISABLE_USERNAME_LOCKOUT:
# no need to block, we disabled it.
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')
send_username_block_signal(username)
REDIS_SERVER.set(key, "blocked")
if not already_blocked:
send_username_block_signal(username)
def record_failed_attempt(ip_address, username):
@ -254,6 +333,11 @@ def unblock_ip(ip_address, pipe=None):
pipe.delete(get_ip_blocked_cache_key(ip_address))
if do_commit:
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):
@ -267,6 +351,11 @@ def unblock_username(username, pipe=None):
pipe.delete(get_username_blocked_cache_key(username))
if do_commit:
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):
@ -274,31 +363,43 @@ def reset_failed_attempts(ip_address=None, username=None):
"""
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,
'failure_limit': config.FAILURE_LIMIT,
"cooloff_time_seconds": cooloff_time,
"cooloff_time_minutes": cooloff_time / 60,
"failure_limit": config.FAILURE_LIMIT,
}
return render(request, config.LOCKOUT_TEMPLATE, context)
if config.LOCKOUT_URL:
return HttpResponseRedirect(config.LOCKOUT_URL)
if config.COOLOFF_TIME:
return HttpResponse("Account locked: too many login attempts. "
"Please try again later.")
if get_lockout_cooloff_time(ip_address=ip_address, username=username):
return HttpResponse(
"Account locked: too many login attempts. " "Please try again later."
)
else:
return HttpResponse("Account locked: too many login attempts. "
"Contact an admin to unlock your account.")
return HttpResponse(
"Account locked: too many login attempts. "
"Contact an admin to unlock your account."
)
def is_user_already_locked(username):
@ -332,9 +433,9 @@ def is_already_locked(request, get_username=get_username_from_request, username=
return ip_blocked or user_blocked
def check_request(request, login_unsuccessful,
get_username=get_username_from_request,
username=None):
def check_request(
request, login_unsuccessful, get_username=get_username_from_request, username=None
):
""" check the request, and process results"""
ip_address = get_ip(request)
username = username or get_username(request)
@ -348,9 +449,9 @@ def check_request(request, login_unsuccessful,
return record_failed_attempt(ip_address, username)
def add_login_attempt_to_db(request, login_valid,
get_username=get_username_from_request,
username=None):
def add_login_attempt_to_db(
request, login_valid, get_username=get_username_from_request, username=None
):
""" Create a record for the login attempt If using celery call celery
task, if not, call the method normally """
@ -360,15 +461,18 @@ def add_login_attempt_to_db(request, login_valid,
username = username or get_username(request)
user_agent = request.META.get('HTTP_USER_AGENT', '<unknown>')[:255]
user_agent = request.META.get("HTTP_USER_AGENT", "<unknown>")[:255]
ip_address = get_ip(request)
http_accept = request.META.get('HTTP_ACCEPT', '<unknown>')
path_info = request.META.get('PATH_INFO', '<unknown>')
http_accept = request.META.get("HTTP_ACCEPT", "<unknown>")
path_info = request.META.get("PATH_INFO", "<unknown>")
if config.USE_CELERY:
from .tasks import add_login_attempt_task
add_login_attempt_task.delay(user_agent, ip_address, username,
http_accept, path_info, login_valid)
add_login_attempt_task.delay(
user_agent, ip_address, username, http_accept, path_info, login_valid
)
else:
store_login_attempt(user_agent, ip_address, username,
http_accept, path_info, login_valid)
store_login_attempt(
user_agent, ip_address, username, http_accept, path_info, login_valid
)

View file

@ -1,13 +1,11 @@
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
try:
from django.urls import reverse
except ImportError:
from django.core.urlresolvers import reverse
from django.urls import reverse
from .utils import (
get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username)
from .utils import get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username
@staff_member_required
@ -16,15 +14,19 @@ def block_view(request):
blocked_ip_list = get_blocked_ips()
blocked_username_list = get_blocked_usernames()
context = {'blocked_ip_list': blocked_ip_list,
'blocked_username_list': blocked_username_list}
return render(request, 'defender/admin/blocks.html', 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)
@staff_member_required
def unblock_ip_view(request, ip_address):
""" upblock the given ip """
if request.method == 'POST':
if request.method == "POST":
unblock_ip(ip_address)
return HttpResponseRedirect(reverse("defender_blocks_view"))
@ -32,6 +34,6 @@ def unblock_ip_view(request, ip_address):
@staff_member_required
def unblock_username_view(request, username):
""" unblockt he given username """
if request.method == 'POST':
if request.method == "POST":
unblock_username(username)
return HttpResponseRedirect(reverse("defender_blocks_view"))

20
docs/Makefile Normal file
View file

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

67
docs/conf.py Normal file
View file

@ -0,0 +1,67 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath("."))
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 = "2024, Ken Cochrane"
author = "Ken Cochrane"
# The full version, including alpha/beta/rc tags.
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])
# -- General configuration ---------------------------------------------------
# Explicitly specify the master doc file for Sphinx 1.x
master_doc = "index"
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named "sphinx.ext.*") or your custom
# ones.
extensions = []
# Add any paths that contain templates here, relative to this directory.
templates_path = []
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
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 = []

8
docs/index.rst Normal file
View file

@ -0,0 +1,8 @@
.. django-defender documentation master file, created by
sphinx-quickstart on Fri Nov 8 18:52:29 2019.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
.. include:: ../README.rst
.. include:: ../CONTRIBUTING.rst
.. include:: ../CHANGES.rst

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=defender.exampleapp.settings
PYTHONPATH=$PYTHONPATH:$PWD django-admin collectstatic --noinput --settings=exampleapp.settings
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py runserver --settings=defender.exampleapp.settings
PYTHONPATH=$PYTHONPATH:$PWD django-admin runserver --settings=exampleapp.settings
```

98
exampleapp/settings.py Normal file
View file

@ -0,0 +1,98 @@
import os
from celery import Celery
PROJECT_DIR = lambda base: os.path.abspath(
os.path.join(os.path.dirname(__file__), base).replace("\\", "/")
)
MEDIA_ROOT = PROJECT_DIR(os.path.join("media"))
MEDIA_URL = "/media/"
STATIC_ROOT = PROJECT_DIR(os.path.join("static"))
STATIC_URL = "/static/"
STATICFILES_DIRS = (PROJECT_DIR(os.path.join("media", "static")),)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": PROJECT_DIR("defender.sb"),
}
}
SITE_ID = 1
MIDDLEWARE = (
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"defender.middleware.FailedLoginMiddleware",
"django.contrib.messages.middleware.MessageMiddleware"
)
ROOT_URLCONF = "exampleapp.urls"
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.admin",
"django.contrib.staticfiles",
"defender",
]
# List of finder classes that know how to find static files in
# various locations.
STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
)
SECRET_KEY = os.environ.get("SECRET_KEY", "too-secret-for-test")
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 CI.
DEFENDER_MOCK_REDIS = False
# Let's use custom function and strip username string from request.
DEFENDER_GET_USERNAME_FROM_REQUEST_PATH = (
"exampleapp.utils.strip_username_from_request"
)
# Celery settings:
CELERY_ALWAYS_EAGER = True
BROKER_BACKEND = "memory"
BROKER_URL = "memory://"
# set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "exampleapp.settings")
app = Celery("defender")
# Using a string here means the worker will not have to
# pickle the object when using Windows.
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()

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
-e .
coverage
mockredispy
django-redis>=5,<6
redis>=5,<6
importlib-metadata<5.0
celery
sphinx_rtd_theme==2.0.0

119
setup.py
View file

@ -1,24 +1,6 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
try:
from setuptools import setup
except ImportError:
from distutils.core import setup
version = '0.6.1'
def get_packages(package):
"""
Return root package and all sub-packages.
"""
return [dirpath
for dirpath, dirnames, filenames in os.walk(package)
if os.path.exists(os.path.join(dirpath, '__init__.py'))]
from setuptools import setup, find_packages
def get_package_data(package):
@ -26,49 +8,68 @@ def get_package_data(package):
Return all files under the root package, that are not in a
package themselves.
"""
walk = [(dirpath.replace(package + os.sep, '', 1), filenames)
for dirpath, dirnames, filenames in os.walk(package)
if not os.path.exists(os.path.join(dirpath, '__init__.py'))]
walk = [
(dirpath.replace(package + os.sep, "", 1), filenames)
for dirpath, dirnames, filenames in os.walk(package)
if not os.path.exists(os.path.join(dirpath, "__init__.py"))
]
filepaths = []
for base, filenames in walk:
filepaths.extend([os.path.join(base, filename)
for filename in filenames])
filepaths.extend([os.path.join(base, filename) for filename in filenames])
return {package: filepaths}
setup(name='django-defender',
version=version,
description="redis based Django app that locks out users after too "
"many failed login attempts.",
long_description="redis based Django app based on speed, that locks out"
"users after too many failed login attempts.",
classifiers=[
'Development Status :: 5 - Production/Stable',
'Framework :: Django',
'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.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: Implementation :: PyPy',
'Programming Language :: Python :: Implementation :: CPython',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Security',
'Topic :: Software Development :: Libraries',
'Topic :: Software Development :: Libraries :: Python Modules', ],
keywords='django, cache, security, authentication, throttle, login',
author='Ken Cochrane',
url='https://github.com/kencochrane/django-defender',
author_email='kencochrane@gmail.com',
license='Apache 2',
include_package_data=True,
packages=get_packages('defender'),
package_data=get_package_data('defender'),
install_requires=['Django>=1.8,<2.2', 'redis>=2.10.3,<=3.2'],
tests_require=['mock', 'mockredispy>=2.9.0.11,<3.0', 'coverage',
'celery', 'django-redis-cache'],
)
setup(
name="django-defender",
use_scm_version=True,
setup_requires=["setuptools_scm"],
description="redis based Django app that locks out users after too "
"many failed login attempts.",
long_description="redis based Django app based on speed, that locks out"
"users after too many failed login attempts.",
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 :: 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",
"Topic :: Security",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
],
keywords="django, cache, security, authentication, throttle, login",
author="Ken Cochrane",
url="https://github.com/kencochrane/django-defender",
author_email="kencochrane@gmail.com",
license="Apache 2",
include_package_data=True,
packages=find_packages(),
package_data=get_package_data("defender"),
python_requires="~=3.8",
install_requires=["Django", "redis>=4.0.0"],
tests_require=[
"mockredispy>=2.9.0.11,<3.0",
"coverage",
"celery",
"django-redis",
],
)

57
tox.ini Normal file
View file

@ -0,0 +1,57 @@
[tox]
envlist =
# 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}
[gh-actions]
python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311
3.12: py312
pypy3: pypy3
[testenv]
deps =
setuptools
-rrequirements.txt
dj32: django>=3.2,<4.0
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 test defender --settings=defender.ci_settings
{envbindir}/coverage report -m
{envbindir}/coverage xml
ignore_outcome =
djmain: True
ignore_errors =
djmain: True
[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:py312-lint]
basepython = python3.12
deps =
twine
check-manifest
setuptools
setuptools_scm
commands =
check-manifest -v
python setup.py sdist
twine check dist/*