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.
Files changed (31) hide show
  1. django_device_cookies-0.1.0/.editorconfig +12 -0
  2. django_device_cookies-0.1.0/.github/dependabot.yml +14 -0
  3. django_device_cookies-0.1.0/.github/workflows/ci.yml +101 -0
  4. django_device_cookies-0.1.0/.github/workflows/dependabot-auto-merge.yml +33 -0
  5. django_device_cookies-0.1.0/.github/workflows/update-hooks.yml +78 -0
  6. django_device_cookies-0.1.0/.gitignore +4 -0
  7. django_device_cookies-0.1.0/.pre-commit-config.yaml +22 -0
  8. django_device_cookies-0.1.0/LICENSE +21 -0
  9. django_device_cookies-0.1.0/PKG-INFO +176 -0
  10. django_device_cookies-0.1.0/README.rst +145 -0
  11. django_device_cookies-0.1.0/codecov.yml +8 -0
  12. django_device_cookies-0.1.0/django_device_cookies/__init__.py +0 -0
  13. django_device_cookies-0.1.0/django_device_cookies/admin.py +30 -0
  14. django_device_cookies-0.1.0/django_device_cookies/apps.py +11 -0
  15. django_device_cookies-0.1.0/django_device_cookies/backends.py +66 -0
  16. django_device_cookies-0.1.0/django_device_cookies/checks.py +199 -0
  17. django_device_cookies-0.1.0/django_device_cookies/config.py +33 -0
  18. django_device_cookies-0.1.0/django_device_cookies/management/__init__.py +0 -0
  19. django_device_cookies-0.1.0/django_device_cookies/management/commands/__init__.py +0 -0
  20. django_device_cookies-0.1.0/django_device_cookies/management/commands/clear_device_cookie_attempts.py +14 -0
  21. django_device_cookies-0.1.0/django_device_cookies/middleware.py +18 -0
  22. django_device_cookies-0.1.0/django_device_cookies/migrations/0001_initial.py +39 -0
  23. django_device_cookies-0.1.0/django_device_cookies/migrations/__init__.py +0 -0
  24. django_device_cookies-0.1.0/django_device_cookies/models.py +67 -0
  25. django_device_cookies-0.1.0/django_device_cookies/py.typed +0 -0
  26. django_device_cookies-0.1.0/django_device_cookies/receivers.py +49 -0
  27. django_device_cookies-0.1.0/django_device_cookies/signals.py +3 -0
  28. django_device_cookies-0.1.0/django_device_cookies/utils.py +131 -0
  29. django_device_cookies-0.1.0/django_device_cookies/views.py +15 -0
  30. django_device_cookies-0.1.0/pyproject.toml +104 -0
  31. django_device_cookies-0.1.0/uv.lock +425 -0
@@ -0,0 +1,12 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ indent_size = 4
7
+ indent_style = space
8
+ insert_final_newline = true
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.{css,html,js,yaml,yml}]
12
+ indent_size = 2
@@ -0,0 +1,14 @@
1
+ version: 2
2
+
3
+ updates:
4
+ - package-ecosystem: "github-actions"
5
+ directory: "/"
6
+ schedule:
7
+ interval: "daily"
8
+ groups:
9
+ github-actions:
10
+ patterns: ["*"]
11
+ - package-ecosystem: "uv"
12
+ directory: "/"
13
+ schedule:
14
+ interval: "weekly"
@@ -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,4 @@
1
+ __pycache__/
2
+ .coverage
3
+ coverage.xml
4
+ dist/
@@ -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
@@ -0,0 +1,8 @@
1
+ coverage:
2
+ status:
3
+ project:
4
+ default:
5
+ target: 100%
6
+ patch:
7
+ default:
8
+ target: 100%
@@ -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