mirror of
https://github.com/jazzband/django-defender.git
synced 2026-05-11 17:13:14 +00:00
Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daa6235caf | ||
|
|
289af19ce9 | ||
|
|
37e5dd3123 | ||
|
|
e420d76463 | ||
|
|
cc35032a0c | ||
|
|
f2dede8c76 | ||
|
|
4e00500537 | ||
|
|
83ad7ce338 | ||
|
|
07555abd29 | ||
|
|
c290b5a673 | ||
|
|
4bea010b65 | ||
|
|
a972dae7fc | ||
|
|
1e0aa91952 | ||
|
|
ba548fa9c3 | ||
|
|
f6c73e093b | ||
|
|
8d4c6840e9 | ||
|
|
2a0469669a | ||
|
|
91dfbde556 | ||
|
|
cc6145b84e |
20 changed files with 232 additions and 199 deletions
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -11,12 +11,12 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: 3.8
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
|
|
||||||
- name: Upload packages to Jazzband
|
- name: Upload packages to Jazzband
|
||||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||||
uses: pypa/gh-action-pypi-publish@master
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
with:
|
with:
|
||||||
user: jazzband
|
user: jazzband
|
||||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||||
|
|
|
||||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
|
|
@ -9,11 +9,11 @@ jobs:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 5
|
max-parallel: 5
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-3.8']
|
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
|
||||||
redis-version: [5, 6, 7]
|
redis-version: [5, 6, 7]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Start Redis
|
- name: Start Redis
|
||||||
uses: supercharge/redis-github-action@1.5.0
|
uses: supercharge/redis-github-action@1.5.0
|
||||||
|
|
@ -21,7 +21,7 @@ jobs:
|
||||||
redis-version: ${{ matrix.redis-version }}
|
redis-version: ${{ matrix.redis-version }}
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
echo "::set-output name=dir::$(pip cache dir)"
|
echo "::set-output name=dir::$(pip cache dir)"
|
||||||
|
|
||||||
- name: Cache
|
- name: Cache
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pip-cache.outputs.dir }}
|
path: ${{ steps.pip-cache.outputs.dir }}
|
||||||
key:
|
key:
|
||||||
|
|
@ -49,6 +49,6 @@ jobs:
|
||||||
tox -v
|
tox -v
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@v1
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
name: Python ${{ matrix.python-version }}
|
name: Python ${{ matrix.python-version }}
|
||||||
|
|
|
||||||
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
|
||||||
12
CHANGES.rst
12
CHANGES.rst
|
|
@ -2,6 +2,18 @@
|
||||||
Changes
|
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
|
0.9.7
|
||||||
=====
|
=====
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ include CODE_OF_CONDUCT.md
|
||||||
include requirements.txt
|
include requirements.txt
|
||||||
include tox.ini
|
include tox.ini
|
||||||
include .pre-commit-config.yaml
|
include .pre-commit-config.yaml
|
||||||
|
include .readthedocs.yaml
|
||||||
recursive-include docs *
|
recursive-include docs *
|
||||||
recursive-include exampleapp *
|
recursive-include exampleapp *
|
||||||
recursive-include defender/templates *.html
|
recursive-include defender/templates *.html
|
||||||
|
|
|
||||||
10
README.rst
10
README.rst
|
|
@ -108,8 +108,8 @@ Admin pages
|
||||||
Requirements
|
Requirements
|
||||||
------------
|
------------
|
||||||
|
|
||||||
* Python: 3.7, 3.8, 3.9, 3.10, PyPy
|
* Python: 3.8, 3.9, 3.10, 3.11, 3.12, PyPy
|
||||||
* Django: 3.x, 4.x
|
* Django: 3.2, 4.2, 5.0, 5.1, 5.2
|
||||||
* Redis: 5.x, 6.x, 7.x
|
* Redis: 5.x, 6.x, 7.x
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -381,7 +381,7 @@ These should be defined in your ``settings.py`` file.
|
||||||
(Example with password: ``redis://:mypassword@localhost:6379/0``\ )
|
(Example with password: ``redis://:mypassword@localhost:6379/0``\ )
|
||||||
* ``DEFENDER_REDIS_PASSWORD_QUOTE``\ : Boolean: if special character in redis password (like '@'), we can quote password ``urllib.parse.quote("password!@#")``, and set to True.
|
* ``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``\ ]
|
[Default: ``False``\ ]
|
||||||
* ``DEFENDER_REDIS_NAME``\ : String: the name of your cache client on the CACHES django setting. If set, ``DEFENDER_REDIS_URL`` will be ignored.
|
* ``DEFENDER_REDIS_NAME``\ : String: the name of the cache from ``CACHES`` in your Django settings (e.g. ``"default"``). If set, ``DEFENDER_REDIS_URL`` will be ignored.
|
||||||
[Default: ``None``\ ]
|
[Default: ``None``\ ]
|
||||||
* ``DEFENDER_STORE_ACCESS_ATTEMPTS``\ : Boolean: If you want to store the login
|
* ``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
|
attempt to the database, set to True. If False, it is not saved
|
||||||
|
|
@ -534,8 +534,8 @@ Below is a sample ``BasicAuthenticationDefender`` class based on ``rest_framewor
|
||||||
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
|
from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm
|
||||||
from django.contrib.auth.tokens import default_token_generator
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
from django.utils.http import urlsafe_base64_decode as uid_decoder
|
from django.utils.http import urlsafe_base64_decode as uid_decoder
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_str
|
||||||
from rest_framework import serializers, exceptions, HTTP_HEADER_ENCODING
|
from rest_framework import serializers, exceptions, HTTP_HEADER_ENCODING
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from defender import utils as defender_utils
|
from defender import utils as defender_utils
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
VERSION = (0, 9, 7)
|
VERSION = (0, 9, 8)
|
||||||
|
|
||||||
__version__ = ".".join((map(str, VERSION)))
|
__version__ = ".".join((map(str, VERSION)))
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from celery import Celery
|
||||||
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
|
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:",}}
|
||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {"BACKEND": "redis_cache.RedisCache", "LOCATION": "localhost:6379",}
|
"default": {"BACKEND": "django_redis.cache.RedisCache", "LOCATION": "redis://localhost:6379",}
|
||||||
}
|
}
|
||||||
|
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
|
||||||
|
|
@ -31,55 +31,5 @@ def get_redis_connection():
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# django_redis.cache.RedisCache case (django-redis package)
|
# django_redis.cache.RedisCache case (django-redis package)
|
||||||
return cache.client.get_client(True)
|
return cache.client.get_client(True)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover)
|
||||||
redis_config = parse_redis_url(
|
return redis.StrictRedis.from_url(config.DEFENDER_REDIS_URL)
|
||||||
config.DEFENDER_REDIS_URL, config.DEFENDER_REDIS_PASSWORD_QUOTE)
|
|
||||||
|
|
||||||
return redis.StrictRedis(
|
|
||||||
host=redis_config.get("HOST"),
|
|
||||||
port=redis_config.get("PORT"),
|
|
||||||
db=redis_config.get("DB"),
|
|
||||||
username=redis_config.get("USERNAME"),
|
|
||||||
password=redis_config.get("PASSWORD"),
|
|
||||||
ssl=redis_config.get("SSL"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_redis_url(url, password_quote=None):
|
|
||||||
"""Parses a redis URL."""
|
|
||||||
|
|
||||||
# create config with some sane defaults
|
|
||||||
redis_config = {
|
|
||||||
"DB": 0,
|
|
||||||
"PASSWORD": None,
|
|
||||||
"HOST": "localhost",
|
|
||||||
"PORT": 6379,
|
|
||||||
"SSL": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
return redis_config
|
|
||||||
|
|
||||||
purl = urlparse.urlparse(url)
|
|
||||||
|
|
||||||
# Remove query strings.
|
|
||||||
path = purl.path[1:]
|
|
||||||
path = path.split("?", 2)[0]
|
|
||||||
|
|
||||||
if path:
|
|
||||||
redis_config.update({"DB": int(path)})
|
|
||||||
if purl.password:
|
|
||||||
password = purl.password
|
|
||||||
if password_quote:
|
|
||||||
password = urlparse.unquote(password)
|
|
||||||
redis_config.update({"PASSWORD": password})
|
|
||||||
if purl.hostname:
|
|
||||||
redis_config.update({"HOST": purl.hostname})
|
|
||||||
if purl.username:
|
|
||||||
redis_config.update({"USERNAME": purl.username})
|
|
||||||
if purl.port:
|
|
||||||
redis_config.update({"PORT": int(purl.port)})
|
|
||||||
if purl.scheme in ["https", "rediss"]:
|
|
||||||
redis_config.update({"SSL": True})
|
|
||||||
|
|
||||||
return redis_config
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from defender import config
|
from defender import config
|
||||||
|
from defender.connection import get_redis_connection
|
||||||
from .models import AccessAttempt
|
from .models import AccessAttempt
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
def store_login_attempt(
|
def store_login_attempt(
|
||||||
|
|
@ -18,6 +20,14 @@ def store_login_attempt(
|
||||||
login_valid=login_valid,
|
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):
|
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.
|
"""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
|
This is approximate because we do not consider the time between these failed
|
||||||
|
|
@ -30,16 +40,12 @@ def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=No
|
||||||
Returns:
|
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).
|
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).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# TODO: Possibly add logic to temporarily store this info in the cache
|
|
||||||
# to help mitigate any potential performance impact this could have.
|
|
||||||
|
|
||||||
if not config.STORE_ACCESS_ATTEMPTS or not (ip_address or username):
|
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
|
# If we're not storing login attempts OR both ip_address and username are
|
||||||
# None we should return 0.
|
# None we should return 0.
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
q = Q(attempt_time__gte=datetime.now() - timedelta(hours=config.ACCESS_ATTEMPT_EXPIRATION))
|
q = Q(attempt_time__gte=timezone.now() - timedelta(hours=config.ACCESS_ATTEMPT_EXPIRATION))
|
||||||
failure_limit = config.FAILURE_LIMIT
|
failure_limit = config.FAILURE_LIMIT
|
||||||
if (ip_address and username and config.LOCKOUT_BY_IP_USERNAME \
|
if (ip_address and username and config.LOCKOUT_BY_IP_USERNAME \
|
||||||
and not config.DISABLE_IP_LOCKOUT and not config.DISABLE_USERNAME_LOCKOUT
|
and not config.DISABLE_IP_LOCKOUT and not config.DISABLE_USERNAME_LOCKOUT
|
||||||
|
|
@ -56,4 +62,15 @@ def get_approx_account_lockouts_from_login_attempts(ip_address=None, username=No
|
||||||
# conditions, we're in an inappropriate context.
|
# conditions, we're in an inappropriate context.
|
||||||
raise Exception("Invalid state requested")
|
raise Exception("Invalid state requested")
|
||||||
|
|
||||||
return AccessAttempt.objects.filter(q).count() // failure_limit
|
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
|
# if the request is currently under lockout, do not proceed to the
|
||||||
# login function, go directly to lockout url, do not pass go,
|
# login function, go directly to lockout url, do not pass go,
|
||||||
# do not collect messages about this login attempt
|
# do not collect messages about this login attempt
|
||||||
if utils.is_already_locked(request):
|
username = get_username(request)
|
||||||
return utils.lockout_response(request)
|
|
||||||
|
if utils.is_already_locked(request, username=username):
|
||||||
|
return utils.lockout_response(request, username=username)
|
||||||
|
|
||||||
# call the login function
|
# call the login function
|
||||||
response = func(request, *args, **kwargs)
|
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,
|
# ideally make this background task, but to keep simple,
|
||||||
# keeping it inline for now.
|
# keeping it inline for now.
|
||||||
utils.add_login_attempt_to_db(
|
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 response
|
||||||
|
|
||||||
return utils.lockout_response(request)
|
return utils.lockout_response(request, username=username)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,13 @@
|
||||||
{% extends "admin/index.html" %}
|
{% extends "admin/app_index.html" %}
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% if not is_popup %}
|
|
||||||
{% block breadcrumbs %}
|
|
||||||
<div class="breadcrumbs">
|
|
||||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
|
||||||
›
|
|
||||||
{% for app in app_list %}
|
|
||||||
{{ app.name }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% block sidebar %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
{% url 'defender_blocks_view' as blocks_url %}
|
||||||
|
{% if blocks_url %}
|
||||||
<div class="app-defender module">
|
<div class="app-defender module">
|
||||||
<table><tr scope='row'><td colspan='3'>
|
<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>
|
</td></tr></table>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock content%}
|
{% endblock content%}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,13 @@
|
||||||
<div class="breadcrumbs">
|
<div class="breadcrumbs">
|
||||||
<a href="{% url "admin:index" %}">Home</a> ›
|
<a href="{% url "admin:index" %}">Home</a> ›
|
||||||
<a href="{% url "admin:app_list" "defender" %}">Defender</a> ›
|
<a href="{% url "admin:app_list" "defender" %}">Defender</a> ›
|
||||||
|
{{ title }}
|
||||||
</div>
|
</div>
|
||||||
{% endblock breadcrumbs %}
|
{% endblock breadcrumbs %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="content-main">
|
<div id="content-main">
|
||||||
|
|
||||||
<h1>Blocked Logins</h1>
|
|
||||||
<p>Here is a list of IP's and usernames that are blocked</p>
|
<p>Here is a list of IP's and usernames that are blocked</p>
|
||||||
|
|
||||||
<div class="module">
|
<div class="module">
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,8 @@ import string
|
||||||
import time
|
import time
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser, User
|
from django.contrib.auth.models import AnonymousUser, User
|
||||||
from django.contrib.sessions.backends.db import SessionStore
|
from django.contrib.sessions.backends.db import SessionStore
|
||||||
from django.db.models import Q
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
from django.test.testcases import TestCase
|
from django.test.testcases import TestCase
|
||||||
|
|
@ -16,7 +13,7 @@ from django.urls import reverse
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
from defender.data import get_approx_account_lockouts_from_login_attempts
|
from defender.data import get_approx_account_lockouts_from_login_attempts, get_approx_lockouts_cache_key
|
||||||
|
|
||||||
from . import utils
|
from . import utils
|
||||||
from . import config
|
from . import config
|
||||||
|
|
@ -26,7 +23,7 @@ from .signals import (
|
||||||
username_block as username_block_signal,
|
username_block as username_block_signal,
|
||||||
username_unblock as username_unblock_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 .decorators import watch_login
|
||||||
from .models import AccessAttempt
|
from .models import AccessAttempt
|
||||||
from .test import DefenderTestCase, DefenderTransactionTestCase
|
from .test import DefenderTestCase, DefenderTransactionTestCase
|
||||||
|
|
@ -478,74 +475,6 @@ class AccessAttemptTest(DefenderTestCase):
|
||||||
self.assertEqual(utils.is_valid_ip("::ffff:192.0.2.128"), True)
|
self.assertEqual(utils.is_valid_ip("::ffff:192.0.2.128"), True)
|
||||||
self.assertEqual(utils.is_valid_ip("::ffff:8.8.8.8"), True)
|
self.assertEqual(utils.is_valid_ip("::ffff:8.8.8.8"), True)
|
||||||
|
|
||||||
def test_parse_redis_url(self):
|
|
||||||
""" test the parse_redis_url method """
|
|
||||||
# full regular
|
|
||||||
conf = parse_redis_url("redis://user:password@localhost2:1234/2", False)
|
|
||||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
|
||||||
self.assertEqual(conf.get("DB"), 2)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
|
||||||
self.assertEqual(conf.get("PORT"), 1234)
|
|
||||||
self.assertEqual(conf.get("USERNAME"), "user")
|
|
||||||
|
|
||||||
# full non local
|
|
||||||
conf = parse_redis_url(
|
|
||||||
"redis://user:pass@www.localhost.com:1234/2", False)
|
|
||||||
self.assertEqual(conf.get("HOST"), "www.localhost.com")
|
|
||||||
self.assertEqual(conf.get("DB"), 2)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), "pass")
|
|
||||||
self.assertEqual(conf.get("PORT"), 1234)
|
|
||||||
self.assertEqual(conf.get("USERNAME"), "user")
|
|
||||||
|
|
||||||
# no user name
|
|
||||||
conf = parse_redis_url("redis://password@localhost2:1234/2", False)
|
|
||||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
|
||||||
self.assertEqual(conf.get("DB"), 2)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), None)
|
|
||||||
self.assertEqual(conf.get("PORT"), 1234)
|
|
||||||
|
|
||||||
# no user name 2 with colon
|
|
||||||
conf = parse_redis_url("redis://:password@localhost2:1234/2", False)
|
|
||||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
|
||||||
self.assertEqual(conf.get("DB"), 2)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
|
||||||
self.assertEqual(conf.get("PORT"), 1234)
|
|
||||||
|
|
||||||
# Empty
|
|
||||||
conf = parse_redis_url(None, False)
|
|
||||||
self.assertEqual(conf.get("HOST"), "localhost")
|
|
||||||
self.assertEqual(conf.get("DB"), 0)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), None)
|
|
||||||
self.assertEqual(conf.get("PORT"), 6379)
|
|
||||||
|
|
||||||
# no db
|
|
||||||
conf = parse_redis_url("redis://:password@localhost2:1234", False)
|
|
||||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
|
||||||
self.assertEqual(conf.get("DB"), 0)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
|
||||||
self.assertEqual(conf.get("PORT"), 1234)
|
|
||||||
|
|
||||||
# no password
|
|
||||||
conf = parse_redis_url("redis://localhost2:1234/0", False)
|
|
||||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
|
||||||
self.assertEqual(conf.get("DB"), 0)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), None)
|
|
||||||
self.assertEqual(conf.get("PORT"), 1234)
|
|
||||||
|
|
||||||
# password with special character and set the password_quote = True
|
|
||||||
conf = parse_redis_url("redis://:calmkart%23%40%21@localhost:6379/0", True)
|
|
||||||
self.assertEqual(conf.get("HOST"), "localhost")
|
|
||||||
self.assertEqual(conf.get("DB"), 0)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), "calmkart#@!")
|
|
||||||
self.assertEqual(conf.get("PORT"), 6379)
|
|
||||||
|
|
||||||
# password without special character and set the password_quote = True
|
|
||||||
conf = parse_redis_url("redis://:password@localhost2:1234", True)
|
|
||||||
self.assertEqual(conf.get("HOST"), "localhost2")
|
|
||||||
self.assertEqual(conf.get("DB"), 0)
|
|
||||||
self.assertEqual(conf.get("PASSWORD"), "password")
|
|
||||||
self.assertEqual(conf.get("PORT"), 1234)
|
|
||||||
|
|
||||||
@patch("defender.config.DEFENDER_REDIS_NAME", "default")
|
@patch("defender.config.DEFENDER_REDIS_NAME", "default")
|
||||||
def test_get_redis_connection_django_conf(self):
|
def test_get_redis_connection_django_conf(self):
|
||||||
""" get the redis connection """
|
""" get the redis connection """
|
||||||
|
|
@ -936,6 +865,41 @@ class AccessAttemptTest(DefenderTestCase):
|
||||||
data_out = utils.get_blocked_ips()
|
data_out = utils.get_blocked_ips()
|
||||||
self.assertEqual(data_out, [])
|
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")
|
@patch("defender.config.ATTEMPT_COOLOFF_TIME", "a")
|
||||||
def test_bad_attempt_cooloff_configuration(self):
|
def test_bad_attempt_cooloff_configuration(self):
|
||||||
self.assertRaises(Exception)
|
self.assertRaises(Exception)
|
||||||
|
|
@ -1001,6 +965,19 @@ class AccessAttemptTest(DefenderTestCase):
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception):
|
||||||
get_approx_account_lockouts_from_login_attempts(username=VALID_USERNAME)
|
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):
|
class SignalTest(DefenderTestCase):
|
||||||
""" Test that signals are properly sent when blocking usernames and IPs.
|
""" Test that signals are properly sent when blocking usernames and IPs.
|
||||||
|
|
@ -1185,6 +1162,16 @@ class TestUtils(DefenderTestCase):
|
||||||
"defender:blocked:username:johndoe", "blocked:username:"),
|
"defender:blocked:username:johndoe", "blocked:username:"),
|
||||||
"defender:blocked:username:johndoe")
|
"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):
|
class TestRedisConnection(TestCase):
|
||||||
""" Test the redis connection parsing """
|
""" Test the redis connection parsing """
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@ from django.utils.module_loading import import_string
|
||||||
|
|
||||||
from .connection import get_redis_connection
|
from .connection import get_redis_connection
|
||||||
from . import config
|
from . import config
|
||||||
from .data import get_approx_account_lockouts_from_login_attempts, store_login_attempt
|
from .data import (
|
||||||
|
get_approx_account_lockouts_from_login_attempts,
|
||||||
|
get_approx_lockouts_cache_key,
|
||||||
|
store_login_attempt,
|
||||||
|
)
|
||||||
from .signals import (
|
from .signals import (
|
||||||
send_username_block_signal,
|
send_username_block_signal,
|
||||||
send_ip_block_signal,
|
send_ip_block_signal,
|
||||||
|
|
@ -195,7 +199,7 @@ def increment_key(key):
|
||||||
def username_from_request(request):
|
def username_from_request(request):
|
||||||
""" unloads username from default POST request """
|
""" unloads username from default POST request """
|
||||||
if config.USERNAME_FORM_FIELD in request.POST:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -331,6 +335,10 @@ def unblock_ip(ip_address, pipe=None):
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
send_ip_unblock_signal(ip_address)
|
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):
|
def unblock_username(username, pipe=None):
|
||||||
""" unblock the given Username """
|
""" unblock the given Username """
|
||||||
|
|
@ -345,6 +353,10 @@ def unblock_username(username, pipe=None):
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
send_username_unblock_signal(username)
|
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):
|
def reset_failed_attempts(ip_address=None, username=None):
|
||||||
""" reset the failed attempts for these ip's and usernames
|
""" reset the failed attempts for these ip's and usernames
|
||||||
|
|
@ -357,13 +369,16 @@ def reset_failed_attempts(ip_address=None, username=None):
|
||||||
unblock_ip(ip_address, pipe=pipe)
|
unblock_ip(ip_address, pipe=pipe)
|
||||||
unblock_username(username, 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()
|
pipe.execute()
|
||||||
|
|
||||||
|
|
||||||
def lockout_response(request):
|
def lockout_response(request, username):
|
||||||
""" if we are locked out, here is the response """
|
""" if we are locked out, here is the response """
|
||||||
ip_address = get_ip(request)
|
ip_address = get_ip(request)
|
||||||
username = get_username_from_request(request)
|
|
||||||
if config.LOCKOUT_TEMPLATE:
|
if config.LOCKOUT_TEMPLATE:
|
||||||
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address, username=username)
|
cooloff_time = get_lockout_cooloff_time(ip_address=ip_address, username=username)
|
||||||
context = {
|
context = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.contrib import admin
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
from django.contrib.admin.views.decorators import staff_member_required
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
@ -13,10 +14,12 @@ def block_view(request):
|
||||||
blocked_ip_list = get_blocked_ips()
|
blocked_ip_list = get_blocked_ips()
|
||||||
blocked_username_list = get_blocked_usernames()
|
blocked_username_list = get_blocked_usernames()
|
||||||
|
|
||||||
context = {
|
context = admin.site.index(request).context_data
|
||||||
|
context.update({
|
||||||
"blocked_ip_list": blocked_ip_list,
|
"blocked_ip_list": blocked_ip_list,
|
||||||
"blocked_username_list": blocked_username_list,
|
"blocked_username_list": blocked_username_list,
|
||||||
}
|
"title": "Blocked logins",
|
||||||
|
})
|
||||||
return render(request, "defender/admin/blocks.html", context)
|
return render(request, "defender/admin/blocks.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
18
docs/conf.py
18
docs/conf.py
|
|
@ -13,16 +13,24 @@
|
||||||
# import os
|
# import os
|
||||||
# import sys
|
# import sys
|
||||||
# sys.path.insert(0, os.path.abspath("."))
|
# 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 information -----------------------------------------------------
|
||||||
|
|
||||||
project = "django-defender"
|
project = "django-defender"
|
||||||
copyright = "2014, Ken Cochrane"
|
copyright = "2024, Ken Cochrane"
|
||||||
author = "Ken Cochrane"
|
author = "Ken Cochrane"
|
||||||
|
|
||||||
# The full version, including alpha/beta/rc tags.
|
# 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.
|
# The short X.Y version.
|
||||||
version = ".".join(release.split(".")[:2])
|
version = ".".join(release.split(".")[:2])
|
||||||
|
|
@ -38,7 +46,7 @@ master_doc = "index"
|
||||||
extensions = []
|
extensions = []
|
||||||
|
|
||||||
# Add any paths that contain templates here, relative to this directory.
|
# 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
|
# List of patterns, relative to source directory, that match files and
|
||||||
# directories to ignore when looking for source files.
|
# 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,
|
# 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,
|
# relative to this directory. They are copied after the builtin static files,
|
||||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||||
html_static_path = ["_static"]
|
html_static_path = []
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
-e .
|
-e .
|
||||||
coverage
|
coverage
|
||||||
mockredispy
|
mockredispy
|
||||||
django-redis-cache
|
django-redis>=5,<6
|
||||||
|
redis>=5,<6
|
||||||
importlib-metadata<5.0
|
importlib-metadata<5.0
|
||||||
celery
|
celery
|
||||||
|
sphinx_rtd_theme==2.0.0
|
||||||
|
|
|
||||||
17
setup.py
17
setup.py
|
|
@ -34,16 +34,21 @@ setup(
|
||||||
"Framework :: Django :: 3.2",
|
"Framework :: Django :: 3.2",
|
||||||
"Framework :: Django :: 4.0",
|
"Framework :: Django :: 4.0",
|
||||||
"Framework :: Django :: 4.1",
|
"Framework :: Django :: 4.1",
|
||||||
|
"Framework :: Django :: 4.2",
|
||||||
|
"Framework :: Django :: 5.0",
|
||||||
|
"Framework :: Django :: 5.1",
|
||||||
|
"Framework :: Django :: 5.2",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: Apache Software License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
'Programming Language :: Python :: 3',
|
"Programming Language :: Python :: 3",
|
||||||
'Programming Language :: Python :: 3 :: Only',
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.7",
|
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||||||
|
|
@ -59,12 +64,12 @@ setup(
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
package_data=get_package_data("defender"),
|
package_data=get_package_data("defender"),
|
||||||
python_requires='~=3.7',
|
python_requires="~=3.8",
|
||||||
install_requires=["Django", "redis"],
|
install_requires=["Django", "redis>=4.0.0"],
|
||||||
tests_require=[
|
tests_require=[
|
||||||
"mockredispy>=2.9.0.11,<3.0",
|
"mockredispy>=2.9.0.11,<3.0",
|
||||||
"coverage",
|
"coverage",
|
||||||
"celery",
|
"celery",
|
||||||
"django-redis-cache",
|
"django-redis",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
28
tox.ini
28
tox.ini
|
|
@ -1,24 +1,29 @@
|
||||||
[tox]
|
[tox]
|
||||||
envlist =
|
envlist =
|
||||||
# list of supported Django/Python versions:
|
# list of supported Django/Python versions:
|
||||||
py{37,38,39,py3}-dj{32}
|
py{38,39,py3}-dj{32}
|
||||||
py{38,39,310}-dj{40,41,main}
|
py{38,39,310,311,312}-dj{42}
|
||||||
py38-{lint,docs}
|
py{310,311,312}-dj{50,51,52,main}
|
||||||
|
py312-{lint,docs}
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
python =
|
python =
|
||||||
3.7: py37
|
|
||||||
3.8: py38
|
3.8: py38
|
||||||
3.9: py39
|
3.9: py39
|
||||||
3.10: py310
|
3.10: py310
|
||||||
|
3.11: py311
|
||||||
|
3.12: py312
|
||||||
pypy3: pypy3
|
pypy3: pypy3
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
deps =
|
deps =
|
||||||
|
setuptools
|
||||||
-rrequirements.txt
|
-rrequirements.txt
|
||||||
dj32: django>=3.2,<4.0
|
dj32: django>=3.2,<4.0
|
||||||
dj40: django>=4.0,<4.1
|
dj42: django>=4.2,<5.0
|
||||||
dj41: django>=4.1,<4.2
|
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
|
djmain: https://github.com/django/django/archive/main.tar.gz
|
||||||
usedevelop = True
|
usedevelop = True
|
||||||
commands =
|
commands =
|
||||||
|
|
@ -30,19 +35,22 @@ ignore_outcome =
|
||||||
ignore_errors =
|
ignore_errors =
|
||||||
djmain: True
|
djmain: True
|
||||||
|
|
||||||
[testenv:py38-docs]
|
[testenv:py312-docs]
|
||||||
basepython = python3.8
|
basepython = python3.12
|
||||||
deps =
|
deps =
|
||||||
-rrequirements.txt
|
-rrequirements.txt
|
||||||
Sphinx
|
Sphinx
|
||||||
sphinx_rtd_theme
|
sphinx_rtd_theme
|
||||||
|
setuptools
|
||||||
commands = sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html
|
commands = sphinx-build -b html -d {envtmpdir}/doctrees docs {envtmpdir}/html
|
||||||
|
|
||||||
[testenv:py38-lint]
|
[testenv:py312-lint]
|
||||||
basepython = python3.8
|
basepython = python3.12
|
||||||
deps =
|
deps =
|
||||||
twine
|
twine
|
||||||
check-manifest
|
check-manifest
|
||||||
|
setuptools
|
||||||
|
setuptools_scm
|
||||||
commands =
|
commands =
|
||||||
check-manifest -v
|
check-manifest -v
|
||||||
python setup.py sdist
|
python setup.py sdist
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue