mirror of
https://github.com/jazzband/django-defender.git
synced 2026-03-17 06:20:24 +00:00
Compare commits
No commits in common. "master" and "v0.6.1" have entirely different histories.
54 changed files with 1516 additions and 2567 deletions
|
|
@ -1,3 +1,3 @@
|
|||
[run]
|
||||
omit = *_settings.py, defender/*migrations/*, exampleapp/*, *test.py,
|
||||
omit = *_settings.py, defender/*migrations/*, defender/exampleapp/*, *test.py,
|
||||
*__init__.py, *tests.py, *urls.py
|
||||
|
|
|
|||
40
.github/workflows/release.yml
vendored
40
.github/workflows/release.yml
vendored
|
|
@ -1,40 +0,0 @@
|
|||
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
54
.github/workflows/test.yml
vendored
|
|
@ -1,54 +0,0 @@
|
|||
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
5
.gitignore
vendored
|
|
@ -22,7 +22,6 @@ var/
|
|||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.eggs
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
|
|
@ -56,5 +55,5 @@ docs/_build/
|
|||
target/
|
||||
|
||||
# exampleapp
|
||||
exampleapp/static/
|
||||
exampleapp/media/
|
||||
defender/exampleapp/static/
|
||||
defender/exampleapp/media/
|
||||
|
|
|
|||
11
.landscape.yaml
Normal file
11
.landscape.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
doc-warnings: no
|
||||
test-warnings: no
|
||||
strictness: high
|
||||
max-line-length: 80
|
||||
uses:
|
||||
- django
|
||||
- celery
|
||||
autodetect: yes
|
||||
ignore-patterns:
|
||||
- .*_settings.py$
|
||||
- defender/exampleapp/*
|
||||
|
|
@ -1 +0,0 @@
|
|||
repos: []
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# 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
|
||||
41
.travis.yml
Normal file
41
.travis.yml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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
Normal file
101
CHANGES
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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
223
CHANGES.rst
|
|
@ -1,223 +0,0 @@
|
|||
|
||||
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
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
# 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/
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
|
||||
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>`_.
|
||||
14
MANIFEST.in
14
MANIFEST.in
|
|
@ -1,15 +1,5 @@
|
|||
include .coveragerc
|
||||
include CHANGES.rst
|
||||
include CONTRIBUTING.rst
|
||||
include CHANGES
|
||||
include README.md
|
||||
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
Normal file
512
README.md
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
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
|
||||
------------
|
||||
|
||||
[](https://travis-ci.org/kencochrane/django-defender) [](https://coveralls.io/r/kencochrane/django-defender)[](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:
|
||||
------------
|
||||

|
||||
|
||||

|
||||
|
||||
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
805
README.rst
|
|
@ -1,805 +0,0 @@
|
|||
|
||||
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 Django’s ``PasswordResetView`` to prevent too many submissions.
|
||||
|
||||
We need to create some new views that subclass Django’s built-in ``LoginView``, ``PasswordResetView`` & ``PasswordResetConfirmView`` — then use these views in our ``urls.py`` as replacements for Django’s 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 user’s 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 isn’t 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 isn’t 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 isn’t 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 isn’t 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 user’s 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/*``
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
VERSION = (0, 9, 8)
|
||||
|
||||
__version__ = ".".join((map(str, VERSION)))
|
||||
|
|
@ -4,26 +4,29 @@ 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')
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DefenderAppConfig(AppConfig):
|
||||
name = "defender"
|
||||
default_auto_field = "django.db.models.AutoField"
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy
|
||||
from django.utils.translation import ugettext_lazy
|
||||
|
||||
|
||||
def get_setting(variable, default=None):
|
||||
|
|
@ -9,104 +9,76 @@ def get_setting(variable, default=None):
|
|||
|
||||
|
||||
# redis server host
|
||||
DEFENDER_REDIS_URL = get_setting("DEFENDER_REDIS_URL")
|
||||
|
||||
# redis password quote for special character
|
||||
DEFENDER_REDIS_PASSWORD_QUOTE = get_setting("DEFENDER_REDIS_PASSWORD_QUOTE", False)
|
||||
DEFENDER_REDIS_URL = get_setting('DEFENDER_REDIS_URL')
|
||||
|
||||
# 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/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
|
||||
# how long to wait before the bad login attempt gets forgotten. in seconds.
|
||||
COOLOFF_TIME = int(get_setting('DEFENDER_COOLOFF_TIME', 300)) # seconds
|
||||
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 = gettext_lazy(
|
||||
"Please enter a correct username and password. "
|
||||
"Note that both fields are case-sensitive."
|
||||
)
|
||||
ERROR_MESSAGE = ugettext_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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,32 @@
|
|||
import urllib.parse as urlparse
|
||||
|
||||
import redis
|
||||
|
||||
from django.core.cache import caches
|
||||
from django.core.cache.backends.base import InvalidCacheBackendError
|
||||
|
||||
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 . 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)
|
||||
|
|
@ -31,5 +34,46 @@ def get_redis_connection():
|
|||
except AttributeError:
|
||||
# django_redis.cache.RedisCache case (django-redis package)
|
||||
return cache.client.get_client(True)
|
||||
else: # pragma: no cover)
|
||||
return redis.StrictRedis.from_url(config.DEFENDER_REDIS_URL)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,15 +1,8 @@
|
|||
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,
|
||||
|
|
@ -19,58 +12,3 @@ def store_login_attempt(
|
|||
path_info=path_info,
|
||||
login_valid=login_valid,
|
||||
)
|
||||
|
||||
|
||||
def get_approx_lockouts_cache_key(ip_address, username):
|
||||
"""get cache key for approximate number of account lockouts"""
|
||||
return "{0}:approx_lockouts:ip:{1}:user:{2}".format(
|
||||
config.CACHE_PREFIX, ip_address or "", username.lower() if username else ""
|
||||
)
|
||||
|
||||
|
||||
def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=None):
|
||||
"""Get the approximate number of account lockouts in a period of ACCESS_ATTEMPT_EXPIRATION hours.
|
||||
This is approximate because we do not consider the time between these failed
|
||||
login attempts to be relevant.
|
||||
|
||||
Args:
|
||||
ip_address (str, optional): IP address to search for. Can be used in conjunction with username for filtering when DISABLE_IP_LOCKOUT is False. Defaults to None.
|
||||
username (str, optional): Username to search for. Can be used in conjunction with ip_address for filtering when DISABLE_USERNAME_LOCKOUT is False. Defaults to None.
|
||||
|
||||
Returns:
|
||||
int: The minimum of the count of logged failure attempts and the length of the LOCKOUT_COOLOFF_TIMES - 1, or 0 dependant on either configuration or argument parameters (ie. both ip_address and username being None).
|
||||
"""
|
||||
if not config.STORE_ACCESS_ATTEMPTS or not (ip_address or username):
|
||||
# If we're not storing login attempts OR both ip_address and username are
|
||||
# None we should return 0.
|
||||
return 0
|
||||
|
||||
q = Q(attempt_time__gte=timezone.now() - timedelta(hours=config.ACCESS_ATTEMPT_EXPIRATION))
|
||||
failure_limit = config.FAILURE_LIMIT
|
||||
if (ip_address and username and config.LOCKOUT_BY_IP_USERNAME \
|
||||
and not config.DISABLE_IP_LOCKOUT and not config.DISABLE_USERNAME_LOCKOUT
|
||||
):
|
||||
q = q & Q(ip_address=ip_address) & Q(username=username)
|
||||
elif ip_address and not config.DISABLE_IP_LOCKOUT:
|
||||
failure_limit = config.IP_FAILURE_LIMIT
|
||||
q = q & Q(ip_address=ip_address)
|
||||
elif username and not config.DISABLE_USERNAME_LOCKOUT:
|
||||
failure_limit = config.USERNAME_FAILURE_LIMIT
|
||||
q = q & Q(username=username)
|
||||
else:
|
||||
# If we've made it this far and didn't hit one of the other if or elif
|
||||
# conditions, we're in an inappropriate context.
|
||||
raise Exception("Invalid state requested")
|
||||
|
||||
cache_key = get_approx_lockouts_cache_key(ip_address, username)
|
||||
|
||||
redis_client = get_redis_connection()
|
||||
cached_value = redis_client.get(cache_key)
|
||||
if cached_value is not None:
|
||||
return int(cached_value)
|
||||
|
||||
lockouts = AccessAttempt.objects.filter(q).count() // failure_limit
|
||||
|
||||
redis_client.set(cache_key, int(lockouts), 60)
|
||||
|
||||
return lockouts
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ 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.
|
||||
|
|
@ -11,51 +12,46 @@ def watch_login(status_code=302, msg="", get_username=utils.get_username_from_re
|
|||
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
|
||||
username = get_username(request)
|
||||
|
||||
if utils.is_already_locked(request, username=username):
|
||||
return utils.lockout_response(request, username=username)
|
||||
if utils.is_already_locked(request):
|
||||
return utils.lockout_response(request)
|
||||
|
||||
# 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, username=username
|
||||
)
|
||||
utils.add_login_attempt_to_db(request, not login_unsuccessful,
|
||||
get_username)
|
||||
|
||||
if utils.check_request(request, login_unsuccessful, username=username):
|
||||
if utils.check_request(request, login_unsuccessful,
|
||||
get_username):
|
||||
return response
|
||||
|
||||
return utils.lockout_response(request, username=username)
|
||||
return utils.lockout_response(request)
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorated_login
|
||||
|
|
|
|||
|
|
@ -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 collectstatic --noinput --settings=exampleapp.settings
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py collectstatic --noinput --settings=defender.exampleapp.settings
|
||||
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin runserver --settings=exampleapp.settings
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py runserver --settings=defender.exampleapp.settings
|
||||
```
|
||||
83
defender/exampleapp/settings.py
Normal file
83
defender/exampleapp/settings.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from django.urls import include, re_path
|
||||
from django.conf.urls import patterns, include
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
|
|
@ -6,10 +6,11 @@ from django.conf.urls.static import static
|
|||
|
||||
admin.autodiscover()
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r"^admin/defender/", include("defender.urls")),
|
||||
re_path(r"^admin/", admin.site.urls),
|
||||
]
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
(r'^admin/', include(admin.site.urls)),
|
||||
(r'^admin/defender/', include('defender.urls')),
|
||||
)
|
||||
|
||||
|
||||
urlpatterns += staticfiles_urlpatterns()
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from __future__ import print_function
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
|
@ -9,7 +10,6 @@ from ... import config
|
|||
|
||||
class Command(BaseCommand):
|
||||
""" clean up management command """
|
||||
|
||||
help = "Cleans up django-defender AccessAttempt table"
|
||||
|
||||
def handle(self, **options):
|
||||
|
|
@ -31,6 +31,5 @@ class Command(BaseCommand):
|
|||
|
||||
print(
|
||||
"Finished. Removed {0} AccessAttempt entries.".format(
|
||||
attempts_to_clean_count
|
||||
)
|
||||
attempts_to_clean_count)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,27 +1,31 @@
|
|||
from django.contrib.auth.views import LoginView
|
||||
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.utils.decorators import method_decorator
|
||||
|
||||
from .decorators import watch_login
|
||||
|
||||
|
||||
class FailedLoginMiddleware:
|
||||
class FailedLoginMiddleware(MIDDLEWARE_BASE_CLASS):
|
||||
""" Failed login middleware """
|
||||
|
||||
patched = False
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FailedLoginMiddleware, self).__init__(*args, **kwargs)
|
||||
# Watch the auth login.
|
||||
# Monkey-patch only once - otherwise we would be recording
|
||||
# failed attempts multiple times!
|
||||
if not FailedLoginMiddleware.patched:
|
||||
our_decorator = watch_login()
|
||||
watch_login_method = method_decorator(our_decorator)
|
||||
LoginView.dispatch = watch_login_method(LoginView.dispatch)
|
||||
# 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)
|
||||
|
||||
FailedLoginMiddleware.patched = True
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
|
@ -1,39 +1,31 @@
|
|||
# -*- 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,),
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,22 +1,43 @@
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -1,31 +1,17 @@
|
|||
from django.dispatch import Signal
|
||||
|
||||
username_block = Signal() # (providing_args=["username"])
|
||||
username_unblock = Signal() # (providing_args=["username"])
|
||||
ip_block = Signal() # (providing_args=["ip_address"])
|
||||
ip_unblock = Signal() # (providing_args=["ip_address"])
|
||||
|
||||
username_block = Signal(providing_args=['username'])
|
||||
ip_block = 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)
|
||||
|
|
|
|||
45
defender/south_migrations/0001_initial.py
Normal file
45
defender/south_migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# -*- 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']
|
||||
0
defender/south_migrations/__init__.py
Normal file
0
defender/south_migrations/__init__.py
Normal file
|
|
@ -1,15 +1,14 @@
|
|||
from .data import store_login_attempt
|
||||
from . import config
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def add_login_attempt_task(
|
||||
user_agent, ip_address, username, http_accept, path_info, login_valid
|
||||
):
|
||||
@shared_task()
|
||||
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
|
||||
)
|
||||
|
||||
if config.USE_CELERY:
|
||||
from celery import shared_task
|
||||
add_login_attempt_task = shared_task(add_login_attempt_task)
|
||||
store_login_attempt(user_agent, ip_address, username,
|
||||
http_accept, path_info, login_valid)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
{% extends "admin/app_index.html" %}
|
||||
{% extends "admin/index.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if not is_popup %}
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
›
|
||||
{% for app in app_list %}
|
||||
{{ app.name }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
|
||||
{% 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='{{ blocks_url }}'>Blocked Users</a></h4>
|
||||
<h4><a href='{% url 'defender_blocks_view' %}'>Blocked Users</a></h4>
|
||||
</td></tr></table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content%}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,13 @@
|
|||
<div class="breadcrumbs">
|
||||
<a href="{% url "admin:index" %}">Home</a> ›
|
||||
<a href="{% url "admin:app_list" "defender" %}">Defender</a> ›
|
||||
{{ 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">
|
||||
|
|
|
|||
|
|
@ -3,22 +3,20 @@ from django.test.testcases import TestCase, TransactionTestCase
|
|||
from .connection import get_redis_connection
|
||||
|
||||
|
||||
class DefenderTestCaseMixin:
|
||||
class DefenderTestCaseMixin(object):
|
||||
"""Mixin used to provide a common tearDown method"""
|
||||
|
||||
def tearDown(self):
|
||||
"""cleanup django-defender cache after each test"""
|
||||
super().tearDown()
|
||||
super(DefenderTestCaseMixin, self).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
|
||||
|
|
|
|||
|
|
@ -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.contrib.messages.middleware.MessageMiddleware",
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'defender.middleware.FailedLoginMiddleware',
|
||||
)
|
||||
|
||||
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",
|
||||
"django.template.context_processors.request",
|
||||
'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")
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from django.urls import path
|
||||
from django.conf.urls import url, include
|
||||
from django.contrib import admin
|
||||
|
||||
from .urls import urlpatterns as original_urlpatterns
|
||||
|
||||
urlpatterns = [path("admin/", admin.site.urls),] + original_urlpatterns
|
||||
urlpatterns = [
|
||||
url(r'^admin/', admin.site.urls),
|
||||
] + original_urlpatterns
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
81
defender/travis_settings.py
Normal file
81
defender/travis_settings.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
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)
|
||||
|
|
@ -1,16 +1,12 @@
|
|||
from django.urls import path, re_path
|
||||
from django.conf.urls import url
|
||||
from .views import block_view, unblock_ip_view, unblock_username_view
|
||||
|
||||
urlpatterns = [
|
||||
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",
|
||||
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$',
|
||||
unblock_username_view,
|
||||
name="defender_unblock_username_view",
|
||||
),
|
||||
name="defender_unblock_username_view"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
from ipaddress import ip_address
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponseRedirect
|
||||
|
|
@ -12,17 +9,8 @@ from django.utils.module_loading import import_string
|
|||
|
||||
from .connection import get_redis_connection
|
||||
from . import config
|
||||
from .data import (
|
||||
get_approx_account_lockouts_from_login_attempts,
|
||||
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,
|
||||
)
|
||||
from .data import store_login_attempt
|
||||
from .signals import send_username_block_signal, send_ip_block_signal
|
||||
|
||||
REDIS_SERVER = get_redis_connection()
|
||||
|
||||
|
|
@ -44,57 +32,21 @@ 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"
|
||||
|
||||
|
||||
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
|
||||
return '127.0.0.1'
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -115,9 +67,8 @@ 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):
|
||||
|
|
@ -127,16 +78,8 @@ 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)
|
||||
)
|
||||
|
||||
|
||||
def remove_prefix(string, prefix):
|
||||
if string.startswith(prefix):
|
||||
return string[len(prefix):]
|
||||
return string
|
||||
|
||||
return "{0}:blocked:username:{1}".format(config.CACHE_PREFIX,
|
||||
lower_username(username))
|
||||
|
||||
|
||||
def strip_keys(key_list):
|
||||
|
|
@ -145,25 +88,14 @@ def strip_keys(key_list):
|
|||
|
||||
for example:
|
||||
|
||||
[
|
||||
'defender:blocked:ip:192.168.24.24',
|
||||
'defender:blocked:ip:::ffff:192.168.24.24',
|
||||
'defender:blocked:username:joffrey'
|
||||
]
|
||||
['defender:blocked:ip:ken', 'defender:blocked:ip:joffrey']
|
||||
|
||||
would result in:
|
||||
|
||||
[
|
||||
'192.168.24.24',
|
||||
'::ffff:192.168.24.24',
|
||||
'joffrey'
|
||||
]
|
||||
['ken', 'joffrey']
|
||||
|
||||
"""
|
||||
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
|
||||
]
|
||||
return [key.split(":")[-1] for key in key_list]
|
||||
|
||||
|
||||
def get_blocked_ips():
|
||||
|
|
@ -172,7 +104,8 @@ 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)
|
||||
|
||||
|
||||
|
|
@ -182,7 +115,8 @@ 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)
|
||||
|
||||
|
||||
|
|
@ -190,8 +124,8 @@ def increment_key(key):
|
|||
""" given a key increment the value """
|
||||
pipe = REDIS_SERVER.pipeline()
|
||||
pipe.incr(key, 1)
|
||||
if config.ATTEMPT_COOLOFF_TIME:
|
||||
pipe.expire(key, config.ATTEMPT_COOLOFF_TIME)
|
||||
if config.COOLOFF_TIME:
|
||||
pipe.expire(key, config.COOLOFF_TIME)
|
||||
new_value = pipe.execute()[0]
|
||||
return new_value
|
||||
|
||||
|
|
@ -199,11 +133,13 @@ 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].strip()
|
||||
return request.POST[config.USERNAME_FORM_FIELD][:255]
|
||||
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):
|
||||
|
|
@ -228,15 +164,6 @@ 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 """
|
||||
|
|
@ -246,15 +173,12 @@ 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)
|
||||
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address)
|
||||
if cooloff_time:
|
||||
REDIS_SERVER.set(key, "blocked", cooloff_time)
|
||||
if config.COOLOFF_TIME:
|
||||
REDIS_SERVER.set(key, 'blocked', config.COOLOFF_TIME)
|
||||
else:
|
||||
REDIS_SERVER.set(key, "blocked")
|
||||
if not already_blocked:
|
||||
send_ip_block_signal(ip_address)
|
||||
REDIS_SERVER.set(key, 'blocked')
|
||||
send_ip_block_signal(ip_address)
|
||||
|
||||
|
||||
def block_username(username):
|
||||
|
|
@ -265,15 +189,12 @@ 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)
|
||||
cooloff_time = get_lockout_cooloff_time(username=username)
|
||||
if cooloff_time:
|
||||
REDIS_SERVER.set(key, "blocked", cooloff_time)
|
||||
if config.COOLOFF_TIME:
|
||||
REDIS_SERVER.set(key, 'blocked', config.COOLOFF_TIME)
|
||||
else:
|
||||
REDIS_SERVER.set(key, "blocked")
|
||||
if not already_blocked:
|
||||
send_username_block_signal(username)
|
||||
REDIS_SERVER.set(key, 'blocked')
|
||||
send_username_block_signal(username)
|
||||
|
||||
|
||||
def record_failed_attempt(ip_address, username):
|
||||
|
|
@ -333,11 +254,6 @@ 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):
|
||||
|
|
@ -351,11 +267,6 @@ 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):
|
||||
|
|
@ -363,43 +274,31 @@ def reset_failed_attempts(ip_address=None, username=None):
|
|||
"""
|
||||
pipe = REDIS_SERVER.pipeline()
|
||||
|
||||
# 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_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, username):
|
||||
def lockout_response(request):
|
||||
""" 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": cooloff_time,
|
||||
"cooloff_time_minutes": cooloff_time / 60,
|
||||
"failure_limit": config.FAILURE_LIMIT,
|
||||
'cooloff_time_seconds': config.COOLOFF_TIME,
|
||||
'cooloff_time_minutes': config.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 get_lockout_cooloff_time(ip_address=ip_address, username=username):
|
||||
return HttpResponse(
|
||||
"Account locked: too many login attempts. " "Please try again later."
|
||||
)
|
||||
if config.COOLOFF_TIME:
|
||||
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):
|
||||
|
|
@ -433,9 +332,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)
|
||||
|
|
@ -449,9 +348,9 @@ def check_request(
|
|||
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 """
|
||||
|
||||
|
|
@ -461,18 +360,15 @@ def add_login_attempt_to_db(
|
|||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from django.shortcuts import render
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.urls import reverse
|
||||
try:
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
|
||||
from .utils import get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username
|
||||
from .utils import (
|
||||
get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
|
|
@ -14,19 +16,15 @@ def block_view(request):
|
|||
blocked_ip_list = get_blocked_ips()
|
||||
blocked_username_list = get_blocked_usernames()
|
||||
|
||||
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)
|
||||
context = {'blocked_ip_list': blocked_ip_list,
|
||||
'blocked_username_list': blocked_username_list}
|
||||
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"))
|
||||
|
||||
|
|
@ -34,6 +32,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"))
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
# 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
67
docs/conf.py
|
|
@ -1,67 +0,0 @@
|
|||
# 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 = []
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.. 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
|
||||
|
|
@ -1,98 +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",
|
||||
"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",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
-e .
|
||||
coverage
|
||||
mockredispy
|
||||
django-redis>=5,<6
|
||||
redis>=5,<6
|
||||
importlib-metadata<5.0
|
||||
celery
|
||||
sphinx_rtd_theme==2.0.0
|
||||
119
setup.py
119
setup.py
|
|
@ -1,6 +1,24 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
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'))]
|
||||
|
||||
|
||||
def get_package_data(package):
|
||||
|
|
@ -8,68 +26,49 @@ 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",
|
||||
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",
|
||||
],
|
||||
)
|
||||
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'],
|
||||
)
|
||||
|
|
|
|||
57
tox.ini
57
tox.ini
|
|
@ -1,57 +0,0 @@
|
|||
[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/*
|
||||
Loading…
Reference in a new issue