mirror of
https://github.com/jazzband/django-defender.git
synced 2026-03-16 22:10:32 +00:00
Compare commits
98 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa6235caf | ||
|
|
289af19ce9 | ||
|
|
37e5dd3123 | ||
|
|
e420d76463 | ||
|
|
cc35032a0c | ||
|
|
f2dede8c76 | ||
|
|
4e00500537 | ||
|
|
83ad7ce338 | ||
|
|
07555abd29 | ||
|
|
c290b5a673 | ||
|
|
4bea010b65 | ||
|
|
a972dae7fc | ||
|
|
1e0aa91952 | ||
|
|
ba548fa9c3 | ||
|
|
f6c73e093b | ||
|
|
8d4c6840e9 | ||
|
|
2a0469669a | ||
|
|
91dfbde556 | ||
|
|
cc6145b84e | ||
|
|
6111eb81da | ||
|
|
b0f90e690a | ||
|
|
a4b3f9f332 | ||
|
|
d90dfa8db7 | ||
|
|
428968b238 | ||
|
|
ac36751561 | ||
|
|
73d442e31b | ||
|
|
3e9d68dd5c | ||
|
|
afa2066ba0 | ||
|
|
374971bfc5 | ||
|
|
5139005106 | ||
|
|
359ee90082 | ||
|
|
b9b067472c | ||
|
|
de1c876b99 | ||
|
|
717d44aa7d | ||
|
|
1bf9d6e7d1 | ||
|
|
a280c90bc0 | ||
|
|
7121db4b0f | ||
|
|
be523281ab | ||
|
|
2d288b247a | ||
|
|
5db32ed0be | ||
|
|
bbe7687abd | ||
|
|
177f2ecce8 | ||
|
|
dffa7c3ba3 | ||
|
|
7bb1359514 | ||
|
|
0a5011d450 | ||
|
|
bb122f24b9 | ||
|
|
0b96c53245 | ||
|
|
44ecbee250 | ||
|
|
45c4575ccd | ||
|
|
b646e10e6c | ||
|
|
f812343491 | ||
|
|
8fa63e864f | ||
|
|
bc161ff518 | ||
|
|
65f2c2fe3f | ||
|
|
3c7ccc4e99 | ||
|
|
38fd8b6f16 | ||
|
|
95ccba251d | ||
|
|
fdf37feb54 | ||
|
|
6f806b046a | ||
|
|
ef3e9869c2 | ||
|
|
41a68f2f71 | ||
|
|
41d2f6aab7 | ||
|
|
b90e545d20 | ||
|
|
43e6fcdf13 | ||
|
|
079d78bee3 | ||
|
|
7c544d1cf8 | ||
|
|
006ecf1dff | ||
|
|
c8a2586892 | ||
|
|
78302f6b35 | ||
|
|
70c549dd2c | ||
|
|
f358e06a53 | ||
|
|
55ab5c6961 | ||
|
|
e5edaf3b5d | ||
|
|
ab6c1a6358 | ||
|
|
5d625dd273 | ||
|
|
5f5b34d50e | ||
|
|
d413e60847 | ||
|
|
7eb24c5036 | ||
|
|
04252ba415 | ||
|
|
743b538263 | ||
|
|
9ff7f8e9fc | ||
|
|
e762d154ed | ||
|
|
b4a5f886d4 | ||
|
|
9a322c4729 | ||
|
|
cc06ab33fd | ||
|
|
5e6e52fcbb | ||
|
|
1f9b3dec7c | ||
|
|
cbe59ddb7b | ||
|
|
7124e4b156 | ||
|
|
7064c9b012 | ||
|
|
1366adc59f | ||
|
|
0ad3690e6f | ||
|
|
621b78c5d5 | ||
|
|
6354df0a8a | ||
|
|
df0b9db26f | ||
|
|
4fcee56ff2 | ||
|
|
426f01a39e | ||
|
|
71312eb841 |
40 changed files with 1174 additions and 429 deletions
40
.github/workflows/release.yml
vendored
Normal file
40
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'jazzband/django-defender'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install -U setuptools twine wheel
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
python setup.py --version
|
||||
python setup.py sdist --format=gztar bdist_wheel
|
||||
twine check dist/*
|
||||
|
||||
- name: Upload packages to Jazzband
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: jazzband
|
||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||
repository_url: https://jazzband.co/projects/django-defender/upload
|
||||
54
.github/workflows/test.yml
vendored
Normal file
54
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||
redis-version: [5, 6, 7]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Start Redis
|
||||
uses: supercharge/redis-github-action@1.5.0
|
||||
with:
|
||||
redis-version: ${{ matrix.redis-version }}
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Get pip cache dir
|
||||
id: pip-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key:
|
||||
${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }}
|
||||
restore-keys: |
|
||||
${{ matrix.python-version }}-v1-
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --upgrade tox tox-gh-actions
|
||||
|
||||
- name: Tox tests
|
||||
run: |
|
||||
tox -v
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
name: Python ${{ matrix.python-version }}
|
||||
1
.pre-commit-config.yaml
Normal file
1
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
repos: []
|
||||
35
.readthedocs.yaml
Normal file
35
.readthedocs.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Read the Docs configuration file for Sphinx projects
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the OS, Python version and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.12"
|
||||
# You can also specify other tool versions:
|
||||
# nodejs: "20"
|
||||
# rust: "1.70"
|
||||
# golang: "1.20"
|
||||
|
||||
# Build documentation in the "docs/" directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
|
||||
# builder: "dirhtml"
|
||||
# Fail on all warnings to avoid broken references
|
||||
# fail_on_warning: true
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF and ePub
|
||||
# formats:
|
||||
# - pdf
|
||||
# - epub
|
||||
|
||||
# Optional but recommended, declare the Python requirements required
|
||||
# to build your documentation
|
||||
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
||||
python:
|
||||
install:
|
||||
- requirements: requirements.txt
|
||||
28
.travis.yml
28
.travis.yml
|
|
@ -1,28 +0,0 @@
|
|||
language: python
|
||||
dist: xenial
|
||||
cache: pip
|
||||
python:
|
||||
- '2.7'
|
||||
- '3.5'
|
||||
- '3.6'
|
||||
- '3.7'
|
||||
- '3.8'
|
||||
- pypy3
|
||||
services:
|
||||
- redis-server
|
||||
install: pip install tox-travis codecov
|
||||
script: tox
|
||||
after_success:
|
||||
- codecov
|
||||
deploy:
|
||||
provider: pypi
|
||||
user: jazzband
|
||||
server: https://jazzband.co/projects/django-defender/upload
|
||||
distributions: sdist bdist_wheel
|
||||
skip_existing: true
|
||||
password:
|
||||
secure: d/PVFgBDMpG6UvBOxXBLH4vc7+tiPsjhq4YLgXfX0iXbdNHUnnCyDLg/XUQ7x0e7kRj+lhmBzytZxu3IpOY+5MuWk4JTqwoBHGuUlmTC9XEVvioqd/nFW7qVe36YKZHKuMDlmwBNQarJZdSBUFAoCfklZKI09sXA1qc8E3BLAb8=
|
||||
on:
|
||||
tags: true
|
||||
repo: jazzband/django-defender
|
||||
python: 3.7
|
||||
68
CHANGES.rst
68
CHANGES.rst
|
|
@ -2,6 +2,74 @@
|
|||
Changes
|
||||
=======
|
||||
|
||||
0.9.8
|
||||
=====
|
||||
|
||||
- Fix watch_login with custom username (#228) [@ron8mcr]
|
||||
- Replace datetime.now with timezone.now (#232) [@ericls]
|
||||
- Update tox.ini with Django 4.2, Python 3.11 (#233) [@marius-mather]
|
||||
- Use redis parse_url method instead of a custom one (#234) [@dkr-sahar]
|
||||
- Update DEFENDER_REDIS_NAME documentation (#235) [@bennylope]
|
||||
- Prevent the "Reverse for 'defender_blocks_view' not found" error (#237) [@ataylor32]
|
||||
- Updated app_index.html (#238) [@ataylor32]
|
||||
- Improved the "Blocked Logins" page's admin integration (#239) [@ataylor32]
|
||||
|
||||
0.9.7
|
||||
=====
|
||||
|
||||
- Fix bug related to using a redis version less than 6 and not having a password. [@kencochrane]
|
||||
- Fix bug in remove_prefix method [@dashgin]
|
||||
|
||||
0.9.6
|
||||
=====
|
||||
|
||||
- Confirm support for Django 4.1
|
||||
- Add ``DEFENDER_ATTEMPT_COOLOFF_TIME`` config to override ``DEFENDER_COOLOFF_TIME`` specifically for attempt lifespan [@djmore4]
|
||||
- Add ``DEFENDER_LOCKOUT_COOLOFF_TIME`` config to override ``DEFENDER_COOLOFF_TIME`` specifically for lockout duration [@djmore4]
|
||||
|
||||
0.9.5
|
||||
=====
|
||||
|
||||
- Add username support to Redis configuration. [@erdos4d]
|
||||
|
||||
0.9.4
|
||||
-----
|
||||
|
||||
- Remove port number from IP address string when behind reverse proxy [@ndrsn]
|
||||
|
||||
0.9.3
|
||||
-----
|
||||
|
||||
- Drop Python 3.6 support from package specifiers.
|
||||
|
||||
0.9.2
|
||||
-----
|
||||
|
||||
- Drop Python 3.6 support.
|
||||
- Drop Django 3.1 support.
|
||||
- Confirm support for Django 4.0
|
||||
- Confirm support for Python 3.10
|
||||
- Drop Django 2.2 support.
|
||||
|
||||
0.9.1
|
||||
-----
|
||||
|
||||
- Fix failing tests for Django main development branch (Django 4.0) [@JonathanWillitts]
|
||||
|
||||
0.9.0
|
||||
-----
|
||||
|
||||
- Move CI to GitHub Actions.
|
||||
- Drop support for Django 3.0
|
||||
- Add support for Django 3.2
|
||||
|
||||
0.8.0
|
||||
-----
|
||||
|
||||
- FIX: Change setup.py to allow for Django 3.1.x versions [@s4ke]
|
||||
- FIX: dynamic load celery [@balsagoth]
|
||||
- FIX: Redis requirement updated [@flaviomartins]
|
||||
- FIX: if special character in redis password, we can set DEFENDER_REDIS_PASSWORD_QUOTE to True, and use quote password [@calmkart]
|
||||
|
||||
0.7.0
|
||||
-----
|
||||
|
|
|
|||
46
CODE_OF_CONDUCT.md
Normal file
46
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Code of Conduct
|
||||
|
||||
As contributors and maintainers of the Jazzband projects, and in the interest of
|
||||
fostering an open and welcoming community, we pledge to respect all people who
|
||||
contribute through reporting issues, posting feature requests, updating documentation,
|
||||
submitting pull requests or patches, and other activities.
|
||||
|
||||
We are committed to making participation in the Jazzband a harassment-free experience
|
||||
for everyone, regardless of the level of experience, gender, gender identity and
|
||||
expression, sexual orientation, disability, personal appearance, body size, race,
|
||||
ethnicity, age, religion, or nationality.
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery
|
||||
- Personal attacks
|
||||
- Trolling or insulting/derogatory comments
|
||||
- Public or private harassment
|
||||
- Publishing other's private information, such as physical or electronic addresses,
|
||||
without explicit permission
|
||||
- Other unethical or unprofessional conduct
|
||||
|
||||
The Jazzband roadies have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are not
|
||||
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
|
||||
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
By adopting this Code of Conduct, the roadies commit themselves to fairly and
|
||||
consistently applying these principles to every aspect of managing the jazzband
|
||||
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
|
||||
removed from the Jazzband roadies.
|
||||
|
||||
This code of conduct applies both within project spaces and in public spaces when an
|
||||
individual is representing the project or its community.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
|
||||
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
|
||||
investigated and will result in a response that is deemed necessary and appropriate to
|
||||
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
|
||||
reporter of an incident.
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
|
||||
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
|
||||
|
||||
[homepage]: https://contributor-covenant.org
|
||||
[version]: https://contributor-covenant.org/version/1/3/0/
|
||||
|
|
@ -3,8 +3,11 @@ include CHANGES.rst
|
|||
include CONTRIBUTING.rst
|
||||
include LICENSE
|
||||
include README.rst
|
||||
include CODE_OF_CONDUCT.md
|
||||
include requirements.txt
|
||||
include tox.ini
|
||||
include .pre-commit-config.yaml
|
||||
include .readthedocs.yaml
|
||||
recursive-include docs *
|
||||
recursive-include exampleapp *
|
||||
recursive-include defender/templates *.html
|
||||
|
|
|
|||
351
README.rst
351
README.rst
|
|
@ -6,15 +6,23 @@ django-defender
|
|||
:target: https://jazzband.co/
|
||||
:alt: Jazzband
|
||||
|
||||
.. image:: https://travis-ci.org/jazzband/django-defender.svg
|
||||
:target: https://travis-ci.org/jazzband/django-defender
|
||||
:alt: Build Status
|
||||
.. image:: https://img.shields.io/pypi/pyversions/django-defender.svg
|
||||
:alt: Supported Python versions
|
||||
:target: https://pypi.org/project/django-defender/
|
||||
|
||||
.. image:: https://img.shields.io/pypi/djversions/django-defender.svg
|
||||
:target: https://pypi.org/project/django-defender/
|
||||
:alt: Supported Django versions
|
||||
|
||||
.. image:: https://github.com/jazzband/django-defender/workflows/Test/badge.svg
|
||||
:target: https://github.com/jazzband/django-defender/actions
|
||||
:alt: GitHub Actions
|
||||
|
||||
.. image:: https://codecov.io/gh/jazzband/django-defender/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/jazzband/django-defender
|
||||
:alt: Coverage
|
||||
|
||||
.. image:: https://readthedocs.org/projects/python-dockerflow/badge/?version=latest
|
||||
.. image:: https://readthedocs.org/projects/django-defender/badge/?version=latest
|
||||
:alt: Documentation Status
|
||||
:target: https://django-defender.readthedocs.io/en/latest/?badge=latest
|
||||
|
||||
|
|
@ -100,9 +108,9 @@ Admin pages
|
|||
Requirements
|
||||
------------
|
||||
|
||||
* Python: 2.7, 3.5, 3.6, 3.7, 3.8, PyPy
|
||||
* Django: 1.11, 2.1, 2.2
|
||||
* Redis
|
||||
* Python: 3.8, 3.9, 3.10, 3.11, 3.12, PyPy
|
||||
* Django: 3.2, 4.2, 5.0, 5.1, 5.2
|
||||
* Redis: 5.x, 6.x, 7.x
|
||||
|
||||
|
||||
Installation
|
||||
|
|
@ -161,8 +169,8 @@ following to your ``urls.py``
|
|||
.. code-block:: python
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^admin/', include(admin.site.urls)), # normal admin
|
||||
url(r'^admin/defender/', include('defender.urls')), # defender admin
|
||||
path('admin/defender/', include('defender.urls')), # defender admin
|
||||
path('admin/', admin.site.urls), # normal admin
|
||||
# your own patterns follow...
|
||||
]
|
||||
|
||||
|
|
@ -342,12 +350,26 @@ These should be defined in your ``settings.py`` file.
|
|||
* ``DEFENDER_DISABLE_IP_LOCKOUT``\ : Boolean: If this is True, it will not lockout the users IP address, it will only lockout the username. [Default: False]
|
||||
* ``DEFENDER_DISABLE_USERNAME_LOCKOUT``\ : Boolean: If this is True, it will not lockout usernames, it will only lockout IP addresess. [Default: False]
|
||||
* ``DEFENDER_COOLOFF_TIME``\ : Int: If set, defines a period of inactivity after which
|
||||
old failed login attempts will be forgotten. An integer, will be interpreted as a
|
||||
number of seconds. If ``0``\ , the locks will not expire. [Default: ``300``\ ]
|
||||
old failed login attempts and username/ip lockouts will be forgotten. An integer,
|
||||
will be interpreted as a number of seconds. If 0, neither the failed login attempts
|
||||
nor the username/ip locks will expire. [Default: ``300``\ ]
|
||||
* ``DEFENDER_ATTEMPT_COOLOFF_TIME``\ : Int: If set, overrides the period of inactivity
|
||||
after which old failed login attempts will be forgotten set by DEFENDER_COOLOFF_TIME.
|
||||
An integer, will be interpreted as a number of seconds. If 0, the failed login
|
||||
attempts will not expire. [Default: ``DEFENDER_COOLOFF_TIME``\ ]
|
||||
* ``DEFENDER_LOCKOUT_COOLOFF_TIME``\ : Int or List: If set, overrides the period of
|
||||
inactivity after which username/ip lockouts will be forgotten set by
|
||||
DEFENDER_COOLOFF_TIME. An integer, will be interpreted as a number of seconds.
|
||||
A list of integers, will be interpreted as a number of seconds for users with
|
||||
the integer's index being how many previous lockouts (up to some maximum) occurred
|
||||
in the last ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION`` hours. If the property is set to
|
||||
0 or [], the username/ip lockout will not expire. [Default: ``DEFENDER_COOLOFF_TIME``\ ]
|
||||
* ``DEFENDER_LOCKOUT_TEMPLATE``\ : String: [Default: ``None``\ ] If set, specifies a template to render when a user is locked out. Template receives the following context variables:
|
||||
|
||||
* ``cooloff_time_seconds``\ : The cool off time in seconds
|
||||
* ``cooloff_time_minutes``\ : The cool off time in minutes
|
||||
* ``failure_limit``\ : The number of failures before you get blocked.
|
||||
|
||||
* ``DEFENDER_USERNAME_FORM_FIELD``\ : String: the name of the form field that contains your
|
||||
users usernames. [Default: ``username``\ ]
|
||||
* ``DEFENDER_CACHE_PREFIX``\ : String: The cache prefix for your defender keys.
|
||||
|
|
@ -357,7 +379,9 @@ These should be defined in your ``settings.py`` file.
|
|||
* ``DEFENDER_REDIS_URL``\ : String: the redis url for defender.
|
||||
[Default: ``redis://localhost:6379/0``\ ]
|
||||
(Example with password: ``redis://:mypassword@localhost:6379/0``\ )
|
||||
* ``DEFENDER_REDIS_NAME``\ : String: the name of your cache client on the CACHES django setting. If set, ``DEFENDER_REDIS_URL`` will be ignored.
|
||||
* ``DEFENDER_REDIS_PASSWORD_QUOTE``\ : Boolean: if special character in redis password (like '@'), we can quote password ``urllib.parse.quote("password!@#")``, and set to True.
|
||||
[Default: ``False``\ ]
|
||||
* ``DEFENDER_REDIS_NAME``\ : String: the name of the cache from ``CACHES`` in your Django settings (e.g. ``"default"``). If set, ``DEFENDER_REDIS_URL`` will be ignored.
|
||||
[Default: ``None``\ ]
|
||||
* ``DEFENDER_STORE_ACCESS_ATTEMPTS``\ : Boolean: If you want to store the login
|
||||
attempt to the database, set to True. If False, it is not saved
|
||||
|
|
@ -374,6 +398,38 @@ These should be defined in your ``settings.py`` file.
|
|||
[Default: ``defender.utils.username_from_request``\ ]
|
||||
|
||||
|
||||
Rationale for using DEFENDER_ATTEMPT_COOLOFF_TIME and DEFENDER_LOCKOUT_COOLOFF_TIME
|
||||
***********************************************************************************
|
||||
|
||||
While using ``DEFENDER_COOLOFF_TIME`` alone is sufficent for most use cases, when using ``defender`` in some specific scenarios such as in a high security setting, developers may wish to have finer
|
||||
grained control over how long invalid login attempts are "remembered" while under consideration for lockout compared to the time those lockout keys are actually locked out from the system.
|
||||
``DEFENDER_ATTEMPT_COOLOFF_TIME`` and ``DEFENDER_LOCKOUT_COOLOFF_TIME`` allow for this exact fine grained configuration.
|
||||
|
||||
We can also take a low security and low scale example like a high school's website. Such a website might be run on some of the school's computers and administrated by the school's IT staff and computer
|
||||
science teachers (if lucky enough to have any). In this scenario we can imagine that there are significant portions of the website accessible without authentication, but logging in to the website could
|
||||
provide access to some relatively privileged information such as the student's name, email, grades, and class schedule. Finally since there is an email linked with the account, we will assume that there
|
||||
is password reset functionality which unblocks the account when completed. In such a case, one could imagine that there is no need to remember failed logins for long periods of time since the application
|
||||
would simply wish to protect against potential denial of service attacks. This could be accomplished keeping ``DEFENDER_ATTEMPT_COOLOFF_TIME`` low, say 30 seconds, and setting ``DEFENDER_LOCKOUT_COOLOFF_TIME``
|
||||
to something much higher like 600 seconds. By keeping ``DEFENDER_ATTEMPT_COOLOFF_TIME`` low and locking out bad actors for significant periods of time by setting ``DEFENDER_LOCKOUT_COOLOFF_TIME`` high,
|
||||
rapid brute force login attacks will still be defeated and their small server will have more space in their cache for other data. And by providing password reset functionality as described above, these hypothetical
|
||||
administrators could limit their required involvement in unblocking real users while retaining the intended accessibility of their website.
|
||||
|
||||
While the previous example is somewhat contrived, the full power of these configurations is demonstrated with the following explanation and example.
|
||||
|
||||
When ``DEFENDER_STORE_ACCESS_ATTEMPTS`` is True, ``DEFENDER_LOCKOUT_COOLOFF_TIME`` can also be configured as a list of integers. When configured as a list,
|
||||
the number of previous failed login attempts for the configured lockout key is divided by ``DEFENDER_LOGIN_FAILURE_LIMIT`` to produce an intentionally overestimated count
|
||||
of the number of failed logins for the period defined by ``DEFENDER_ACCESS_ATTEMPT_EXPIRATION``. This ends up being an overestimate because the time between the failed login attempts
|
||||
is not considered when doing this calculation. While this may seem harsh, in some specific scenarios the additional protection against slower attacks can be worth the\ potential\ inconvenience
|
||||
caused to real users of the system.
|
||||
|
||||
One such example of this could be a public web accessible web application that houses sensitive information of it's users (let's say personal financial records).
|
||||
The application and data therein should be accessible with minimal interruption, however security is integral so delays can be tolerated up to a point.
|
||||
Under these circumstances we may have a desire to simply set ``DEFENDER_COOLOFF_TIME`` to a very large integer or even 0 for maximum protection. But this would mean that
|
||||
if a real user\ does\ get locked out of the system, we will need an administrator to manually unblock them which of course is cumbersome and costly.
|
||||
By setting ``DEFENDER_ATTEMPT_COOLOFF_TIME`` to a large enough number, let's say 600 and setting ``DEFENDER_LOCKOUT_COOLOFF_TIME`` to a list of increasing integers (ie. [60, 120, 300, 600, 0]) we can
|
||||
protect our theoretical application comprably to if we had simply set ``DEFENDER_COOLOFF_TIME`` to 600 while disrupting our users significantly less.
|
||||
|
||||
|
||||
Adapting to other authentication methods
|
||||
----------------------------------------
|
||||
|
||||
|
|
@ -387,7 +443,7 @@ There's sample ``BasicAuthenticationDefender`` class based on ``djangorestframew
|
|||
import base64
|
||||
import binascii
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import HTTP_HEADER_ENCODING, exceptions
|
||||
from rest_framework.authentication import (
|
||||
|
|
@ -422,7 +478,9 @@ There's sample ``BasicAuthenticationDefender`` class based on ``djangorestframew
|
|||
"Your account is locked for {cooloff_time_seconds} seconds" \
|
||||
"".format(
|
||||
failure_limit=config.FAILURE_LIMIT,
|
||||
cooloff_time_seconds=config.COOLOFF_TIME
|
||||
cooloff_time_seconds=config.LOCKOUT_COOLOFF_TIME[
|
||||
defender_utils.get_lockout_cooloff_time(username=self.get_username_from_request(request))
|
||||
]
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(_(detail))
|
||||
|
||||
|
|
@ -444,17 +502,264 @@ There's sample ``BasicAuthenticationDefender`` class based on ``djangorestframew
|
|||
utils.add_login_attempt_to_db(request,
|
||||
login_valid=not login_unsuccessful,
|
||||
get_username=self.get_username_from_request)
|
||||
# add the failed attempt to Redis in case of a failed login or resets the attempt count in case of success
|
||||
utils.check_request(request,
|
||||
login_unsuccessful=login_unsuccessful,
|
||||
get_username=self.get_username_from_request)
|
||||
if login_unsuccessful:
|
||||
raise login_exception
|
||||
|
||||
user_not_blocked = utils.check_request(request,
|
||||
login_unsuccessful=login_unsuccessful,
|
||||
get_username=self.get_username_from_request)
|
||||
if user_not_blocked and not login_unsuccessful:
|
||||
return response
|
||||
|
||||
raise login_exception
|
||||
return response
|
||||
|
||||
To make it work add ``BasicAuthenticationDefender`` to ``DEFAULT_AUTHENTICATION_CLASSES`` above all other authentication methods in your ``settings.py``.
|
||||
|
||||
Adapting to other authentication methods :- django-rest-auth in djangorestframework
|
||||
------------------------------------------------------------------------------------
|
||||
``defender`` can be incorporated with the combination of ``django-rest-framework`` and ``django-rest-auth`` which can be used to authenticate users.
|
||||
|
||||
Reference
|
||||
**********
|
||||
* https://www.django-rest-framework.org/
|
||||
* https://django-rest-auth.readthedocs.io/en/latest/
|
||||
|
||||
Below is a sample ``BasicAuthenticationDefender`` class based on ``rest_framework.authentication.TokenAuthentication`` which uses ``django-rest-auth`` library for user authentication.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model, authenticate
|
||||
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.utils.http import urlsafe_base64_decode as uid_decoder
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.encoding import force_str
|
||||
from rest_framework import serializers, exceptions, HTTP_HEADER_ENCODING
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from defender import utils as defender_utils
|
||||
from defender import config
|
||||
from rest_framework.authentication import (
|
||||
get_authorization_header,
|
||||
)
|
||||
|
||||
# Get the UserModel
|
||||
UserModel = get_user_model()
|
||||
|
||||
class BasicAuthenticationDefender(serializers.Serializer):
|
||||
|
||||
username = serializers.CharField(required=False, allow_blank=True)
|
||||
email = serializers.EmailField(required=False, allow_blank=True)
|
||||
password = serializers.CharField(style={'input_type': 'password'})
|
||||
|
||||
def authenticate(self, **kwargs):
|
||||
request = self.context['request']
|
||||
|
||||
if hasattr(settings, 'ACCOUNT_AUTHENTICATION_METHOD'):
|
||||
login_field = settings.ACCOUNT_AUTHENTICATION_METHOD
|
||||
else:
|
||||
login_field = 'username'
|
||||
userid = self.username_from_request(request, login_field)
|
||||
|
||||
if defender_utils.is_already_locked(request, username=userid):
|
||||
detail = "You have attempted to login {failure_limit} times with no success. "
|
||||
.format(
|
||||
failure_limit=config.FAILURE_LIMIT,
|
||||
cooloff_time_seconds=config.LOCKOUT_COOLOFF_TIME[defender_utils.get_lockout_cooloff_time(username=userid)]
|
||||
)
|
||||
raise exceptions.AuthenticationFailed(_(detail))
|
||||
|
||||
login_unsuccessful = False
|
||||
login_exception = None
|
||||
try:
|
||||
response = authenticate(request, **kwargs)
|
||||
if response == None:
|
||||
login_unsuccessful = True
|
||||
msg = _('Unable to log in with provided credentials.')
|
||||
# raise exceptions.ValidationError(msg)
|
||||
login_exception = exceptions.ValidationError(msg)
|
||||
except exceptions.AuthenticationFailed as e:
|
||||
login_unsuccessful = True
|
||||
login_exception = e
|
||||
|
||||
defender_utils.add_login_attempt_to_db(request,
|
||||
login_valid=not login_unsuccessful,
|
||||
username=userid)
|
||||
|
||||
user_not_blocked = defender_utils.check_request(request,
|
||||
login_unsuccessful=login_unsuccessful,
|
||||
username=userid)
|
||||
if user_not_blocked and not login_unsuccessful:
|
||||
return response
|
||||
|
||||
raise login_exception
|
||||
|
||||
def _validate_email(self, email, password):
|
||||
user = None
|
||||
|
||||
if email and password:
|
||||
user = self.authenticate(email=email, password=password)
|
||||
else:
|
||||
msg = _('Must include "email" and "password".')
|
||||
raise exceptions.ValidationError(msg)
|
||||
|
||||
return user
|
||||
|
||||
def _validate_username(self, username, password):
|
||||
user = None
|
||||
|
||||
if username and password:
|
||||
user = self.authenticate(username=username, password=password)
|
||||
else:
|
||||
msg = _('Must include "username" and "password".')
|
||||
raise exceptions.ValidationError(msg)
|
||||
|
||||
return user
|
||||
|
||||
def _validate_username_email(self, username, email, password):
|
||||
user = None
|
||||
|
||||
if email and password:
|
||||
user = self.authenticate(email=email, password=password)
|
||||
elif username and password:
|
||||
user = self.authenticate(username=username, password=password)
|
||||
else:
|
||||
msg = _('Must include either "username" or "email" and "password".')
|
||||
raise exceptions.ValidationError(msg)
|
||||
|
||||
return user
|
||||
|
||||
def validate(self, attrs):
|
||||
username = attrs.get('username')
|
||||
email = attrs.get('email')
|
||||
password = attrs.get('password')
|
||||
|
||||
user = None
|
||||
|
||||
if 'allauth' in settings.INSTALLED_APPS:
|
||||
from allauth.account import app_settings
|
||||
|
||||
# Authentication through email
|
||||
if app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.EMAIL:
|
||||
user = self._validate_email(email, password)
|
||||
|
||||
# Authentication through username
|
||||
elif app_settings.AUTHENTICATION_METHOD == app_settings.AuthenticationMethod.USERNAME:
|
||||
user = self._validate_username(username, password)
|
||||
|
||||
# Authentication through either username or email
|
||||
else:
|
||||
user = self._validate_username_email(username, email, password)
|
||||
|
||||
else:
|
||||
# Authentication without using allauth
|
||||
if email:
|
||||
try:
|
||||
username = UserModel.objects.get(
|
||||
email__iexact=email).username()
|
||||
except UserModel.DoesNotExist:
|
||||
pass
|
||||
|
||||
if username:
|
||||
user = self._validate_username_email(username, '', password)
|
||||
|
||||
# Did we get back an active user?
|
||||
if user:
|
||||
if not user.is_active:
|
||||
msg = _('User account is disabled.')
|
||||
raise exceptions.ValidationError(msg)
|
||||
else:
|
||||
msg = _('Unable to log in with provided credentials.')
|
||||
raise exceptions.ValidationError(msg)
|
||||
|
||||
# If required, is the email verified?
|
||||
if 'rest_auth.registration' in settings.INSTALLED_APPS:
|
||||
from allauth.account import app_settings
|
||||
if app_settings.EMAIL_VERIFICATION == app_settings.EmailVerificationMethod.MANDATORY:
|
||||
email_address = user.emailaddress_set.get(email=user.email)
|
||||
if not email_address.verified:
|
||||
raise serializers.ValidationError(
|
||||
_('E-mail is not verified.'))
|
||||
|
||||
attrs['user'] = user
|
||||
return attrs
|
||||
|
||||
def username_from_request(self, request, login_field):
|
||||
user_data = request._data
|
||||
return user_data[login_field]
|
||||
|
||||
To make it work add ``BasicAuthenticationDefender`` to ``REST_AUTH_SERIALIZERS`` dictionary in your ``settings.py`` under the key ``LOGIN_SERIALIZER``.
|
||||
For example, in your settings.py add the below line,
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
REST_AUTH_SERIALIZERS = {
|
||||
'LOGIN_SERIALIZER': '<path to your basic authentication defender python file>.BasicAuthenticationDefender',
|
||||
}
|
||||
|
||||
Adapting for password reset forms
|
||||
---------------------------------
|
||||
|
||||
``defender`` can be adapted for 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
|
||||
--------------
|
||||
|
|
@ -484,13 +789,13 @@ like:
|
|||
|
||||
.. code-block:: bash
|
||||
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py test defender --settings=defender.test_settings
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin test defender --settings=defender.test_settings
|
||||
|
||||
With Code coverage:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
PYTHONPATH=$PYTHONPATH:$PWD coverage run --source=defender $(which django-admin.py) test defender --settings=defender.test_settings
|
||||
PYTHONPATH=$PYTHONPATH:$PWD coverage run --source=defender $(which django-admin) test defender --settings=defender.test_settings
|
||||
|
||||
|
||||
Releasing
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
VERSION = (0, 7, 0)
|
||||
VERSION = (0, 9, 8)
|
||||
|
||||
__version__ = ".".join((map(str, VERSION)))
|
||||
|
|
|
|||
6
defender/apps.py
Normal file
6
defender/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DefenderAppConfig(AppConfig):
|
||||
name = "defender"
|
||||
default_auto_field = "django.db.models.AutoField"
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import os
|
||||
|
||||
import django
|
||||
from celery import Celery
|
||||
|
||||
|
||||
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
|
||||
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "redis_cache.RedisCache", "LOCATION": "localhost:6379",}
|
||||
"default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://localhost:6379",}
|
||||
}
|
||||
|
||||
SITE_ID = 1
|
||||
|
|
@ -43,6 +45,7 @@ TEMPLATES = [
|
|||
"django.template.context_processors.static",
|
||||
"django.template.context_processors.tz",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"django.template.context_processors.request",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -55,7 +58,7 @@ LOGIN_REDIRECT_URL = "/admin"
|
|||
DEFENDER_LOGIN_FAILURE_LIMIT = 10
|
||||
DEFENDER_COOLOFF_TIME = 2
|
||||
DEFENDER_REDIS_URL = "redis://localhost:6379/1"
|
||||
# don't use mock redis in unit tests, we will use real redis on travis.
|
||||
# don't use mock redis in unit tests, we will use real redis on CI.
|
||||
DEFENDER_MOCK_REDIS = False
|
||||
|
||||
# Celery settings:
|
||||
|
|
@ -64,7 +67,7 @@ BROKER_BACKEND = "memory"
|
|||
BROKER_URL = "memory://"
|
||||
|
||||
# set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "defender.travis_settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "defender.ci_settings")
|
||||
|
||||
app = Celery("defender")
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy
|
||||
from django.utils.translation import gettext_lazy
|
||||
|
||||
|
||||
def get_setting(variable, default=None):
|
||||
|
|
@ -11,6 +11,9 @@ def get_setting(variable, default=None):
|
|||
# redis server host
|
||||
DEFENDER_REDIS_URL = get_setting("DEFENDER_REDIS_URL")
|
||||
|
||||
# redis password quote for special character
|
||||
DEFENDER_REDIS_PASSWORD_QUOTE = get_setting("DEFENDER_REDIS_PASSWORD_QUOTE", False)
|
||||
|
||||
# reuse declared cache from django settings
|
||||
DEFENDER_REDIS_NAME = get_setting("DEFENDER_REDIS_NAME")
|
||||
|
||||
|
|
@ -51,14 +54,37 @@ REVERSE_PROXY_HEADER = get_setting(
|
|||
)
|
||||
|
||||
try:
|
||||
# how long to wait before the bad login attempt gets forgotten. in seconds.
|
||||
# how long to wait before the bad login attempt/lockout gets forgotten, in seconds.
|
||||
COOLOFF_TIME = int(get_setting("DEFENDER_COOLOFF_TIME", 300)) # seconds
|
||||
try:
|
||||
# how long to wait before the bad login attempt gets forgotten, in seconds.
|
||||
ATTEMPT_COOLOFF_TIME = int(get_setting("DEFENDER_ATTEMPT_COOLOFF_TIME", COOLOFF_TIME)) # measured in seconds
|
||||
except ValueError: # pragma: no cover
|
||||
raise Exception("DEFENDER_ATTEMPT_COOLOFF_TIME needs to be an integer") # pragma: no cover
|
||||
|
||||
try:
|
||||
# how long to wait before a lockout gets forgotten, in seconds.
|
||||
LOCKOUT_COOLOFF_TIMES = [int(get_setting("DEFENDER_LOCKOUT_COOLOFF_TIME", COOLOFF_TIME))] # measured in seconds
|
||||
except TypeError: # pragma: no cover
|
||||
try: # pragma: no cover
|
||||
cooloff_times = get_setting("DEFENDER_LOCKOUT_COOLOFF_TIME", [COOLOFF_TIME]) # measured in seconds
|
||||
for index, cooloff_time in enumerate(cooloff_times): # pragma: no cover
|
||||
cooloff_times[index] = int(cooloff_time) # pragma: no cover
|
||||
|
||||
if not len(cooloff_times): # pragma: no cover
|
||||
raise TypeError() # pragma: no cover
|
||||
|
||||
LOCKOUT_COOLOFF_TIMES = cooloff_times
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
raise Exception("DEFENDER_LOCKOUT_COOLOFF_TIME needs to be an integer or list of integers having at least one element") # pragma: no cover
|
||||
except ValueError: # pragma: no cover
|
||||
raise Exception("DEFENDER_LOCKOUT_COOLOFF_TIME needs to be an integer or list of integers having at least one element") # pragma: no cover
|
||||
except ValueError: # pragma: no cover
|
||||
raise Exception("DEFENDER_COOLOFF_TIME needs to be an integer") # pragma: no cover
|
||||
|
||||
LOCKOUT_TEMPLATE = get_setting("DEFENDER_LOCKOUT_TEMPLATE")
|
||||
|
||||
ERROR_MESSAGE = ugettext_lazy(
|
||||
ERROR_MESSAGE = gettext_lazy(
|
||||
"Please enter a correct username and password. "
|
||||
"Note that both fields are case-sensitive."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
from django.core.cache import caches
|
||||
from django.core.cache.backends.base import InvalidCacheBackendError
|
||||
import urllib.parse as urlparse
|
||||
|
||||
import redis
|
||||
|
||||
try:
|
||||
import urlparse
|
||||
except ImportError: # pragma: no cover
|
||||
import urllib.parse as urlparse # pragma: no cover # Python3 # pylint: disable=import-error,no-name-in-module,line-too-long
|
||||
from django.core.cache import caches
|
||||
from django.core.cache.backends.base import InvalidCacheBackendError
|
||||
|
||||
from . import config
|
||||
|
||||
|
|
@ -34,46 +31,5 @@ def get_redis_connection():
|
|||
except AttributeError:
|
||||
# django_redis.cache.RedisCache case (django-redis package)
|
||||
return cache.client.get_client(True)
|
||||
else: # pragma: no cover
|
||||
redis_config = parse_redis_url(config.DEFENDER_REDIS_URL)
|
||||
return redis.StrictRedis(
|
||||
host=redis_config.get("HOST"),
|
||||
port=redis_config.get("PORT"),
|
||||
db=redis_config.get("DB"),
|
||||
password=redis_config.get("PASSWORD"),
|
||||
ssl=redis_config.get("SSL"),
|
||||
)
|
||||
|
||||
|
||||
def parse_redis_url(url):
|
||||
"""Parses a redis URL."""
|
||||
|
||||
# create config with some sane defaults
|
||||
redis_config = {
|
||||
"DB": 0,
|
||||
"PASSWORD": None,
|
||||
"HOST": "localhost",
|
||||
"PORT": 6379,
|
||||
"SSL": False,
|
||||
}
|
||||
|
||||
if not url:
|
||||
return redis_config
|
||||
|
||||
url = urlparse.urlparse(url)
|
||||
# Remove query strings.
|
||||
path = url.path[1:]
|
||||
path = path.split("?", 2)[0]
|
||||
|
||||
if path:
|
||||
redis_config.update({"DB": int(path)})
|
||||
if url.password:
|
||||
redis_config.update({"PASSWORD": url.password})
|
||||
if url.hostname:
|
||||
redis_config.update({"HOST": url.hostname})
|
||||
if url.port:
|
||||
redis_config.update({"PORT": int(url.port)})
|
||||
if url.scheme in ["https", "rediss"]:
|
||||
redis_config.update({"SSL": True})
|
||||
|
||||
return redis_config
|
||||
else: # pragma: no cover)
|
||||
return redis.StrictRedis.from_url(config.DEFENDER_REDIS_URL)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from defender import config
|
||||
from defender.connection import get_redis_connection
|
||||
from .models import AccessAttempt
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def store_login_attempt(
|
||||
|
|
@ -13,3 +19,58 @@ def store_login_attempt(
|
|||
path_info=path_info,
|
||||
login_valid=login_valid,
|
||||
)
|
||||
|
||||
|
||||
def get_approx_lockouts_cache_key(ip_address, username):
|
||||
"""get cache key for approximate number of account lockouts"""
|
||||
return "{0}:approx_lockouts:ip:{1}:user:{2}".format(
|
||||
config.CACHE_PREFIX, ip_address or "", username.lower() if username else ""
|
||||
)
|
||||
|
||||
|
||||
def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=None):
|
||||
"""Get the approximate number of account lockouts in a period of ACCESS_ATTEMPT_EXPIRATION hours.
|
||||
This is approximate because we do not consider the time between these failed
|
||||
login attempts to be relevant.
|
||||
|
||||
Args:
|
||||
ip_address (str, optional): IP address to search for. Can be used in conjunction with username for filtering when DISABLE_IP_LOCKOUT is False. Defaults to None.
|
||||
username (str, optional): Username to search for. Can be used in conjunction with ip_address for filtering when DISABLE_USERNAME_LOCKOUT is False. Defaults to None.
|
||||
|
||||
Returns:
|
||||
int: The minimum of the count of logged failure attempts and the length of the LOCKOUT_COOLOFF_TIMES - 1, or 0 dependant on either configuration or argument parameters (ie. both ip_address and username being None).
|
||||
"""
|
||||
if not config.STORE_ACCESS_ATTEMPTS or not (ip_address or username):
|
||||
# If we're not storing login attempts OR both ip_address and username are
|
||||
# None we should return 0.
|
||||
return 0
|
||||
|
||||
q = Q(attempt_time__gte=timezone.now() - timedelta(hours=config.ACCESS_ATTEMPT_EXPIRATION))
|
||||
failure_limit = config.FAILURE_LIMIT
|
||||
if (ip_address and username and config.LOCKOUT_BY_IP_USERNAME \
|
||||
and not config.DISABLE_IP_LOCKOUT and not config.DISABLE_USERNAME_LOCKOUT
|
||||
):
|
||||
q = q & Q(ip_address=ip_address) & Q(username=username)
|
||||
elif ip_address and not config.DISABLE_IP_LOCKOUT:
|
||||
failure_limit = config.IP_FAILURE_LIMIT
|
||||
q = q & Q(ip_address=ip_address)
|
||||
elif username and not config.DISABLE_USERNAME_LOCKOUT:
|
||||
failure_limit = config.USERNAME_FAILURE_LIMIT
|
||||
q = q & Q(username=username)
|
||||
else:
|
||||
# If we've made it this far and didn't hit one of the other if or elif
|
||||
# conditions, we're in an inappropriate context.
|
||||
raise Exception("Invalid state requested")
|
||||
|
||||
cache_key = get_approx_lockouts_cache_key(ip_address, username)
|
||||
|
||||
redis_client = get_redis_connection()
|
||||
cached_value = redis_client.get(cache_key)
|
||||
if cached_value is not None:
|
||||
return int(cached_value)
|
||||
|
||||
lockouts = AccessAttempt.objects.filter(q).count() // failure_limit
|
||||
|
||||
redis_client.set(cache_key, int(lockouts), 60)
|
||||
|
||||
return lockouts
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ def watch_login(status_code=302, msg="", get_username=utils.get_username_from_re
|
|||
# if the request is currently under lockout, do not proceed to the
|
||||
# login function, go directly to lockout url, do not pass go,
|
||||
# do not collect messages about this login attempt
|
||||
if utils.is_already_locked(request):
|
||||
return utils.lockout_response(request)
|
||||
username = get_username(request)
|
||||
|
||||
if utils.is_already_locked(request, username=username):
|
||||
return utils.lockout_response(request, username=username)
|
||||
|
||||
# call the login function
|
||||
response = func(request, *args, **kwargs)
|
||||
|
|
@ -44,13 +46,13 @@ def watch_login(status_code=302, msg="", get_username=utils.get_username_from_re
|
|||
# ideally make this background task, but to keep simple,
|
||||
# keeping it inline for now.
|
||||
utils.add_login_attempt_to_db(
|
||||
request, not login_unsuccessful, get_username
|
||||
request, not login_unsuccessful, username=username
|
||||
)
|
||||
|
||||
if utils.check_request(request, login_unsuccessful, get_username):
|
||||
if utils.check_request(request, login_unsuccessful, username=username):
|
||||
return response
|
||||
|
||||
return utils.lockout_response(request)
|
||||
return utils.lockout_response(request, username=username)
|
||||
|
||||
return response
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
from __future__ import print_function
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
|
|
|||
|
|
@ -1,33 +1,27 @@
|
|||
try:
|
||||
from django.utils.deprecation import MiddlewareMixin as MIDDLEWARE_BASE_CLASS
|
||||
except ImportError:
|
||||
MIDDLEWARE_BASE_CLASS = object
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.utils.decorators import method_decorator
|
||||
|
||||
from .decorators import watch_login
|
||||
|
||||
|
||||
class FailedLoginMiddleware(MIDDLEWARE_BASE_CLASS):
|
||||
class FailedLoginMiddleware:
|
||||
""" Failed login middleware """
|
||||
|
||||
patched = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FailedLoginMiddleware, self).__init__(*args, **kwargs)
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
# Watch the auth login.
|
||||
# Monkey-patch only once - otherwise we would be recording
|
||||
# failed attempts multiple times!
|
||||
if not FailedLoginMiddleware.patched:
|
||||
# Django 1.11 turned the `login` function view into the
|
||||
# `LoginView` class-based view
|
||||
try:
|
||||
from django.contrib.auth.views import LoginView
|
||||
|
||||
our_decorator = watch_login()
|
||||
watch_login_method = method_decorator(our_decorator)
|
||||
LoginView.dispatch = watch_login_method(LoginView.dispatch)
|
||||
except ImportError: # Django < 1.11
|
||||
auth_views.login = watch_login()(auth_views.login)
|
||||
our_decorator = watch_login()
|
||||
watch_login_method = method_decorator(our_decorator)
|
||||
LoginView.dispatch = watch_login_method(LoginView.dispatch)
|
||||
|
||||
FailedLoginMiddleware.patched = True
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import models, migrations
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,6 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import django
|
||||
from django.db import models
|
||||
if django.VERSION[0] < 3:
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
else:
|
||||
# noop stub
|
||||
def python_2_unicode_compatible(cls):
|
||||
return cls
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class AccessAttempt(models.Model):
|
||||
""" Access Attempt log """
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from django.dispatch import Signal
|
||||
|
||||
username_block = Signal(providing_args=["username"])
|
||||
username_unblock = Signal(providing_args=["username"])
|
||||
ip_block = Signal(providing_args=["ip_address"])
|
||||
ip_unblock = Signal(providing_args=["ip_address"])
|
||||
username_block = Signal() # (providing_args=["username"])
|
||||
username_unblock = Signal() # (providing_args=["username"])
|
||||
ip_block = Signal() # (providing_args=["ip_address"])
|
||||
ip_unblock = Signal() # (providing_args=["ip_address"])
|
||||
|
||||
|
||||
class BlockSignal:
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
"""Initial Migration for Defender"""
|
||||
|
||||
def forwards(self, orm):
|
||||
""" Adding model 'AccessAttempt' """
|
||||
db.create_table(
|
||||
"defender_accessattempt",
|
||||
(
|
||||
("id", self.gf("django.db.models.fields.AutoField")(primary_key=True)),
|
||||
(
|
||||
"user_agent",
|
||||
self.gf("django.db.models.fields.CharField")(max_length=255),
|
||||
),
|
||||
(
|
||||
"ip_address",
|
||||
self.gf("django.db.models.fields.GenericIPAddressField")(
|
||||
max_length=39, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
self.gf("django.db.models.fields.CharField")(
|
||||
max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"http_accept",
|
||||
self.gf("django.db.models.fields.CharField")(max_length=1025),
|
||||
),
|
||||
(
|
||||
"path_info",
|
||||
self.gf("django.db.models.fields.CharField")(max_length=255),
|
||||
),
|
||||
(
|
||||
"attempt_time",
|
||||
self.gf("django.db.models.fields.DateTimeField")(
|
||||
auto_now_add=True, blank=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"login_valid",
|
||||
self.gf("django.db.models.fields.BooleanField")(default=False),
|
||||
),
|
||||
),
|
||||
)
|
||||
db.send_create_signal("defender", ["AccessAttempt"])
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'AccessAttempt'
|
||||
db.delete_table("defender_accessattempt")
|
||||
|
||||
models = {
|
||||
"defender.accessattempt": {
|
||||
"Meta": {"ordering": "[u'-attempt_time']", "object_name": "AccessAttempt"},
|
||||
"attempt_time": (
|
||||
"django.db.models.fields.DateTimeField",
|
||||
[],
|
||||
{"auto_now_add": "True", "blank": "True"},
|
||||
),
|
||||
"http_accept": (
|
||||
"django.db.models.fields.CharField",
|
||||
[],
|
||||
{"max_length": "1025"},
|
||||
),
|
||||
"id": ("django.db.models.fields.AutoField", [], {"primary_key": "True"}),
|
||||
"ip_address": (
|
||||
"django.db.models.fields.GenericIPAddressField",
|
||||
[],
|
||||
{"max_length": "39", "null": "True"},
|
||||
),
|
||||
"login_valid": (
|
||||
"django.db.models.fields.BooleanField",
|
||||
[],
|
||||
{"default": "False"},
|
||||
),
|
||||
"path_info": (
|
||||
"django.db.models.fields.CharField",
|
||||
[],
|
||||
{"max_length": "255"},
|
||||
),
|
||||
"user_agent": (
|
||||
"django.db.models.fields.CharField",
|
||||
[],
|
||||
{"max_length": "255"},
|
||||
),
|
||||
"username": (
|
||||
"django.db.models.fields.CharField",
|
||||
[],
|
||||
{"max_length": "255", "null": "True"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ["defender"]
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
from .data import store_login_attempt
|
||||
|
||||
# not sure how to get this to look better. ideally we want to dynamically
|
||||
# apply the celery decorator based on the USE_CELERY setting.
|
||||
|
||||
from celery import shared_task
|
||||
from . import config
|
||||
|
||||
|
||||
@shared_task()
|
||||
def add_login_attempt_task(
|
||||
user_agent, ip_address, username, http_accept, path_info, login_valid
|
||||
):
|
||||
|
|
@ -14,3 +9,7 @@ def add_login_attempt_task(
|
|||
store_login_attempt(
|
||||
user_agent, ip_address, username, http_accept, path_info, login_valid
|
||||
)
|
||||
|
||||
if config.USE_CELERY:
|
||||
from celery import shared_task
|
||||
add_login_attempt_task = shared_task(add_login_attempt_task)
|
||||
|
|
|
|||
|
|
@ -1,25 +1,13 @@
|
|||
{% extends "admin/index.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% if not is_popup %}
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
›
|
||||
{% for app in app_list %}
|
||||
{{ app.name }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
{% extends "admin/app_index.html" %}
|
||||
|
||||
{% block content %}
|
||||
{{ block.super }}
|
||||
{% url 'defender_blocks_view' as blocks_url %}
|
||||
{% if blocks_url %}
|
||||
<div class="app-defender module">
|
||||
<table><tr scope='row'><td colspan='3'>
|
||||
<h4><a href='{% url 'defender_blocks_view' %}'>Blocked Users</a></h4>
|
||||
<h4><a href='{{ blocks_url }}'>Blocked Users</a></h4>
|
||||
</td></tr></table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content%}
|
||||
|
|
|
|||
|
|
@ -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,12 +3,12 @@ from django.test.testcases import TestCase, TransactionTestCase
|
|||
from .connection import get_redis_connection
|
||||
|
||||
|
||||
class DefenderTestCaseMixin(object):
|
||||
class DefenderTestCaseMixin:
|
||||
"""Mixin used to provide a common tearDown method"""
|
||||
|
||||
def tearDown(self):
|
||||
"""cleanup django-defender cache after each test"""
|
||||
super(DefenderTestCaseMixin, self).tearDown()
|
||||
super().tearDown()
|
||||
get_redis_connection().flushdb()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import os
|
||||
|
||||
import django
|
||||
from celery import Celery
|
||||
|
||||
|
||||
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
|
||||
|
||||
|
||||
|
|
@ -11,6 +14,7 @@ MIDDLEWARE = (
|
|||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"defender.middleware.FailedLoginMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
)
|
||||
|
||||
ROOT_URLCONF = "defender.test_urls"
|
||||
|
|
@ -38,6 +42,7 @@ TEMPLATES = [
|
|||
"django.template.context_processors.static",
|
||||
"django.template.context_processors.tz",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"django.template.context_processors.request",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from django.conf.urls import url, include
|
||||
from django.urls import path
|
||||
from django.contrib import admin
|
||||
|
||||
from .urls import urlpatterns as original_urlpatterns
|
||||
|
||||
urlpatterns = [url(r"^admin/", admin.site.urls),] + original_urlpatterns
|
||||
urlpatterns = [path("admin/", admin.site.urls),] + original_urlpatterns
|
||||
|
|
|
|||
|
|
@ -1,26 +1,19 @@
|
|||
import random
|
||||
import string
|
||||
import time
|
||||
from distutils.version import StrictVersion
|
||||
from unittest.mock import patch
|
||||
|
||||
# Python 3 has mock in the stdlib
|
||||
try:
|
||||
from mock import patch
|
||||
except ImportError:
|
||||
from unittest.mock import patch
|
||||
|
||||
from django import get_version
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.auth.models import AnonymousUser, User
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.testcases import TestCase
|
||||
from redis.client import Redis
|
||||
from django.urls import reverse
|
||||
|
||||
try:
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
from django.core.urlresolvers import reverse
|
||||
import redis
|
||||
|
||||
from defender.data import get_approx_account_lockouts_from_login_attempts, get_approx_lockouts_cache_key
|
||||
|
||||
from . import utils
|
||||
from . import config
|
||||
|
|
@ -30,7 +23,7 @@ from .signals import (
|
|||
username_block as username_block_signal,
|
||||
username_unblock as username_unblock_signal,
|
||||
)
|
||||
from .connection import parse_redis_url, get_redis_connection
|
||||
from .connection import get_redis_connection
|
||||
from .decorators import watch_login
|
||||
from .models import AccessAttempt
|
||||
from .test import DefenderTestCase, DefenderTransactionTestCase
|
||||
|
|
@ -38,8 +31,6 @@ from .test import DefenderTestCase, DefenderTransactionTestCase
|
|||
LOGIN_FORM_KEY = '<form action="/admin/login/" method="post" id="login-form">'
|
||||
ADMIN_LOGIN_URL = reverse("admin:login")
|
||||
|
||||
DJANGO_VERSION = StrictVersion(get_version())
|
||||
|
||||
VALID_USERNAME = VALID_PASSWORD = "valid"
|
||||
UPPER_USERNAME = "VALID"
|
||||
|
||||
|
|
@ -292,14 +283,14 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
"""
|
||||
Test that the lowercase(None) returns None.
|
||||
"""
|
||||
self.assertEquals(utils.lower_username(None), None)
|
||||
self.assertEqual(utils.lower_username(None), None)
|
||||
|
||||
def test_cooling_off(self):
|
||||
""" Tests if the cooling time allows a user to login
|
||||
"""
|
||||
self.test_failure_limit_by_ip_once()
|
||||
# Wait for the cooling off period
|
||||
time.sleep(config.COOLOFF_TIME)
|
||||
time.sleep(config.LOCKOUT_COOLOFF_TIMES[0])
|
||||
|
||||
if config.MOCK_REDIS:
|
||||
# mock redis require that we expire on our own
|
||||
|
|
@ -405,12 +396,7 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
# Check if we are in the same login page
|
||||
self.assertContains(response, LOGIN_FORM_KEY)
|
||||
|
||||
# RFC 7231 allows relative URIs in Location header.
|
||||
# Django from version 1.9 is support this:
|
||||
# https://docs.djangoproject.com/en/1.9/releases/1.9/#http-redirects-no-longer-forced-to-absolute-uris
|
||||
lockout_url = "http://testserver/o/login/"
|
||||
if DJANGO_VERSION >= StrictVersion("1.9"):
|
||||
lockout_url = "/o/login/"
|
||||
lockout_url = "/o/login/"
|
||||
|
||||
# So, we shouldn't have gotten a lock-out yet.
|
||||
# But we should get one now, check redirect make sure it is valid.
|
||||
|
|
@ -445,6 +431,7 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
self.assertTemplateUsed(response, "defender/lockout.html")
|
||||
|
||||
@patch("defender.config.COOLOFF_TIME", 0)
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [0])
|
||||
def test_failed_login_no_cooloff(self):
|
||||
""" failed login no cooloff """
|
||||
for i in range(0, config.FAILURE_LIMIT):
|
||||
|
|
@ -488,57 +475,6 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
self.assertEqual(utils.is_valid_ip("::ffff:192.0.2.128"), True)
|
||||
self.assertEqual(utils.is_valid_ip("::ffff:8.8.8.8"), True)
|
||||
|
||||
def test_parse_redis_url(self):
|
||||
""" test the parse_redis_url method """
|
||||
# full regular
|
||||
conf = parse_redis_url("redis://user:password@localhost2:1234/2")
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 2)
|
||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# full non local
|
||||
conf = parse_redis_url("redis://user:pass@www.localhost.com:1234/2")
|
||||
self.assertEqual(conf.get("HOST"), "www.localhost.com")
|
||||
self.assertEqual(conf.get("DB"), 2)
|
||||
self.assertEqual(conf.get("PASSWORD"), "pass")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# no user name
|
||||
conf = parse_redis_url("redis://password@localhost2:1234/2")
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 2)
|
||||
self.assertEqual(conf.get("PASSWORD"), None)
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# no user name 2 with colon
|
||||
conf = parse_redis_url("redis://:password@localhost2:1234/2")
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 2)
|
||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# Empty
|
||||
conf = parse_redis_url(None)
|
||||
self.assertEqual(conf.get("HOST"), "localhost")
|
||||
self.assertEqual(conf.get("DB"), 0)
|
||||
self.assertEqual(conf.get("PASSWORD"), None)
|
||||
self.assertEqual(conf.get("PORT"), 6379)
|
||||
|
||||
# no db
|
||||
conf = parse_redis_url("redis://:password@localhost2:1234")
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 0)
|
||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
# no password
|
||||
conf = parse_redis_url("redis://localhost2:1234/0")
|
||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
||||
self.assertEqual(conf.get("DB"), 0)
|
||||
self.assertEqual(conf.get("PASSWORD"), None)
|
||||
self.assertEqual(conf.get("PORT"), 1234)
|
||||
|
||||
@patch("defender.config.DEFENDER_REDIS_NAME", "default")
|
||||
def test_get_redis_connection_django_conf(self):
|
||||
""" get the redis connection """
|
||||
|
|
@ -722,6 +658,14 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
response = self._login()
|
||||
self.assertContains(response, LOGIN_FORM_KEY)
|
||||
|
||||
# Successful login should not clear IP lock
|
||||
self._login(username=VALID_USERNAME, password=VALID_PASSWORD)
|
||||
|
||||
# We should still be locked out for the locked
|
||||
# username using the same IP
|
||||
response = self._login(username=username)
|
||||
self.assertContains(response, self.LOCKED_MESSAGE)
|
||||
|
||||
# We shouldn't get a lockout message when attempting to use a
|
||||
# different ip address
|
||||
ip = "74.125.239.60"
|
||||
|
|
@ -921,6 +865,119 @@ class AccessAttemptTest(DefenderTestCase):
|
|||
data_out = utils.get_blocked_ips()
|
||||
self.assertEqual(data_out, [])
|
||||
|
||||
@patch("defender.config.USERNAME_FAILURE_LIMIT", 3)
|
||||
@patch("defender.config.DISABLE_IP_LOCKOUT", True)
|
||||
def test_login_blocked_for_non_standard_login_views_different_username(self):
|
||||
"""
|
||||
Check that a view with custom username blocked correctly
|
||||
"""
|
||||
|
||||
@watch_login(status_code=401, get_username=lambda request: request.POST.get("email"))
|
||||
def fake_api_401_login_different_username(request):
|
||||
""" Fake the api login with 401 """
|
||||
return HttpResponse("Invalid", status=401)
|
||||
|
||||
wrong_email = "email@localhost"
|
||||
|
||||
request_factory = RequestFactory()
|
||||
request = request_factory.post("api/login", data={"email": wrong_email})
|
||||
request.user = AnonymousUser()
|
||||
request.session = SessionStore()
|
||||
|
||||
for _ in range(3):
|
||||
fake_api_401_login_different_username(request)
|
||||
|
||||
data_out = utils.get_blocked_usernames()
|
||||
self.assertEqual(data_out, [])
|
||||
|
||||
fake_api_401_login_different_username(request)
|
||||
|
||||
data_out = utils.get_blocked_usernames()
|
||||
self.assertEqual(data_out, [wrong_email])
|
||||
|
||||
# Ensure that `watch_login` correctly extract username from request
|
||||
# during `is_already_locked` check and don't cause 500 errors
|
||||
status_code = fake_api_401_login_different_username(request)
|
||||
self.assertNotEqual(status_code, 500)
|
||||
|
||||
@patch("defender.config.ATTEMPT_COOLOFF_TIME", "a")
|
||||
def test_bad_attempt_cooloff_configuration(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.ATTEMPT_COOLOFF_TIME", ["a"])
|
||||
def test_bad_attempt_cooloff_configuration_with_list(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", "a")
|
||||
def test_bad_lockout_cooloff_configuration(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [300, "a"])
|
||||
def test_bad_list_lockout_cooloff_configuration(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [300, dict(a="a")])
|
||||
def test_bad_list_with_dict_lockout_cooloff_configuration(self):
|
||||
self.assertRaises(Exception)
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [3, 6])
|
||||
@patch("defender.config.IP_FAILURE_LIMIT", 3)
|
||||
def test_lockout_cooloff_correctly_scales_with_ip_when_set(self):
|
||||
self.test_ip_failure_limit()
|
||||
self.assertEqual(utils.get_lockout_cooloff_time(ip_address="127.0.0.1"), 3)
|
||||
utils.reset_failed_attempts(ip_address="127.0.0.1")
|
||||
self.test_ip_failure_limit()
|
||||
self.assertEqual(utils.get_lockout_cooloff_time(ip_address="127.0.0.1"), 6)
|
||||
time.sleep(config.LOCKOUT_COOLOFF_TIMES[1])
|
||||
if config.MOCK_REDIS:
|
||||
# mock redis require that we expire on our own
|
||||
get_redis_connection().do_expire() # pragma: no cover
|
||||
self.test_valid_login()
|
||||
|
||||
@patch("defender.config.LOCKOUT_COOLOFF_TIMES", [3, 6])
|
||||
@patch("defender.config.USERNAME_FAILURE_LIMIT", 3)
|
||||
def test_lockout_cooloff_correctly_scales_with_username_when_set(self):
|
||||
self.test_username_failure_limit()
|
||||
self.assertEqual(utils.get_lockout_cooloff_time(username=VALID_USERNAME), 3)
|
||||
utils.reset_failed_attempts(username=VALID_USERNAME)
|
||||
self.test_username_failure_limit()
|
||||
self.assertEqual(utils.get_lockout_cooloff_time(username=VALID_USERNAME), 6)
|
||||
time.sleep(config.LOCKOUT_COOLOFF_TIMES[1])
|
||||
if config.MOCK_REDIS:
|
||||
# mock redis require that we expire on our own
|
||||
get_redis_connection().do_expire() # pragma: no cover
|
||||
self.test_valid_login()
|
||||
|
||||
@patch("defender.config.STORE_ACCESS_ATTEMPTS", False)
|
||||
def test_approx_account_lockout_count_default_case_no_store(self):
|
||||
self.assertEqual(get_approx_account_lockouts_from_login_attempts(ip_address="127.0.0.1"), 0)
|
||||
|
||||
def test_approx_account_lockout_count_default_case_empty_args(self):
|
||||
self.assertEqual(get_approx_account_lockouts_from_login_attempts(), 0)
|
||||
|
||||
@patch("defender.config.DISABLE_IP_LOCKOUT", True)
|
||||
def test_approx_account_lockout_count_default_case_invalid_args_pt1(self):
|
||||
with self.assertRaises(Exception):
|
||||
get_approx_account_lockouts_from_login_attempts(ip_address="127.0.0.1")
|
||||
|
||||
@patch("defender.config.DISABLE_USERNAME_LOCKOUT", True)
|
||||
def test_approx_account_lockout_count_default_case_invalid_args_pt2(self):
|
||||
with self.assertRaises(Exception):
|
||||
get_approx_account_lockouts_from_login_attempts(username=VALID_USERNAME)
|
||||
|
||||
def test_approx_account_lockout_uses_redis_cache(self):
|
||||
get_approx_account_lockouts_from_login_attempts(
|
||||
ip_address="127.0.0.1", username=VALID_USERNAME
|
||||
)
|
||||
|
||||
redis_client = get_redis_connection()
|
||||
cached_value = redis_client.get(
|
||||
get_approx_lockouts_cache_key(
|
||||
ip_address="127.0.0.1", username=VALID_USERNAME
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(cached_value)
|
||||
|
||||
|
||||
class SignalTest(DefenderTestCase):
|
||||
""" Test that signals are properly sent when blocking usernames and IPs.
|
||||
|
|
@ -1070,3 +1127,112 @@ class TestUtils(DefenderTestCase):
|
|||
|
||||
utils.add_login_attempt_to_db(request, True, username=username)
|
||||
self.assertEqual(AccessAttempt.objects.filter(username=username).count(), 1)
|
||||
|
||||
def test_ip_address_strip_port_number(self):
|
||||
""" Test the strip_port_number() method """
|
||||
# IPv4 with/without port
|
||||
self.assertEqual(utils.strip_port_number("192.168.1.1"), "192.168.1.1")
|
||||
self.assertEqual(utils.strip_port_number(
|
||||
"192.168.1.1:8000"), "192.168.1.1")
|
||||
|
||||
# IPv6 with/without port
|
||||
self.assertEqual(utils.strip_port_number(
|
||||
"2001:db8:85a3:0:0:8a2e:370:7334"), "2001:db8:85a3:0:0:8a2e:370:7334")
|
||||
self.assertEqual(utils.strip_port_number(
|
||||
"[2001:db8:85a3:0:0:8a2e:370:7334]:123456"), "2001:db8:85a3:0:0:8a2e:370:7334")
|
||||
|
||||
@patch("defender.config.BEHIND_REVERSE_PROXY", True)
|
||||
def test_get_ip_strips_port_number(self):
|
||||
""" make sure the IP address is stripped of its port number """
|
||||
req = HttpRequest()
|
||||
req.META["HTTP_X_FORWARDED_FOR"] = "1.2.3.4:123456"
|
||||
self.assertEqual(utils.get_ip(req), "1.2.3.4")
|
||||
|
||||
req = HttpRequest()
|
||||
req.META["HTTP_X_FORWARDED_FOR"] = "[2001:db8::1]:123456"
|
||||
self.assertEqual(utils.get_ip(req), "2001:db8::1")
|
||||
|
||||
def test_remove_prefix(self):
|
||||
""" test the remove_prefix() method """
|
||||
self.assertEqual(utils.remove_prefix(
|
||||
"defender:blocked:ip:192.168.24.24", "defender:blocked:"), "ip:192.168.24.24")
|
||||
self.assertEqual(utils.remove_prefix(
|
||||
"defender:blocked:username:johndoe", "defender:blocked:"), "username:johndoe")
|
||||
self.assertEqual(utils.remove_prefix(
|
||||
"defender:blocked:username:johndoe", "blocked:username:"),
|
||||
"defender:blocked:username:johndoe")
|
||||
|
||||
def test_whitespace_block_circumvention(self):
|
||||
username = "johndoe"
|
||||
req = HttpRequest()
|
||||
req.POST["username"] = f"{username} " # username with appended whitespace
|
||||
req.META["HTTP_X_REAL_IP"] = "1.2.3.4"
|
||||
|
||||
utils.block_username(username)
|
||||
|
||||
self.assertTrue(utils.is_already_locked(req))
|
||||
|
||||
|
||||
class TestRedisConnection(TestCase):
|
||||
""" Test the redis connection parsing """
|
||||
REDIS_URL_PLAIN = "redis://localhost:6379/0"
|
||||
REDIS_URL_PASS = "redis://:mypass@localhost:6379/0"
|
||||
REDIS_URL_NAME_PASS = "redis://myname:mypass2@localhost:6379/0"
|
||||
|
||||
@patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_PLAIN)
|
||||
@patch("defender.config.MOCK_REDIS", False)
|
||||
def test_get_redis_connection(self):
|
||||
""" get redis connection plain """
|
||||
redis_client = get_redis_connection()
|
||||
self.assertIsInstance(redis_client, Redis)
|
||||
redis_client.set('test', 0)
|
||||
result = int(redis_client.get('test'))
|
||||
self.assertEqual(result, 0)
|
||||
redis_client.delete('test')
|
||||
|
||||
@patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_PASS)
|
||||
@patch("defender.config.MOCK_REDIS", False)
|
||||
def test_get_redis_connection_with_password(self):
|
||||
""" get redis connection with password """
|
||||
|
||||
connection = redis.Redis()
|
||||
connection.config_set('requirepass', 'mypass')
|
||||
|
||||
redis_client = get_redis_connection()
|
||||
self.assertIsInstance(redis_client, Redis)
|
||||
redis_client.set('test2', 0)
|
||||
result = int(redis_client.get('test2'))
|
||||
self.assertEqual(result, 0)
|
||||
redis_client.delete('test2')
|
||||
# clean up
|
||||
redis_client.config_set('requirepass', '')
|
||||
|
||||
@patch("defender.config.DEFENDER_REDIS_URL", REDIS_URL_NAME_PASS)
|
||||
@patch("defender.config.MOCK_REDIS", False)
|
||||
def test_get_redis_connection_with_acl(self):
|
||||
""" get redis connection with password and name ACL """
|
||||
connection = redis.Redis()
|
||||
|
||||
if connection.info().get('redis_version') < '6':
|
||||
# redis versions before 6 don't have acl, so skip.
|
||||
return
|
||||
|
||||
connection.acl_setuser(
|
||||
'myname',
|
||||
enabled=True,
|
||||
passwords=["+" + "mypass2", ],
|
||||
keys="*",
|
||||
commands=["+@all", ])
|
||||
|
||||
try:
|
||||
redis_client = get_redis_connection()
|
||||
self.assertIsInstance(redis_client, Redis)
|
||||
redis_client.set('test3', 0)
|
||||
result = int(redis_client.get('test3'))
|
||||
self.assertEqual(result, 0)
|
||||
redis_client.delete('test3')
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
# clean up
|
||||
connection.acl_deluser('myname')
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
from django.conf.urls import url
|
||||
from django.urls import path, re_path
|
||||
from .views import block_view, unblock_ip_view, unblock_username_view
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^blocks/$", block_view, name="defender_blocks_view"),
|
||||
url(
|
||||
r"^blocks/ip/(?P<ip_address>[A-Za-z0-9-._]+)/unblock$",
|
||||
path("blocks/", block_view, name="defender_blocks_view"),
|
||||
re_path(
|
||||
"blocks/ip/(?P<ip_address>[A-Za-z0-9-._]+)/unblock",
|
||||
unblock_ip_view,
|
||||
name="defender_unblock_ip_view",
|
||||
),
|
||||
url(
|
||||
r"^blocks/username/(?P<username>[\w]+[^\/]*)/unblock$",
|
||||
path(
|
||||
"blocks/username/<path:username>/unblock",
|
||||
unblock_username_view,
|
||||
name="defender_unblock_username_view",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from ipaddress import ip_address
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponseRedirect
|
||||
|
|
@ -9,7 +12,11 @@ from django.utils.module_loading import import_string
|
|||
|
||||
from .connection import get_redis_connection
|
||||
from . import config
|
||||
from .data import store_login_attempt
|
||||
from .data import (
|
||||
get_approx_account_lockouts_from_login_attempts,
|
||||
get_approx_lockouts_cache_key,
|
||||
store_login_attempt,
|
||||
)
|
||||
from .signals import (
|
||||
send_username_block_signal,
|
||||
send_ip_block_signal,
|
||||
|
|
@ -43,15 +50,51 @@ def get_ip_address_from_request(request):
|
|||
return "127.0.0.1"
|
||||
|
||||
|
||||
ipv4_with_port = re.compile(r"^(\d+\.\d+\.\d+\.\d+):\d+")
|
||||
ipv6_with_port = re.compile(r"^\[([^\]]+)\]:\d+")
|
||||
|
||||
|
||||
def strip_port_number(ip_address_string):
|
||||
""" strips port number from IPv4 or IPv6 address """
|
||||
ip_address = None
|
||||
|
||||
if ipv4_with_port.match(ip_address_string):
|
||||
match = ipv4_with_port.match(ip_address_string)
|
||||
ip_address = match[1]
|
||||
elif ipv6_with_port.match(ip_address_string):
|
||||
match = ipv6_with_port.match(ip_address_string)
|
||||
ip_address = match[1]
|
||||
|
||||
"""
|
||||
If it's not a valid IP address, we prefer to return
|
||||
the string as-is instead of returning a potentially
|
||||
corrupted string:
|
||||
"""
|
||||
if is_valid_ip(ip_address):
|
||||
return ip_address
|
||||
|
||||
return ip_address_string
|
||||
|
||||
|
||||
def get_ip(request):
|
||||
""" get the ip address from the request """
|
||||
if config.BEHIND_REVERSE_PROXY:
|
||||
ip_address = request.META.get(config.REVERSE_PROXY_HEADER, "")
|
||||
ip_address = ip_address.split(",", 1)[0].strip()
|
||||
|
||||
if ip_address == "":
|
||||
ip_address = get_ip_address_from_request(request)
|
||||
else:
|
||||
"""
|
||||
Some reverse proxies will include a port number with the
|
||||
IP address; as this port may change from request to request,
|
||||
and thus make it appear to be different IP addresses, we'll
|
||||
want to remove the port number, if present:
|
||||
"""
|
||||
ip_address = strip_port_number(ip_address)
|
||||
else:
|
||||
ip_address = get_ip_address_from_request(request)
|
||||
|
||||
return ip_address
|
||||
|
||||
|
||||
|
|
@ -89,20 +132,38 @@ def get_username_blocked_cache_key(username):
|
|||
)
|
||||
|
||||
|
||||
def remove_prefix(string, prefix):
|
||||
if string.startswith(prefix):
|
||||
return string[len(prefix):]
|
||||
return string
|
||||
|
||||
|
||||
|
||||
def strip_keys(key_list):
|
||||
""" Given a list of keys, remove the prefix and remove just
|
||||
the data we care about.
|
||||
|
||||
for example:
|
||||
|
||||
['defender:blocked:ip:ken', 'defender:blocked:ip:joffrey']
|
||||
[
|
||||
'defender:blocked:ip:192.168.24.24',
|
||||
'defender:blocked:ip:::ffff:192.168.24.24',
|
||||
'defender:blocked:username:joffrey'
|
||||
]
|
||||
|
||||
would result in:
|
||||
|
||||
['ken', 'joffrey']
|
||||
|
||||
[
|
||||
'192.168.24.24',
|
||||
'::ffff:192.168.24.24',
|
||||
'joffrey'
|
||||
]
|
||||
"""
|
||||
return [key.split(":")[-1] for key in key_list]
|
||||
return [
|
||||
# key.removeprefix(f"{config.CACHE_PREFIX}:blocked:").partition(":")[2]
|
||||
remove_prefix(key, f"{config.CACHE_PREFIX}:blocked:").partition(":")[2]
|
||||
for key in key_list
|
||||
]
|
||||
|
||||
|
||||
def get_blocked_ips():
|
||||
|
|
@ -129,8 +190,8 @@ def increment_key(key):
|
|||
""" given a key increment the value """
|
||||
pipe = REDIS_SERVER.pipeline()
|
||||
pipe.incr(key, 1)
|
||||
if config.COOLOFF_TIME:
|
||||
pipe.expire(key, config.COOLOFF_TIME)
|
||||
if config.ATTEMPT_COOLOFF_TIME:
|
||||
pipe.expire(key, config.ATTEMPT_COOLOFF_TIME)
|
||||
new_value = pipe.execute()[0]
|
||||
return new_value
|
||||
|
||||
|
|
@ -138,7 +199,7 @@ def increment_key(key):
|
|||
def username_from_request(request):
|
||||
""" unloads username from default POST request """
|
||||
if config.USERNAME_FORM_FIELD in request.POST:
|
||||
return request.POST[config.USERNAME_FORM_FIELD][:255]
|
||||
return request.POST[config.USERNAME_FORM_FIELD][:255].strip()
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -167,6 +228,15 @@ def get_user_attempts(request, get_username=get_username_from_request, username=
|
|||
# return the larger of the two.
|
||||
return max(ip_count, username_count)
|
||||
|
||||
def get_lockout_cooloff_time(ip_address=None, username=None):
|
||||
if not config.LOCKOUT_COOLOFF_TIMES:
|
||||
return 0
|
||||
index = max(0, min(
|
||||
len(config.LOCKOUT_COOLOFF_TIMES) - 1,
|
||||
get_approx_account_lockouts_from_login_attempts(ip_address, username) - 1
|
||||
))
|
||||
return config.LOCKOUT_COOLOFF_TIMES[index]
|
||||
|
||||
|
||||
def block_ip(ip_address):
|
||||
""" given the ip, block it """
|
||||
|
|
@ -178,8 +248,9 @@ def block_ip(ip_address):
|
|||
return
|
||||
already_blocked = is_source_ip_already_locked(ip_address)
|
||||
key = get_ip_blocked_cache_key(ip_address)
|
||||
if config.COOLOFF_TIME:
|
||||
REDIS_SERVER.set(key, "blocked", config.COOLOFF_TIME)
|
||||
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address)
|
||||
if cooloff_time:
|
||||
REDIS_SERVER.set(key, "blocked", cooloff_time)
|
||||
else:
|
||||
REDIS_SERVER.set(key, "blocked")
|
||||
if not already_blocked:
|
||||
|
|
@ -196,8 +267,9 @@ def block_username(username):
|
|||
return
|
||||
already_blocked = is_user_already_locked(username)
|
||||
key = get_username_blocked_cache_key(username)
|
||||
if config.COOLOFF_TIME:
|
||||
REDIS_SERVER.set(key, "blocked", config.COOLOFF_TIME)
|
||||
cooloff_time = get_lockout_cooloff_time(username=username)
|
||||
if cooloff_time:
|
||||
REDIS_SERVER.set(key, "blocked", cooloff_time)
|
||||
else:
|
||||
REDIS_SERVER.set(key, "blocked")
|
||||
if not already_blocked:
|
||||
|
|
@ -263,6 +335,10 @@ def unblock_ip(ip_address, pipe=None):
|
|||
pipe.execute()
|
||||
send_ip_unblock_signal(ip_address)
|
||||
|
||||
redis_cache_key = get_approx_lockouts_cache_key(ip_address, None)
|
||||
redis_client = get_redis_connection()
|
||||
redis_client.delete(redis_cache_key)
|
||||
|
||||
|
||||
def unblock_username(username, pipe=None):
|
||||
""" unblock the given Username """
|
||||
|
|
@ -277,24 +353,37 @@ def unblock_username(username, pipe=None):
|
|||
pipe.execute()
|
||||
send_username_unblock_signal(username)
|
||||
|
||||
redis_cache_key = get_approx_lockouts_cache_key(None, username)
|
||||
redis_client = get_redis_connection()
|
||||
redis_client.delete(redis_cache_key)
|
||||
|
||||
|
||||
def reset_failed_attempts(ip_address=None, username=None):
|
||||
""" reset the failed attempts for these ip's and usernames
|
||||
"""
|
||||
pipe = REDIS_SERVER.pipeline()
|
||||
|
||||
unblock_ip(ip_address, pipe=pipe)
|
||||
# Because IP is shared, a reset should never clear an IP block
|
||||
# when using IP/username as block
|
||||
if not config.LOCKOUT_BY_IP_USERNAME:
|
||||
unblock_ip(ip_address, pipe=pipe)
|
||||
unblock_username(username, pipe=pipe)
|
||||
|
||||
redis_cache_key = get_approx_lockouts_cache_key(ip_address, username)
|
||||
redis_client = get_redis_connection()
|
||||
redis_client.delete(redis_cache_key)
|
||||
|
||||
pipe.execute()
|
||||
|
||||
|
||||
def lockout_response(request):
|
||||
def lockout_response(request, username):
|
||||
""" if we are locked out, here is the response """
|
||||
ip_address = get_ip(request)
|
||||
if config.LOCKOUT_TEMPLATE:
|
||||
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address, username=username)
|
||||
context = {
|
||||
"cooloff_time_seconds": config.COOLOFF_TIME,
|
||||
"cooloff_time_minutes": config.COOLOFF_TIME / 60,
|
||||
"cooloff_time_seconds": cooloff_time,
|
||||
"cooloff_time_minutes": cooloff_time / 60,
|
||||
"failure_limit": config.FAILURE_LIMIT,
|
||||
}
|
||||
return render(request, config.LOCKOUT_TEMPLATE, context)
|
||||
|
|
@ -302,7 +391,7 @@ def lockout_response(request):
|
|||
if config.LOCKOUT_URL:
|
||||
return HttpResponseRedirect(config.LOCKOUT_URL)
|
||||
|
||||
if config.COOLOFF_TIME:
|
||||
if get_lockout_cooloff_time(ip_address=ip_address, username=username):
|
||||
return HttpResponse(
|
||||
"Account locked: too many login attempts. " "Please try again later."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
from django.shortcuts import render
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.urls import reverse
|
||||
|
||||
try:
|
||||
from django.urls import reverse
|
||||
except ImportError:
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from .utils import get_blocked_ips, get_blocked_usernames, unblock_ip, unblock_username
|
||||
|
||||
|
|
@ -16,10 +14,12 @@ def block_view(request):
|
|||
blocked_ip_list = get_blocked_ips()
|
||||
blocked_username_list = get_blocked_usernames()
|
||||
|
||||
context = {
|
||||
context = admin.site.index(request).context_data
|
||||
context.update({
|
||||
"blocked_ip_list": blocked_ip_list,
|
||||
"blocked_username_list": blocked_username_list,
|
||||
}
|
||||
"title": "Blocked logins",
|
||||
})
|
||||
return render(request, "defender/admin/blocks.html", context)
|
||||
|
||||
|
||||
|
|
|
|||
18
docs/conf.py
18
docs/conf.py
|
|
@ -13,16 +13,24 @@
|
|||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath("."))
|
||||
from pkg_resources import get_distribution
|
||||
try:
|
||||
from importlib import metadata
|
||||
except ImportError:
|
||||
# Running on pre-3.8 Python; use importlib-metadata package
|
||||
import importlib_metadata as metadata
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "django-defender"
|
||||
copyright = "2014, Ken Cochrane"
|
||||
copyright = "2024, Ken Cochrane"
|
||||
author = "Ken Cochrane"
|
||||
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = get_distribution("django-defender").version
|
||||
try:
|
||||
release = metadata.version("django-defender")
|
||||
except metadata.PackageNotFoundError:
|
||||
# package is not installed
|
||||
release = "0.0.0"
|
||||
|
||||
# The short X.Y version.
|
||||
version = ".".join(release.split(".")[:2])
|
||||
|
|
@ -38,7 +46,7 @@ master_doc = "index"
|
|||
extensions = []
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
templates_path = []
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
|
|
@ -56,4 +64,4 @@ html_theme = "sphinx_rtd_theme"
|
|||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ["_static"]
|
||||
html_static_path = []
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ This is just a simple example app, used for testing and showing how things work
|
|||
```
|
||||
mkdir -p exampleapp/static exampleapp/media/static
|
||||
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py collectstatic --noinput --settings=exampleapp.settings
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin collectstatic --noinput --settings=exampleapp.settings
|
||||
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin.py runserver --settings=exampleapp.settings
|
||||
PYTHONPATH=$PYTHONPATH:$PWD django-admin runserver --settings=exampleapp.settings
|
||||
```
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ MIDDLEWARE = (
|
|||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"defender.middleware.FailedLoginMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware"
|
||||
)
|
||||
|
||||
ROOT_URLCONF = "exampleapp.urls"
|
||||
|
|
@ -57,7 +58,7 @@ LOGIN_REDIRECT_URL = "/admin"
|
|||
DEFENDER_LOGIN_FAILURE_LIMIT = 1
|
||||
DEFENDER_COOLOFF_TIME = 60
|
||||
DEFENDER_REDIS_URL = "redis://localhost:6379/1"
|
||||
# don't use mock redis in unit tests, we will use real redis on travis.
|
||||
# don't use mock redis in unit tests, we will use real redis on CI.
|
||||
DEFENDER_MOCK_REDIS = False
|
||||
# Let's use custom function and strip username string from request.
|
||||
DEFENDER_GET_USERNAME_FROM_REQUEST_PATH = (
|
||||
|
|
@ -80,3 +81,18 @@ app.config_from_object("django.conf:settings")
|
|||
app.autodiscover_tasks(lambda: INSTALLED_APPS)
|
||||
|
||||
DEBUG = True
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from django.conf.urls import patterns, include
|
||||
from django.urls import include, re_path
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
|
||||
|
|
@ -6,11 +6,10 @@ from django.conf.urls.static import static
|
|||
|
||||
admin.autodiscover()
|
||||
|
||||
urlpatterns = patterns(
|
||||
"",
|
||||
(r"^admin/", include(admin.site.urls)),
|
||||
(r"^admin/defender/", include("defender.urls")),
|
||||
)
|
||||
urlpatterns = [
|
||||
re_path(r"^admin/defender/", include("defender.urls")),
|
||||
re_path(r"^admin/", admin.site.urls),
|
||||
]
|
||||
|
||||
|
||||
urlpatterns += staticfiles_urlpatterns()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
-e .
|
||||
coverage
|
||||
mockredispy
|
||||
django-redis-cache
|
||||
celery<5
|
||||
mock; python_version < '3.0'
|
||||
django-redis>=5,<6
|
||||
redis>=5,<6
|
||||
importlib-metadata<5.0
|
||||
celery
|
||||
sphinx_rtd_theme==2.0.0
|
||||
|
|
|
|||
24
setup.py
24
setup.py
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
|
|
@ -32,15 +31,24 @@ setup(
|
|||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 3.2",
|
||||
"Framework :: Django :: 4.0",
|
||||
"Framework :: Django :: 4.1",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Framework :: Django :: 5.1",
|
||||
"Framework :: Django :: 5.2",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 2.7",
|
||||
"Programming Language :: Python :: 3.5",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||||
|
|
@ -56,12 +64,12 @@ setup(
|
|||
include_package_data=True,
|
||||
packages=find_packages(),
|
||||
package_data=get_package_data("defender"),
|
||||
install_requires=["Django>=1.8,<=3.1", "redis<=3.5"],
|
||||
python_requires="~=3.8",
|
||||
install_requires=["Django", "redis>=4.0.0"],
|
||||
tests_require=[
|
||||
"mock",
|
||||
"mockredispy>=2.9.0.11,<3.0",
|
||||
"coverage",
|
||||
"celery",
|
||||
"django-redis-cache",
|
||||
"django-redis",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
52
tox.ini
52
tox.ini
|
|
@ -1,48 +1,56 @@
|
|||
[tox]
|
||||
envlist =
|
||||
# list of supported Django/Python versioons:
|
||||
# https://docs.djangoproject.com/en/2.2/faq/install/#what-python-version-can-i-use-with-django
|
||||
py{27,35,36,37,py3}-django111
|
||||
py35-django(21,22)
|
||||
py{36,37,py3}-django{21,22,master}
|
||||
py38-django22
|
||||
py38-{lint,docs}
|
||||
# list of supported Django/Python versions:
|
||||
py{38,39,py3}-dj{32}
|
||||
py{38,39,310,311,312}-dj{42}
|
||||
py{310,311,312}-dj{50,51,52,main}
|
||||
py312-{lint,docs}
|
||||
|
||||
[travis]
|
||||
[gh-actions]
|
||||
python =
|
||||
2.7: py27
|
||||
3.5: py35
|
||||
3.6: py36
|
||||
3.7: py37
|
||||
3.8: py38
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
3.11: py311
|
||||
3.12: py312
|
||||
pypy3: pypy3
|
||||
|
||||
[testenv]
|
||||
deps =
|
||||
setuptools
|
||||
-rrequirements.txt
|
||||
py27: mock
|
||||
django111: django>=1.11,<2.0
|
||||
django21: django>=2.1,<2.2
|
||||
django22: django>=2.2,<2.3
|
||||
djangomaster: https://github.com/django/django/archive/master.tar.gz
|
||||
dj32: django>=3.2,<4.0
|
||||
dj42: django>=4.2,<5.0
|
||||
dj50: django>=5.0,<5.1
|
||||
dj51: django>=5.1,<5.2
|
||||
dj52: django>=5.2,<5.3
|
||||
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||
usedevelop = True
|
||||
commands =
|
||||
{envbindir}/coverage run --source=defender {envbindir}/django-admin.py test defender --settings=defender.travis_settings
|
||||
{envbindir}/coverage run --source=defender {envbindir}/django-admin test defender --settings=defender.ci_settings
|
||||
{envbindir}/coverage report -m
|
||||
{envbindir}/coverage xml
|
||||
ignore_outcome =
|
||||
djmain: True
|
||||
ignore_errors =
|
||||
djmain: True
|
||||
|
||||
[testenv:py38-docs]
|
||||
basepython = python3.8
|
||||
[testenv:py312-docs]
|
||||
basepython = python3.12
|
||||
deps =
|
||||
-rrequirements.txt
|
||||
Sphinx
|
||||
sphinx_rtd_theme
|
||||
setuptools
|
||||
commands = sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html
|
||||
|
||||
[testenv:py38-lint]
|
||||
basepython = python3.8
|
||||
[testenv:py312-lint]
|
||||
basepython = python3.12
|
||||
deps =
|
||||
twine
|
||||
check-manifest
|
||||
setuptools
|
||||
setuptools_scm
|
||||
commands =
|
||||
check-manifest -v
|
||||
python setup.py sdist
|
||||
|
|
|
|||
Loading…
Reference in a new issue