django-device-cookies 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_device_cookies-0.1.0/.editorconfig +12 -0
- django_device_cookies-0.1.0/.github/dependabot.yml +14 -0
- django_device_cookies-0.1.0/.github/workflows/ci.yml +101 -0
- django_device_cookies-0.1.0/.github/workflows/dependabot-auto-merge.yml +33 -0
- django_device_cookies-0.1.0/.github/workflows/update-hooks.yml +78 -0
- django_device_cookies-0.1.0/.gitignore +4 -0
- django_device_cookies-0.1.0/.pre-commit-config.yaml +22 -0
- django_device_cookies-0.1.0/LICENSE +21 -0
- django_device_cookies-0.1.0/PKG-INFO +176 -0
- django_device_cookies-0.1.0/README.rst +145 -0
- django_device_cookies-0.1.0/codecov.yml +8 -0
- django_device_cookies-0.1.0/django_device_cookies/__init__.py +0 -0
- django_device_cookies-0.1.0/django_device_cookies/admin.py +30 -0
- django_device_cookies-0.1.0/django_device_cookies/apps.py +11 -0
- django_device_cookies-0.1.0/django_device_cookies/backends.py +66 -0
- django_device_cookies-0.1.0/django_device_cookies/checks.py +199 -0
- django_device_cookies-0.1.0/django_device_cookies/config.py +33 -0
- django_device_cookies-0.1.0/django_device_cookies/management/__init__.py +0 -0
- django_device_cookies-0.1.0/django_device_cookies/management/commands/__init__.py +0 -0
- django_device_cookies-0.1.0/django_device_cookies/management/commands/clear_device_cookie_attempts.py +14 -0
- django_device_cookies-0.1.0/django_device_cookies/middleware.py +18 -0
- django_device_cookies-0.1.0/django_device_cookies/migrations/0001_initial.py +39 -0
- django_device_cookies-0.1.0/django_device_cookies/migrations/__init__.py +0 -0
- django_device_cookies-0.1.0/django_device_cookies/models.py +67 -0
- django_device_cookies-0.1.0/django_device_cookies/py.typed +0 -0
- django_device_cookies-0.1.0/django_device_cookies/receivers.py +49 -0
- django_device_cookies-0.1.0/django_device_cookies/signals.py +3 -0
- django_device_cookies-0.1.0/django_device_cookies/utils.py +131 -0
- django_device_cookies-0.1.0/django_device_cookies/views.py +15 -0
- django_device_cookies-0.1.0/pyproject.toml +104 -0
- django_device_cookies-0.1.0/uv.lock +425 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
workflow_dispatch:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
push:
|
|
7
|
+
branches: ["main"]
|
|
8
|
+
release:
|
|
9
|
+
types:
|
|
10
|
+
- published
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
lint:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- name: Check out code
|
|
21
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
22
|
+
- name: Run prek
|
|
23
|
+
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
|
24
|
+
|
|
25
|
+
test:
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
strategy:
|
|
28
|
+
matrix:
|
|
29
|
+
django-version: ["5.2", "6.0", "6.1"]
|
|
30
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
31
|
+
exclude:
|
|
32
|
+
- django-version: "6.0"
|
|
33
|
+
python-version: "3.10"
|
|
34
|
+
- django-version: "6.0"
|
|
35
|
+
python-version: "3.11"
|
|
36
|
+
- django-version: "6.1"
|
|
37
|
+
python-version: "3.10"
|
|
38
|
+
- django-version: "6.1"
|
|
39
|
+
python-version: "3.11"
|
|
40
|
+
steps:
|
|
41
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
42
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
|
43
|
+
with:
|
|
44
|
+
python-version: ${{ matrix.python-version }}
|
|
45
|
+
- name: Check out code
|
|
46
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
47
|
+
- name: Install uv
|
|
48
|
+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
49
|
+
with:
|
|
50
|
+
cache-dependency-glob: uv.lock
|
|
51
|
+
enable-cache: true
|
|
52
|
+
- name: Install dependencies
|
|
53
|
+
run: |
|
|
54
|
+
uv sync --frozen --python ${{ matrix.python-version }}
|
|
55
|
+
uv pip install --python .venv django~=${{ matrix.django-version }}.0
|
|
56
|
+
- name: Run tests
|
|
57
|
+
run: |
|
|
58
|
+
uv run --no-sync python -m coverage run -m pytest
|
|
59
|
+
uv run --no-sync python -m coverage xml
|
|
60
|
+
- name: Check coverage
|
|
61
|
+
run: uv run --no-sync python -m coverage report --fail-under=100
|
|
62
|
+
- name: Upload coverage
|
|
63
|
+
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
|
64
|
+
with:
|
|
65
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
66
|
+
files: coverage.xml
|
|
67
|
+
fail_ci_if_error: true
|
|
68
|
+
|
|
69
|
+
checks:
|
|
70
|
+
if: always()
|
|
71
|
+
runs-on: ubuntu-latest
|
|
72
|
+
needs: [lint, test]
|
|
73
|
+
steps:
|
|
74
|
+
- name: Check all jobs passed
|
|
75
|
+
run: |
|
|
76
|
+
[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "false" ]
|
|
77
|
+
|
|
78
|
+
release:
|
|
79
|
+
if: ${{ github.event_name == 'release' }}
|
|
80
|
+
runs-on: ubuntu-latest
|
|
81
|
+
needs: [checks]
|
|
82
|
+
environment:
|
|
83
|
+
name: pypi
|
|
84
|
+
url: https://pypi.org/p/django-device-cookies
|
|
85
|
+
permissions:
|
|
86
|
+
id-token: write
|
|
87
|
+
steps:
|
|
88
|
+
- name: Set up Python
|
|
89
|
+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
|
90
|
+
with:
|
|
91
|
+
python-version: "3.14"
|
|
92
|
+
- name: Check out code
|
|
93
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
94
|
+
- name: Install uv
|
|
95
|
+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
96
|
+
with:
|
|
97
|
+
enable-cache: false
|
|
98
|
+
- name: Build package
|
|
99
|
+
run: uv build
|
|
100
|
+
- name: Publish package
|
|
101
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Dependabot auto-merge
|
|
2
|
+
|
|
3
|
+
on: pull_request
|
|
4
|
+
|
|
5
|
+
permissions: {}
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
auto-merge:
|
|
9
|
+
if: github.event.pull_request.user.login == 'dependabot[bot]'
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: write
|
|
13
|
+
pull-requests: write
|
|
14
|
+
steps:
|
|
15
|
+
- name: Fetch Dependabot metadata
|
|
16
|
+
id: metadata
|
|
17
|
+
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3
|
|
18
|
+
- name: Enable auto-merge
|
|
19
|
+
env:
|
|
20
|
+
GH_TOKEN: ${{ github.token }}
|
|
21
|
+
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
22
|
+
run: |
|
|
23
|
+
gh pr merge --squash --auto "$PR_URL" ||
|
|
24
|
+
gh pr merge --squash "$PR_URL" ||
|
|
25
|
+
echo "::warning::Could not enable auto-merge or merge directly"
|
|
26
|
+
- name: Approve
|
|
27
|
+
if: >-
|
|
28
|
+
contains(fromJSON('["github_actions", "github-actions"]'), steps.metadata.outputs.package-ecosystem)
|
|
29
|
+
|| steps.metadata.outputs.update-type != 'version-update:semver-major'
|
|
30
|
+
env:
|
|
31
|
+
GH_TOKEN: ${{ github.token }}
|
|
32
|
+
PR_URL: ${{ github.event.pull_request.html_url }}
|
|
33
|
+
run: gh pr review --approve "$PR_URL"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
name: Update hooks
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: "17 5 * * 1"
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions: {}
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
update:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
permissions:
|
|
14
|
+
actions: write
|
|
15
|
+
contents: write
|
|
16
|
+
pull-requests: write
|
|
17
|
+
steps:
|
|
18
|
+
- name: Check out code
|
|
19
|
+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
|
20
|
+
- name: Install prek
|
|
21
|
+
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
|
22
|
+
with:
|
|
23
|
+
install-only: true
|
|
24
|
+
- name: Update hook versions
|
|
25
|
+
run: prek update
|
|
26
|
+
- name: Run hooks
|
|
27
|
+
run: prek run --all-files || prek run --all-files || true
|
|
28
|
+
- name: Check which files changed
|
|
29
|
+
id: changes
|
|
30
|
+
run: |
|
|
31
|
+
if [ "$(git status --porcelain)" = " M .pre-commit-config.yaml" ]; then
|
|
32
|
+
echo "config-only=true" >> "$GITHUB_OUTPUT"
|
|
33
|
+
else
|
|
34
|
+
echo "config-only=false" >> "$GITHUB_OUTPUT"
|
|
35
|
+
fi
|
|
36
|
+
- name: Create pull request
|
|
37
|
+
id: pr
|
|
38
|
+
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
|
39
|
+
with:
|
|
40
|
+
commit-message: Update pre-commit hooks
|
|
41
|
+
title: Update pre-commit hooks
|
|
42
|
+
body: Automated hook update from the `update-hooks` workflow, using `prek update`.
|
|
43
|
+
branch: update-pre-commit-hooks
|
|
44
|
+
delete-branch: true
|
|
45
|
+
- name: Approve CI run
|
|
46
|
+
if: steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated'
|
|
47
|
+
env:
|
|
48
|
+
GH_TOKEN: ${{ github.token }}
|
|
49
|
+
PAT: ${{ secrets.AUTOMATION_TOKEN }}
|
|
50
|
+
run: |
|
|
51
|
+
for i in $(seq 1 24); do
|
|
52
|
+
run_ids=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?branch=update-pre-commit-hooks&event=pull_request" \
|
|
53
|
+
--jq '.workflow_runs[] | select(.conclusion == "action_required") | .id')
|
|
54
|
+
[ -n "$run_ids" ] && break
|
|
55
|
+
sleep 5
|
|
56
|
+
done
|
|
57
|
+
if [ -z "$run_ids" ]; then
|
|
58
|
+
echo "No CI run waiting for approval"
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
for run_id in $run_ids; do
|
|
62
|
+
gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/approve" ||
|
|
63
|
+
GH_TOKEN="$PAT" gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/approve"
|
|
64
|
+
done
|
|
65
|
+
- name: Enable auto-merge
|
|
66
|
+
if: steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated'
|
|
67
|
+
env:
|
|
68
|
+
GH_TOKEN: ${{ github.token }}
|
|
69
|
+
PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }}
|
|
70
|
+
run: |
|
|
71
|
+
gh pr merge --squash --auto "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" ||
|
|
72
|
+
echo "::warning::Could not enable auto-merge"
|
|
73
|
+
- name: Approve if only the hook config changed
|
|
74
|
+
if: steps.changes.outputs.config-only == 'true' && (steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated')
|
|
75
|
+
env:
|
|
76
|
+
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
|
|
77
|
+
PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }}
|
|
78
|
+
run: gh pr review --approve "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v6.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: check-added-large-files
|
|
6
|
+
- id: check-merge-conflict
|
|
7
|
+
- id: check-toml
|
|
8
|
+
- id: check-yaml
|
|
9
|
+
- id: end-of-file-fixer
|
|
10
|
+
- id: trailing-whitespace
|
|
11
|
+
|
|
12
|
+
- repo: https://github.com/rstcheck/rstcheck
|
|
13
|
+
rev: v6.3.0
|
|
14
|
+
hooks:
|
|
15
|
+
- id: rstcheck
|
|
16
|
+
|
|
17
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
18
|
+
rev: v0.16.8
|
|
19
|
+
hooks:
|
|
20
|
+
- id: ruff
|
|
21
|
+
args: [--fix]
|
|
22
|
+
- id: ruff-format
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Tom Carrick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-device-cookies
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Login throttling with device cookies
|
|
5
|
+
Project-URL: Homepage, https://github.com/knyghty/django-device-cookies
|
|
6
|
+
Project-URL: Repository, https://github.com/knyghty/django-device-cookies
|
|
7
|
+
Author-email: Tom Carrick <tom@carrick.eu>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
|
+
Classifier: Framework :: Django :: 6.0
|
|
15
|
+
Classifier: Framework :: Django :: 6.1
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Security
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Requires-Dist: django>=5.2
|
|
30
|
+
Description-Content-Type: text/x-rst
|
|
31
|
+
|
|
32
|
+
=====================
|
|
33
|
+
Django Device Cookies
|
|
34
|
+
=====================
|
|
35
|
+
|
|
36
|
+
Throttle login attempts with `device cookies`_. Each login sets a signed
|
|
37
|
+
cookie, and failed attempts count per cookie, or per username for requests
|
|
38
|
+
without a valid one.
|
|
39
|
+
|
|
40
|
+
Why
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
Temporary account lockout after several failed attempts is an easy target for
|
|
44
|
+
denial of service: a fixed lockout policy lets an attacker lock selected users
|
|
45
|
+
out of the site. Locking the account/IP pair instead is better against denial
|
|
46
|
+
of service but weaker against botnets and proxies, and harder to implement
|
|
47
|
+
correctly. Device cookies are a variant of account/IP blocking that uses a
|
|
48
|
+
browser cookie instead of an IP address. The throttle trusts a client that
|
|
49
|
+
logged in before and locks it out individually. All untrusted clients share
|
|
50
|
+
one temporary lockout per account. That bounds the guesses against an account
|
|
51
|
+
to ``DEVICE_COOKIE_ATTEMPTS_PER_PERIOD`` per ``DEVICE_COOKIE_PERIOD``
|
|
52
|
+
regardless of the size of the botnet.
|
|
53
|
+
|
|
54
|
+
Installation
|
|
55
|
+
------------
|
|
56
|
+
|
|
57
|
+
1. Add ``"django_device_cookies"`` to ``INSTALLED_APPS``.
|
|
58
|
+
2. Add ``"django_device_cookies.middleware.DeviceCookieMiddleware"`` to
|
|
59
|
+
``MIDDLEWARE``.
|
|
60
|
+
3. Put ``"django_device_cookies.backends.DeviceCookieBackend"`` first in
|
|
61
|
+
``AUTHENTICATION_BACKENDS``, before ``ModelBackend`` or your own backends.
|
|
62
|
+
4. Run ``manage.py migrate``.
|
|
63
|
+
|
|
64
|
+
With two backends, a call to ``login()`` for a user that did not come from
|
|
65
|
+
``authenticate()`` must pass ``backend``, as the Django documentation says.
|
|
66
|
+
|
|
67
|
+
``django_device_cookies.backends.DeviceCookieModelBackend`` is ``ModelBackend``
|
|
68
|
+
with the throttle built in, for projects that want a single entry. Django
|
|
69
|
+
stores the path of the backend that authenticated a user in the session.
|
|
70
|
+
Replacing ``ModelBackend`` with it logs out every existing session.
|
|
71
|
+
|
|
72
|
+
Settings
|
|
73
|
+
--------
|
|
74
|
+
|
|
75
|
+
The defaults:
|
|
76
|
+
|
|
77
|
+
.. code-block:: python
|
|
78
|
+
|
|
79
|
+
DEVICE_COOKIE_NAME = "django_device"
|
|
80
|
+
DEVICE_COOKIE_PERIOD = timedelta(minutes=15)
|
|
81
|
+
DEVICE_COOKIE_ATTEMPTS_PER_PERIOD = 5
|
|
82
|
+
DEVICE_COOKIE_MAX_AGE = timedelta(days=365)
|
|
83
|
+
DEVICE_COOKIE_SECURE = True
|
|
84
|
+
DEVICE_COOKIE_SAMESITE = "Lax"
|
|
85
|
+
DEVICE_COOKIE_DOMAIN = None
|
|
86
|
+
DEVICE_COOKIE_PATH = "/"
|
|
87
|
+
DEVICE_COOKIE_PER_USER = False
|
|
88
|
+
DEVICE_COOKIE_REVOKE_AFTER_FAILURES = None
|
|
89
|
+
|
|
90
|
+
``DEVICE_COOKIE_PER_USER`` sets one cookie per user instead of per browser.
|
|
91
|
+
``DEVICE_COOKIE_REVOKE_AFTER_FAILURES`` stops trusting a cookie after that many
|
|
92
|
+
failures over its life.
|
|
93
|
+
|
|
94
|
+
Password reset
|
|
95
|
+
--------------
|
|
96
|
+
|
|
97
|
+
An attacker can keep the untrusted lockout for an account in place, which
|
|
98
|
+
blocks its user from logging in from a new device. OWASP suggests issuing a
|
|
99
|
+
device cookie when the user visits a password reset link, since that proves
|
|
100
|
+
possession of the email account. An actual password reset is not necessary.
|
|
101
|
+
``django_device_cookies.views.PasswordResetConfirmView`` does this. Add it to
|
|
102
|
+
your URLs before ``include("django.contrib.auth.urls")``:
|
|
103
|
+
|
|
104
|
+
.. code-block:: python
|
|
105
|
+
|
|
106
|
+
from django_device_cookies.views import PasswordResetConfirmView
|
|
107
|
+
|
|
108
|
+
path(
|
|
109
|
+
"accounts/reset/<uidb64>/<token>/",
|
|
110
|
+
PasswordResetConfirmView.as_view(),
|
|
111
|
+
name="password_reset_confirm",
|
|
112
|
+
),
|
|
113
|
+
|
|
114
|
+
It renders ``registration/password_reset_confirm.html``, like Django's view.
|
|
115
|
+
|
|
116
|
+
Signals
|
|
117
|
+
-------
|
|
118
|
+
|
|
119
|
+
``django_device_cookies.signals.lockout``
|
|
120
|
+
Sent when an account or a device reaches the failed attempt limit.
|
|
121
|
+
|
|
122
|
+
Arguments sent with this signal:
|
|
123
|
+
|
|
124
|
+
``sender``
|
|
125
|
+
The ``FailedAuthenticationAttempt`` class.
|
|
126
|
+
|
|
127
|
+
``username``
|
|
128
|
+
The normalized username.
|
|
129
|
+
|
|
130
|
+
``device``
|
|
131
|
+
The device cookie nonce, or ``""`` for untrusted clients.
|
|
132
|
+
|
|
133
|
+
``request``
|
|
134
|
+
The current ``HttpRequest``, or ``None``.
|
|
135
|
+
|
|
136
|
+
Maintenance
|
|
137
|
+
-----------
|
|
138
|
+
|
|
139
|
+
Nothing removes expired failed attempts automatically. The
|
|
140
|
+
``clear_device_cookie_attempts`` management command deletes them. Run it on a
|
|
141
|
+
regular basis, for example as a daily cron job.
|
|
142
|
+
|
|
143
|
+
Limitations
|
|
144
|
+
-----------
|
|
145
|
+
|
|
146
|
+
- The throttle covers only calls to ``authenticate()`` and
|
|
147
|
+
``aauthenticate()``. Calls without a request count as untrusted.
|
|
148
|
+
- Attempts sent in parallel can exceed the limit.
|
|
149
|
+
- If Django masks your ``USERNAME_FIELD`` in the ``user_login_failed`` signal,
|
|
150
|
+
the throttle ignores calls without a request that pass it by name.
|
|
151
|
+
|
|
152
|
+
System checks
|
|
153
|
+
-------------
|
|
154
|
+
|
|
155
|
+
* **device_cookies.E001**: ``AUTHENTICATION_BACKENDS`` has no device cookie
|
|
156
|
+
backend. Nothing throttles logins.
|
|
157
|
+
* **device_cookies.W001**: The device cookie backend is not first in
|
|
158
|
+
``AUTHENTICATION_BACKENDS``. A locked-out client that knows the password
|
|
159
|
+
still logs in.
|
|
160
|
+
* **device_cookies.E002**: ``MIDDLEWARE`` has no device cookie middleware. No
|
|
161
|
+
client gets a device cookie, and every login attempt counts as untrusted.
|
|
162
|
+
* **device_cookies.E003**: ``<setting>`` must be ``<expected>``.
|
|
163
|
+
* **device_cookies.E004**: Browsers reject a ``SameSite=None`` cookie that is
|
|
164
|
+
not ``Secure``. No client gets a device cookie.
|
|
165
|
+
* **device_cookies.E005**: ``AUTHENTICATION_BACKENDS`` has only the device
|
|
166
|
+
cookie backend. Nothing authenticates.
|
|
167
|
+
* **device_cookies.W003**: Django masks ``USERNAME_FIELD`` ``<field>`` in the
|
|
168
|
+
``user_login_failed`` signal. The throttle ignores calls to
|
|
169
|
+
``authenticate()`` without a request that pass it by name.
|
|
170
|
+
|
|
171
|
+
This check runs only with the ``--deploy`` option:
|
|
172
|
+
|
|
173
|
+
* **device_cookies.W002**: ``DEVICE_COOKIE_SECURE`` is off. Browsers send
|
|
174
|
+
device cookies over HTTP.
|
|
175
|
+
|
|
176
|
+
.. _`device cookies`: https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
=====================
|
|
2
|
+
Django Device Cookies
|
|
3
|
+
=====================
|
|
4
|
+
|
|
5
|
+
Throttle login attempts with `device cookies`_. Each login sets a signed
|
|
6
|
+
cookie, and failed attempts count per cookie, or per username for requests
|
|
7
|
+
without a valid one.
|
|
8
|
+
|
|
9
|
+
Why
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Temporary account lockout after several failed attempts is an easy target for
|
|
13
|
+
denial of service: a fixed lockout policy lets an attacker lock selected users
|
|
14
|
+
out of the site. Locking the account/IP pair instead is better against denial
|
|
15
|
+
of service but weaker against botnets and proxies, and harder to implement
|
|
16
|
+
correctly. Device cookies are a variant of account/IP blocking that uses a
|
|
17
|
+
browser cookie instead of an IP address. The throttle trusts a client that
|
|
18
|
+
logged in before and locks it out individually. All untrusted clients share
|
|
19
|
+
one temporary lockout per account. That bounds the guesses against an account
|
|
20
|
+
to ``DEVICE_COOKIE_ATTEMPTS_PER_PERIOD`` per ``DEVICE_COOKIE_PERIOD``
|
|
21
|
+
regardless of the size of the botnet.
|
|
22
|
+
|
|
23
|
+
Installation
|
|
24
|
+
------------
|
|
25
|
+
|
|
26
|
+
1. Add ``"django_device_cookies"`` to ``INSTALLED_APPS``.
|
|
27
|
+
2. Add ``"django_device_cookies.middleware.DeviceCookieMiddleware"`` to
|
|
28
|
+
``MIDDLEWARE``.
|
|
29
|
+
3. Put ``"django_device_cookies.backends.DeviceCookieBackend"`` first in
|
|
30
|
+
``AUTHENTICATION_BACKENDS``, before ``ModelBackend`` or your own backends.
|
|
31
|
+
4. Run ``manage.py migrate``.
|
|
32
|
+
|
|
33
|
+
With two backends, a call to ``login()`` for a user that did not come from
|
|
34
|
+
``authenticate()`` must pass ``backend``, as the Django documentation says.
|
|
35
|
+
|
|
36
|
+
``django_device_cookies.backends.DeviceCookieModelBackend`` is ``ModelBackend``
|
|
37
|
+
with the throttle built in, for projects that want a single entry. Django
|
|
38
|
+
stores the path of the backend that authenticated a user in the session.
|
|
39
|
+
Replacing ``ModelBackend`` with it logs out every existing session.
|
|
40
|
+
|
|
41
|
+
Settings
|
|
42
|
+
--------
|
|
43
|
+
|
|
44
|
+
The defaults:
|
|
45
|
+
|
|
46
|
+
.. code-block:: python
|
|
47
|
+
|
|
48
|
+
DEVICE_COOKIE_NAME = "django_device"
|
|
49
|
+
DEVICE_COOKIE_PERIOD = timedelta(minutes=15)
|
|
50
|
+
DEVICE_COOKIE_ATTEMPTS_PER_PERIOD = 5
|
|
51
|
+
DEVICE_COOKIE_MAX_AGE = timedelta(days=365)
|
|
52
|
+
DEVICE_COOKIE_SECURE = True
|
|
53
|
+
DEVICE_COOKIE_SAMESITE = "Lax"
|
|
54
|
+
DEVICE_COOKIE_DOMAIN = None
|
|
55
|
+
DEVICE_COOKIE_PATH = "/"
|
|
56
|
+
DEVICE_COOKIE_PER_USER = False
|
|
57
|
+
DEVICE_COOKIE_REVOKE_AFTER_FAILURES = None
|
|
58
|
+
|
|
59
|
+
``DEVICE_COOKIE_PER_USER`` sets one cookie per user instead of per browser.
|
|
60
|
+
``DEVICE_COOKIE_REVOKE_AFTER_FAILURES`` stops trusting a cookie after that many
|
|
61
|
+
failures over its life.
|
|
62
|
+
|
|
63
|
+
Password reset
|
|
64
|
+
--------------
|
|
65
|
+
|
|
66
|
+
An attacker can keep the untrusted lockout for an account in place, which
|
|
67
|
+
blocks its user from logging in from a new device. OWASP suggests issuing a
|
|
68
|
+
device cookie when the user visits a password reset link, since that proves
|
|
69
|
+
possession of the email account. An actual password reset is not necessary.
|
|
70
|
+
``django_device_cookies.views.PasswordResetConfirmView`` does this. Add it to
|
|
71
|
+
your URLs before ``include("django.contrib.auth.urls")``:
|
|
72
|
+
|
|
73
|
+
.. code-block:: python
|
|
74
|
+
|
|
75
|
+
from django_device_cookies.views import PasswordResetConfirmView
|
|
76
|
+
|
|
77
|
+
path(
|
|
78
|
+
"accounts/reset/<uidb64>/<token>/",
|
|
79
|
+
PasswordResetConfirmView.as_view(),
|
|
80
|
+
name="password_reset_confirm",
|
|
81
|
+
),
|
|
82
|
+
|
|
83
|
+
It renders ``registration/password_reset_confirm.html``, like Django's view.
|
|
84
|
+
|
|
85
|
+
Signals
|
|
86
|
+
-------
|
|
87
|
+
|
|
88
|
+
``django_device_cookies.signals.lockout``
|
|
89
|
+
Sent when an account or a device reaches the failed attempt limit.
|
|
90
|
+
|
|
91
|
+
Arguments sent with this signal:
|
|
92
|
+
|
|
93
|
+
``sender``
|
|
94
|
+
The ``FailedAuthenticationAttempt`` class.
|
|
95
|
+
|
|
96
|
+
``username``
|
|
97
|
+
The normalized username.
|
|
98
|
+
|
|
99
|
+
``device``
|
|
100
|
+
The device cookie nonce, or ``""`` for untrusted clients.
|
|
101
|
+
|
|
102
|
+
``request``
|
|
103
|
+
The current ``HttpRequest``, or ``None``.
|
|
104
|
+
|
|
105
|
+
Maintenance
|
|
106
|
+
-----------
|
|
107
|
+
|
|
108
|
+
Nothing removes expired failed attempts automatically. The
|
|
109
|
+
``clear_device_cookie_attempts`` management command deletes them. Run it on a
|
|
110
|
+
regular basis, for example as a daily cron job.
|
|
111
|
+
|
|
112
|
+
Limitations
|
|
113
|
+
-----------
|
|
114
|
+
|
|
115
|
+
- The throttle covers only calls to ``authenticate()`` and
|
|
116
|
+
``aauthenticate()``. Calls without a request count as untrusted.
|
|
117
|
+
- Attempts sent in parallel can exceed the limit.
|
|
118
|
+
- If Django masks your ``USERNAME_FIELD`` in the ``user_login_failed`` signal,
|
|
119
|
+
the throttle ignores calls without a request that pass it by name.
|
|
120
|
+
|
|
121
|
+
System checks
|
|
122
|
+
-------------
|
|
123
|
+
|
|
124
|
+
* **device_cookies.E001**: ``AUTHENTICATION_BACKENDS`` has no device cookie
|
|
125
|
+
backend. Nothing throttles logins.
|
|
126
|
+
* **device_cookies.W001**: The device cookie backend is not first in
|
|
127
|
+
``AUTHENTICATION_BACKENDS``. A locked-out client that knows the password
|
|
128
|
+
still logs in.
|
|
129
|
+
* **device_cookies.E002**: ``MIDDLEWARE`` has no device cookie middleware. No
|
|
130
|
+
client gets a device cookie, and every login attempt counts as untrusted.
|
|
131
|
+
* **device_cookies.E003**: ``<setting>`` must be ``<expected>``.
|
|
132
|
+
* **device_cookies.E004**: Browsers reject a ``SameSite=None`` cookie that is
|
|
133
|
+
not ``Secure``. No client gets a device cookie.
|
|
134
|
+
* **device_cookies.E005**: ``AUTHENTICATION_BACKENDS`` has only the device
|
|
135
|
+
cookie backend. Nothing authenticates.
|
|
136
|
+
* **device_cookies.W003**: Django masks ``USERNAME_FIELD`` ``<field>`` in the
|
|
137
|
+
``user_login_failed`` signal. The throttle ignores calls to
|
|
138
|
+
``authenticate()`` without a request that pass it by name.
|
|
139
|
+
|
|
140
|
+
This check runs only with the ``--deploy`` option:
|
|
141
|
+
|
|
142
|
+
* **device_cookies.W002**: ``DEVICE_COOKIE_SECURE`` is off. Browsers send
|
|
143
|
+
device cookies over HTTP.
|
|
144
|
+
|
|
145
|
+
.. _`device cookies`: https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from django.contrib import admin
|
|
2
|
+
from django.contrib.auth import get_user_model
|
|
3
|
+
from django.db.models import Q
|
|
4
|
+
from django.db.models import QuerySet
|
|
5
|
+
from django.http import HttpRequest
|
|
6
|
+
|
|
7
|
+
from . import utils
|
|
8
|
+
from .models import FailedAuthenticationAttempt
|
|
9
|
+
from .models import hash_username
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@admin.register(FailedAuthenticationAttempt)
|
|
13
|
+
class FailedAuthenticationAttemptAdmin(admin.ModelAdmin):
|
|
14
|
+
list_display = ["key", "device", "time"]
|
|
15
|
+
ordering = ["-time"]
|
|
16
|
+
search_fields = ["key"]
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def search_help_text(self) -> str:
|
|
20
|
+
user_model = get_user_model()
|
|
21
|
+
field = user_model._meta.get_field(user_model.USERNAME_FIELD)
|
|
22
|
+
return f"Enter the {field.verbose_name} or a key."
|
|
23
|
+
|
|
24
|
+
def get_search_results(
|
|
25
|
+
self, request: HttpRequest, queryset: QuerySet, search_term: str
|
|
26
|
+
) -> tuple[QuerySet, bool]:
|
|
27
|
+
if search_term:
|
|
28
|
+
key = hash_username(utils.normalize_username(search_term))
|
|
29
|
+
queryset = queryset.filter(Q(key=key) | Q(key=search_term))
|
|
30
|
+
return queryset, False
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DeviceCookiesConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "django_device_cookies"
|
|
7
|
+
label = "device_cookies"
|
|
8
|
+
|
|
9
|
+
def ready(self) -> None:
|
|
10
|
+
from . import checks # noqa: F401
|
|
11
|
+
from . import receivers # noqa: F401
|