Codebase list django-axes / 820df50
Import upstream version 5.20.0+git20210802.1.eddac32 Debian Janitor 2 years ago
93 changed file(s) with 6887 addition(s) and 2726 deletion(s). Raw diff Collapse all Expand all
+0
-3
.coveragerc less more
0 [run]
1 branch = True
2 source = axes
0 version: 2
1 updates:
2 - package-ecosystem: "pip"
3 directory: "/"
4 schedule:
5 interval: "daily"
6 time: "12:00"
7 open-pull-requests-limit: 10
0 name: Release
1
2 on:
3 push:
4 tags:
5 - '*'
6
7 jobs:
8 build:
9 if: github.repository == 'jazzband/django-axes'
10 runs-on: ubuntu-latest
11
12 steps:
13 - uses: actions/checkout@v2
14 with:
15 fetch-depth: 0
16
17 - name: Set up Python
18 uses: actions/setup-python@v2
19 with:
20 python-version: 3.8
21
22 - name: Install dependencies
23 run: |
24 python -m pip install -U pip
25 python -m pip install -U setuptools twine wheel
26
27 - name: Build package
28 run: |
29 python setup.py --version
30 python setup.py sdist --format=gztar bdist_wheel
31 twine check dist/*
32
33 - name: Upload packages to Jazzband
34 if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
35 uses: pypa/gh-action-pypi-publish@master
36 with:
37 user: jazzband
38 password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
39 repository_url: https://jazzband.co/projects/django-axes/upload
0 name: Test
1
2 on: [push, pull_request]
3
4 jobs:
5 build:
6 name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }})
7 runs-on: ubuntu-latest
8 strategy:
9 fail-fast: false
10 matrix:
11 python-version: ['3.6', '3.7', '3.8', '3.9', 'pypy3']
12 django-version: ['2.2', '3.1', '3.2']
13 # Tox configuration for QA environment
14 include:
15 - python-version: '3.8'
16 django-version: 'qa'
17 # Django > 3.2 only supports >= Python 3.8
18 - python-version: '3.8'
19 django-version: 'main'
20 experimental: true
21 - python-version: '3.9'
22 django-version: 'main'
23 experimental: true
24 - python-version: 'pypy3'
25 django-version: 'main'
26 experimental: true
27
28 steps:
29 - uses: actions/checkout@v2
30
31 - name: Set up Python ${{ matrix.python-version }}
32 uses: actions/setup-python@v2
33 with:
34 python-version: ${{ matrix.python-version }}
35
36 - name: Get pip cache dir
37 id: pip-cache
38 run: |
39 echo "::set-output name=dir::$(pip cache dir)"
40
41 - name: Cache
42 uses: actions/cache@v2
43 with:
44 path: ${{ steps.pip-cache.outputs.dir }}
45 key:
46 ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
47 restore-keys: |
48 ${{ matrix.python-version }}-v1-
49
50 - name: Install dependencies
51 run: |
52 python -m pip install --upgrade pip
53 python -m pip install --upgrade tox tox-gh-actions
54
55 - name: Tox tests
56 run: |
57 tox -v
58 env:
59 DJANGO: ${{ matrix.django-version }}
60
61 - name: Upload coverage
62 uses: codecov/codecov-action@v1
63 with:
64 name: Python ${{ matrix.python-version }}
11 *.pyc
22 *.swp
33 .coverage
4 coverage.xml
45 .DS_Store
56 .idea
67 .mypy_cache/
1415 test.db
1516 .eggs
1617 pip-wheel-metadata
18 .vscode/
+0
-40
.travis.yml less more
0 dist: xenial
1 language: python
2 cache: pip
3 python:
4 - 3.6
5 - 3.7
6 - 3.8
7 - pypy3
8 env:
9 - DJANGO=2.2
10 - DJANGO=3.0
11 - DJANGO=3.1
12 - DJANGO=master
13 jobs:
14 fast_finish: true
15 allow_failures:
16 - env: DJANGO=master
17 include:
18 - python: 3.6
19 env: TOXENV=qa
20 - stage: deploy
21 env:
22 python: 3.6
23 script: skip
24 deploy:
25 provider: pypi
26 user: jazzband
27 server: https://jazzband.co/projects/django-axes/upload
28 distributions: sdist bdist_wheel
29 password:
30 secure: TCH5tGIggL2wsWce2svMwpEpPiwVOYqq1R3uSBTexszleP0OafNq/wZk2KZEReR5w1Aq68qp5F5Eeh2ZjJTq4f9M4LtTvqQzrmyNP55DYk/uB1rBJm9b4gBgMtAknxdI2g7unkhQEDo4suuPCVofM7rrDughySNpmvlUQYDttHQ=
31 skip_existing: true
32 on:
33 tags: true
34 repo: jazzband/django-axes
35 python: 3.6
36 install: pip install tox-travis codecov
37 script: tox
38 after_success:
39 - codecov
00
11 Changes
22 =======
3
4
5 5.20.0 (2021-06-29)
6 -------------------
7
8 - Improve race condition handling in e.g. multi-process environments by using
9 ``get_or_create`` for access attempt fetching and updates.
10 [uli-klank]
11
12
13 5.19.0 (2021-06-16)
14 -------------------
15
16 - Add Polish locale.
17 [Quadric]
18
19
20 5.18.0 (2021-06-09)
21 -------------------
22
23 - Fix ``default_auto_field`` warning.
24 [zkanda]
25
26
27 5.17.0 (2021-06-05)
28 -------------------
29
30 - Fix ``default_app_config`` deprecation.
31 Django 3.2 automatically detects ``AppConfig`` and therefore this setting is no longer required.
32 [nikolaik]
33
34
35 5.16.0 (2021-05-19)
36 -------------------
37
38 - Add ``AXES_CLIENT_STR_CALLABLE`` setting.
39 [smtydn]
40
41
42 5.15.0 (2021-05-03)
43 -------------------
44
45 - Add option to cleanse sensitive GET and POST params in database handler
46 with the ``AXES_SENSITIVE_PARAMETERS`` setting.
47 [mcoconnor]
48
49
50 5.14.0 (2021-04-06)
51 -------------------
52
53 - Improve message formatting for lockout message and translations.
54 [ashokdelphia]
55 - Remove support for Django 3.0.
56 [hramezani]
57 - Add support for Django 3.2.
58 [hramezani]
59
60
61 5.13.1 (2021-02-22)
62 -------------------
63
64 - Default ``AXES_VERBOSE`` to ``AXES_ENABLED`` configuration setting,
65 disabling verbose startup logging when Axes itself is disabled.
66 [christianbundy]
67 - Update documentation.
68 [KStenK]
69
70
71 5.13.0 (2021-02-15)
72 -------------------
73
74 - Add support for resetting attempts with cache backend.
75 [nattyg93]
76
77
78 5.12.0 (2021-01-07)
79 -------------------
80
81 - Clean up test structure and migrate tests outside
82 the main package for a smaller wheel distributions.
83 [aleksihakli]
84 - Move configuration to pyproject.toml for cleaner layout.
85 [aleksihakli]
86 - Clean up test settings override configuration.
87 [hramezani]
88
89
90 5.11.1 (2021-01-06)
91 -------------------
92
93 - Fix cache entry creations for None username.
94 [cabarnes]
95
96
97 5.11.0 (2021-01-05)
98 -------------------
99
100 - Add lockout view CORS support with ``AXES_ALLOWED_CORS_ORIGINS`` configuration flag.
101 [vladox]
102 - Add missing ``@wraps`` decorator to ``axes.decorators.axes_dispatch``.
103 [aleksihakli]
104
105
106 5.10.1 (2021-01-04)
107 -------------------
108
109 - Add ``DEFAULT_AUTO_FIELD`` to test settings.
110 [hramezani]
111 - Fix documentation language.
112 [danielquinn]
113 - Fix Python package version specifiers and remove redundant imports.
114 [aleksihakli]
115
116
117 5.10.0 (2020-12-18)
118 -------------------
119
120 - Deprecate stock DRF support from 5.8.0,
121 require users to set it up per project.
122 Check the documentation for more information.
123 [aleksihakli]
124
125
126 5.9.1 (2020-12-02)
127 ------------------
128
129 - Move tests to GitHub Actions
130 [jezdez]
131 - Fix running Axes code in middleware when ``AXES_ENABLED`` is ``False``.
132 [ashokdelphia]
133
134
135 5.9.0 (2020-11-05)
136 ------------------
137
138 - Add Python 3.9 support.
139 [hramezani]
140 - Prevent ``AccessAttempt`` creation with database handler when
141 username is not set and ``AXES_ONLY_USER_FAILURES`` setting is not set.
142 [hramezani]
143
144
145 5.8.0 (2020-10-16)
146 ------------------
147
148 - Improve Django REST Framework (DRF) integration.
149 [Anatoly]
150
151
152 5.7.1 (2020-09-27)
153 ------------------
154
155 - Adjust settings import and handling chain
156 for cleaner module import and invocation order.
157 [aleksihakli]
158 - Adjust the use of ``AXES_ENABLED`` flag so that
159 imports are always done the same way and initial log
160 is written regardless of the setting and it only affects
161 code that is decorated or wrapped with ``toggleable``.
162 [alekshakli]
163
164
165 5.7.0 (2020-09-26)
166 ------------------
167
168 - Deprecate ``AXES_LOGGER`` Axes setting and move to ``__name__``
169 based logging and fully qualified Python module name log identifiers.
170 [aleksihakli]
171
172
173 5.6.2 (2020-09-20)
174 ------------------
175
176 - Fix regression in ``axes_reset_user`` management command.
177 [aleksihakli]
178
179
180 5.6.1 (2020-09-17)
181 ------------------
182
183 - Improve test dependency management and upgrade black code formatter.
184 [smithdc1]
185
186
187 5.6.0 (2020-09-12)
188 ------------------
189
190 - Add proper development ``subTest`` support via ``pytest-subtests`` package.
191 [smithdc1]
192 - Deprecate ``django-appconf`` and use plain settings for Axes.
193 [aleksihakli]
194
195
196 5.5.2 (2020-09-11)
197 ------------------
198
199 - Update deprecating use of the ``request.is_ajax`` method.
200 [smithdc1]
201
202
203 5.5.1 (2020-09-10)
204 ------------------
205
206 - Update deprecated uses of Django modules and members.
207 [smithdc1]
208
209
210 5.5.0 (2020-08-21)
211 ------------------
212
213 - Add support for locking requests based on
214 username OR IP address with inclusive or
215 using the ``LOCK_OUT_BY_USER_OR_IP`` flag.
216 [PetrDlouhy]
217 - Deprecate Signal ``providing_args`` for Django 3.1 support.
218 [coredumperror]
3219
4220
5221 5.4.3 (2020-08-06)
+0
-4
MANIFEST.in less more
0 include LICENSE README.rst CHANGES.rst
1 recursive-include axes *.py
2 recursive-include axes/locale *.mo *.po
3 recursive-include docs *.rst
0 Metadata-Version: 1.2
1 Name: django-axes
2 Version: 0.1.dev1265+geddac32
3 Summary: Keep track of failed login attempts in Django-powered sites.
4 Home-page: https://github.com/jazzband/django-axes
5 Author: Josh VanderLinden, Philip Neustrom, Michael Blume, Alex Clark, Camilo Nova, Aleksi Hakli
6 Author-email: security@jazzband.co
7 Maintainer: Jazzband
8 Maintainer-email: security@jazzband.co
9 License: MIT
10 Project-URL: Documentation, https://django-axes.readthedocs.io/
11 Project-URL: Source, https://github.com/jazzband/django-axes
12 Project-URL: Tracker, https://github.com/jazzband/django-axes/issues
13 Description:
14 django-axes
15 ===========
16
17 .. image:: https://jazzband.co/static/img/badge.svg
18 :target: https://jazzband.co/
19 :alt: Jazzband
20
21 .. image:: https://img.shields.io/github/stars/jazzband/django-axes.svg?label=Stars&style=socialcA
22 :target: https://github.com/jazzband/django-axes
23 :alt: GitHub
24
25 .. image:: https://img.shields.io/pypi/v/django-axes.svg
26 :target: https://pypi.org/project/django-axes/
27 :alt: PyPI release
28
29 .. image:: https://img.shields.io/pypi/pyversions/django-axes.svg
30 :target: https://pypi.org/project/django-axes/
31 :alt: Supported Python versions
32
33 .. image:: https://img.shields.io/pypi/djversions/django-axes.svg
34 :target: https://pypi.org/project/django-axes/
35 :alt: Supported Django versions
36
37 .. image:: https://img.shields.io/readthedocs/django-axes.svg
38 :target: https://django-axes.readthedocs.io/
39 :alt: Documentation
40
41 .. image:: https://github.com/jazzband/django-axes/workflows/Test/badge.svg
42 :target: https://github.com/jazzband/django-axes/actions
43 :alt: GitHub Actions
44
45 .. image:: https://codecov.io/gh/jazzband/django-axes/branch/master/graph/badge.svg
46 :target: https://codecov.io/gh/jazzband/django-axes
47 :alt: Coverage
48
49
50 Axes is a Django plugin for keeping track of suspicious
51 login attempts for your Django based website
52 and implementing simple brute-force attack blocking.
53
54 The name is sort of a geeky pun, since it can be interpreted as:
55
56 * ``access``, as in monitoring access attempts, or
57 * ``axes``, as in tools you can use to hack (generally on wood).
58
59
60 Functionality
61 -------------
62
63 Axes records login attempts to your Django powered site and prevents attackers
64 from attempting further logins to your site when they exceed the configured attempt limit.
65
66 Axes can track the attempts and persist them in the database indefinitely,
67 or alternatively use a fast and DDoS resistant cache implementation.
68
69 Axes can be configured to monitor login attempts by
70 IP address, username, user agent, or their combinations.
71
72 Axes supports cool off periods, IP address whitelisting and blacklisting,
73 user account whitelisting, and other features for Django access management.
74
75
76 Documentation
77 -------------
78
79 For more information on installation and configuration see the documentation at:
80
81 https://django-axes.readthedocs.io/
82
83
84 Issues
85 ------
86
87 If you have questions or have trouble using the app please file a bug report at:
88
89 https://github.com/jazzband/django-axes/issues
90
91
92 Contributions
93 -------------
94
95 All contributions are welcome!
96
97 It is best to separate proposed changes and PRs into small, distinct patches
98 by type so that they can be merged faster into upstream and released quicker.
99
100 One way to organize contributions would be to separate PRs for e.g.
101
102 * bugfixes,
103 * new features,
104 * code and design improvements,
105 * documentation improvements, or
106 * tooling and CI improvements.
107
108 Merging contributions requires passing the checks configured
109 with the CI. This includes running tests and linters successfully
110 on the currently officially supported Python and Django versions.
111
112 The test automation is run automatically with GitHub Actions, but you can
113 run it locally with the ``tox`` command before pushing commits.
114
115 Please note that this is a `Jazzband <https://jazzband.co>`_ project.
116 By contributing you agree to abide by the
117 `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_
118 and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
119
120
121 Changes
122 =======
123
124
125 5.20.0 (2021-06-29)
126 -------------------
127
128 - Improve race condition handling in e.g. multi-process environments by using
129 ``get_or_create`` for access attempt fetching and updates.
130 [uli-klank]
131
132
133 5.19.0 (2021-06-16)
134 -------------------
135
136 - Add Polish locale.
137 [Quadric]
138
139
140 5.18.0 (2021-06-09)
141 -------------------
142
143 - Fix ``default_auto_field`` warning.
144 [zkanda]
145
146
147 5.17.0 (2021-06-05)
148 -------------------
149
150 - Fix ``default_app_config`` deprecation.
151 Django 3.2 automatically detects ``AppConfig`` and therefore this setting is no longer required.
152 [nikolaik]
153
154
155 5.16.0 (2021-05-19)
156 -------------------
157
158 - Add ``AXES_CLIENT_STR_CALLABLE`` setting.
159 [smtydn]
160
161
162 5.15.0 (2021-05-03)
163 -------------------
164
165 - Add option to cleanse sensitive GET and POST params in database handler
166 with the ``AXES_SENSITIVE_PARAMETERS`` setting.
167 [mcoconnor]
168
169
170 5.14.0 (2021-04-06)
171 -------------------
172
173 - Improve message formatting for lockout message and translations.
174 [ashokdelphia]
175 - Remove support for Django 3.0.
176 [hramezani]
177 - Add support for Django 3.2.
178 [hramezani]
179
180
181 5.13.1 (2021-02-22)
182 -------------------
183
184 - Default ``AXES_VERBOSE`` to ``AXES_ENABLED`` configuration setting,
185 disabling verbose startup logging when Axes itself is disabled.
186 [christianbundy]
187 - Update documentation.
188 [KStenK]
189
190
191 5.13.0 (2021-02-15)
192 -------------------
193
194 - Add support for resetting attempts with cache backend.
195 [nattyg93]
196
197
198 5.12.0 (2021-01-07)
199 -------------------
200
201 - Clean up test structure and migrate tests outside
202 the main package for a smaller wheel distributions.
203 [aleksihakli]
204 - Move configuration to pyproject.toml for cleaner layout.
205 [aleksihakli]
206 - Clean up test settings override configuration.
207 [hramezani]
208
209
210 5.11.1 (2021-01-06)
211 -------------------
212
213 - Fix cache entry creations for None username.
214 [cabarnes]
215
216
217 5.11.0 (2021-01-05)
218 -------------------
219
220 - Add lockout view CORS support with ``AXES_ALLOWED_CORS_ORIGINS`` configuration flag.
221 [vladox]
222 - Add missing ``@wraps`` decorator to ``axes.decorators.axes_dispatch``.
223 [aleksihakli]
224
225
226 5.10.1 (2021-01-04)
227 -------------------
228
229 - Add ``DEFAULT_AUTO_FIELD`` to test settings.
230 [hramezani]
231 - Fix documentation language.
232 [danielquinn]
233 - Fix Python package version specifiers and remove redundant imports.
234 [aleksihakli]
235
236
237 5.10.0 (2020-12-18)
238 -------------------
239
240 - Deprecate stock DRF support from 5.8.0,
241 require users to set it up per project.
242 Check the documentation for more information.
243 [aleksihakli]
244
245
246 5.9.1 (2020-12-02)
247 ------------------
248
249 - Move tests to GitHub Actions
250 [jezdez]
251 - Fix running Axes code in middleware when ``AXES_ENABLED`` is ``False``.
252 [ashokdelphia]
253
254
255 5.9.0 (2020-11-05)
256 ------------------
257
258 - Add Python 3.9 support.
259 [hramezani]
260 - Prevent ``AccessAttempt`` creation with database handler when
261 username is not set and ``AXES_ONLY_USER_FAILURES`` setting is not set.
262 [hramezani]
263
264
265 5.8.0 (2020-10-16)
266 ------------------
267
268 - Improve Django REST Framework (DRF) integration.
269 [Anatoly]
270
271
272 5.7.1 (2020-09-27)
273 ------------------
274
275 - Adjust settings import and handling chain
276 for cleaner module import and invocation order.
277 [aleksihakli]
278 - Adjust the use of ``AXES_ENABLED`` flag so that
279 imports are always done the same way and initial log
280 is written regardless of the setting and it only affects
281 code that is decorated or wrapped with ``toggleable``.
282 [alekshakli]
283
284
285 5.7.0 (2020-09-26)
286 ------------------
287
288 - Deprecate ``AXES_LOGGER`` Axes setting and move to ``__name__``
289 based logging and fully qualified Python module name log identifiers.
290 [aleksihakli]
291
292
293 5.6.2 (2020-09-20)
294 ------------------
295
296 - Fix regression in ``axes_reset_user`` management command.
297 [aleksihakli]
298
299
300 5.6.1 (2020-09-17)
301 ------------------
302
303 - Improve test dependency management and upgrade black code formatter.
304 [smithdc1]
305
306
307 5.6.0 (2020-09-12)
308 ------------------
309
310 - Add proper development ``subTest`` support via ``pytest-subtests`` package.
311 [smithdc1]
312 - Deprecate ``django-appconf`` and use plain settings for Axes.
313 [aleksihakli]
314
315
316 5.5.2 (2020-09-11)
317 ------------------
318
319 - Update deprecating use of the ``request.is_ajax`` method.
320 [smithdc1]
321
322
323 5.5.1 (2020-09-10)
324 ------------------
325
326 - Update deprecated uses of Django modules and members.
327 [smithdc1]
328
329
330 5.5.0 (2020-08-21)
331 ------------------
332
333 - Add support for locking requests based on
334 username OR IP address with inclusive or
335 using the ``LOCK_OUT_BY_USER_OR_IP`` flag.
336 [PetrDlouhy]
337 - Deprecate Signal ``providing_args`` for Django 3.1 support.
338 [coredumperror]
339
340
341 5.4.3 (2020-08-06)
342 ------------------
343
344 - Add Django 3.1 support.
345 [hramezani]
346
347
348 5.4.2 (2020-07-28)
349 ------------------
350
351 - Add ABC or abstract base class implementation for handlers.
352 [jorlugaqui]
353
354
355 5.4.1 (2020-07-03)
356 ------------------
357
358 - Fix code styling for linters.
359 [aleksihakli]
360
361
362 5.4.0 (2020-07-03)
363 ------------------
364
365 - Propagate username to lockout view in URL parameters.
366 [PetrDlouhy]
367 - Update CAPTCHA examples.
368 [PetrDlouhy]
369 - Upgrade django-ipware to version 3.
370 [hramezani,mnislam01]
371
372
373 5.3.5 (2020-07-02)
374 ------------------
375
376 - Restrict ipware version for version compatibility.
377 [aleksihakli]
378
379
380 5.3.4 (2020-06-09)
381 ------------------
382
383 - Deprecate Django 1.11 LTS support.
384 [aleksihakli]
385
386
387 5.3.3 (2020-05-22)
388 ------------------
389
390 - Fix ``AXES_ONLY_ADMIN_SITE`` functionality when
391 no default admin site is defined in the URL configuration.
392 [igor-shevchenko]
393
394
395 5.3.2 (2020-05-15)
396 ------------------
397
398 - Fix AppConf settings prefix for Fargate.
399 [marksweb]
400
401
402 5.3.1 (2020-03-23)
403 ------------------
404
405 - Fix null byte ValueError bug in ORM.
406 [ddimmich]
407
408
409 5.3.0 (2020-03-10)
410 ------------------
411
412 - Improve Django REST Framework compatibility.
413 [I0x4dI]
414
415
416 5.2.2 (2020-01-08)
417 ------------------
418
419 - Add missing proxy implementation for
420 ``axes.handlers.proxy.AxesProxyHandler.get_failures``.
421 [aleksihakli]
422
423
424 5.2.1 (2020-01-08)
425 ------------------
426
427 - Add django-reversion compatibility notes.
428 [mark-mishyn]
429 - Add pluggable lockout responses and the
430 ``AXES_LOCKOUT_CALLABLE`` configuration flag.
431 [aleksihakli]
432
433
434 5.2.0 (2020-01-01)
435 ------------------
436
437 - Add a test handler.
438 [aidanlister]
439
440
441 5.1.0 (2019-12-29)
442 ------------------
443
444 - Add pluggable user account whitelisting and the
445 ``AXES_WHITELIST_CALLABLE`` configuration flag.
446 [aleksihakli]
447
448
449 5.0.20 (2019-12-01)
450 -------------------
451
452 - Fix django-allauth compatibility issue.
453 [hramezani]
454 - Improve tests for login attempt monitoring.
455 [hramezani]
456 - Add reverse proxy documentation.
457 [ckcollab]
458 - Update OAuth documentation examples.
459 [aleksihakli]
460
461
462 5.0.19 (2019-11-06)
463 -------------------
464
465 - Optimize access attempt fetching in database handler.
466 [hramezani]
467 - Optimize request data fetching in proxy handler.
468 [hramezani]
469
470
471 5.0.18 (2019-10-17)
472 -------------------
473
474 - Add ``cooloff_timedelta`` context variable to lockout responses.
475 [jstockwin]
476
477
478 5.0.17 (2019-10-15)
479 -------------------
480
481 - Safer string formatting for user input.
482 [aleksihakli]
483
484
485 5.0.16 (2019-10-15)
486 -------------------
487
488 - Fix string formatting bug in logging.
489 [zerolab]
490
491
492 5.0.15 (2019-10-09)
493 -------------------
494
495 - Add ``AXES_ENABLE_ADMIN`` flag.
496 [flannelhead]
497
498
499 5.0.14 (2019-09-28)
500 -------------------
501
502 - Docs, CI pipeline, and code formatting improvements
503 [aleksihakli]
504
505
506 5.0.13 (2019-08-30)
507 -------------------
508
509 - Python 3.8 and PyPy support.
510 [aleksihakli]
511 - Migrate to ``setuptools_scm`` and automatic versioning.
512 [aleksihakli]
513
514
515 5.0.12 (2019-08-05)
516 -------------------
517
518 - Support callables for ``AXES_COOLOFF_TIME`` setting.
519 [DariaPlotnikova]
520
521
522 5.0.11 (2019-07-25)
523 -------------------
524
525 - Fix typo in rST formatting that prevented 5.0.10 release to PyPI.
526 [aleksihakli]
527
528
529 5.0.10 (2019-07-25)
530 -------------------
531
532 - Refactor type checks for ``axes.helpers.get_client_cache_key``
533 for framework compatibility, fixes #471.
534 [aleksihakli]
535
536
537 5.0.9 (2019-07-11)
538 ------------------
539
540 - Add better handling for attempt and log resets by moving them
541 into handlers which allows customization and more configurability.
542 Unimplemented handlers raise ``NotImplementedError`` by default.
543 [aleksihakli]
544 - Add Python 3.8 dev version and PyPy to the Travis test matrix.
545 [aleksihakli]
546
547
548 5.0.8 (2019-07-09)
549 ------------------
550
551 - Add ``AXES_ONLY_ADMIN_SITE`` flag for only running Axes on admin site.
552 [hramezani]
553 - Add ``axes_reset_logs`` command for removing old AccessLog records.
554 [tlebrize]
555 - Allow ``AxesBackend`` subclasses to pass the ``axes.W003`` system check.
556 [adamchainz]
557
558
559 5.0.7 (2019-06-14)
560 ------------------
561
562 - Fix lockout message showing when lockout is disabled
563 with the ``AXES_LOCK_OUT_AT_FAILURE`` setting.
564 [mogzol]
565
566 - Add support for callable ``AXES_FAILURE_LIMIT`` setting.
567 [bbayles]
568
569
570 5.0.6 (2019-05-25)
571 ------------------
572
573 - Deprecate ``AXES_DISABLE_SUCCESS_ACCESS_LOG`` flag in favour of
574 ``AXES_DISABLE_ACCESS_LOG`` which has mostly the same functionality.
575 Update documentation to better reflect the behaviour of the flag.
576 [aleksihakli]
577
578
579 5.0.5 (2019-05-19)
580 ------------------
581
582 - Change the lockout response calculation to request flagging
583 instead of exception throwing in the signal handler and middleware.
584 Move request attribute calculation from middleware to handler layer.
585 Deprecate ``axes.request.AxesHttpRequest`` object type definition.
586 [aleksihakli]
587
588 - Deprecate the old version 4.x ``axes.backends.AxesModelBackend`` class.
589 [aleksihakli]
590
591 - Improve documentation on attempt tracking, resets, Axes customization,
592 project and component compatibility and integrations, and other things.
593 [aleksihakli]
594
595
596 5.0.4 (2019-05-09)
597 ------------------
598
599 - Fix regression with OAuth2 authentication backends not having remote
600 IP addresses set and throwing an exception in cache key calculation.
601 [aleksihakli]
602
603
604 5.0.3 (2019-05-08)
605 ------------------
606
607 - Fix ``django.contrib.auth`` module ``login`` and ``logout`` functionality
608 so that they work with the handlers without the an ``AxesHttpRequest``
609 to improve cross compatibility with other Django applications.
610 [aleksihakli]
611
612 - Change IP address resolution to allow empty or missing addresses.
613 [aleksihakli]
614
615 - Add error logging for missing request attributes in the handler layer
616 so that users get better indicators of misconfigured applications.
617 [aleksihakli]
618
619
620 5.0.2 (2019-05-07)
621 ------------------
622
623 - Add ``AXES_ENABLED`` setting for disabling Axes with e.g. tests
624 that use Django test client ``login``, ``logout``, and ``force_login``
625 methods, which do not supply the ``request`` argument to views,
626 preventing Axes from functioning correctly in certain test setups.
627 [aleksihakli]
628
629
630 5.0.1 (2019-05-03)
631 ------------------
632
633 - Add changelog to documentation.
634 [aleksihakli]
635
636
637 5.0 (2019-05-01)
638 ----------------
639
640 - Deprecate Python 2.7, 3.4 and 3.5 support.
641 [aleksihakli]
642
643 - Remove automatic decoration and monkey-patching of Django views and forms.
644 Decorators are available for login function and method decoration as before.
645 [aleksihakli]
646
647 - Use backend, middleware, and signal handlers for tracking
648 login attempts and implementing user lockouts.
649 [aleksihakli, jorlugaqui, joshua-s]
650
651 - Add ``AxesDatabaseHandler``, ``AxesCacheHandler``, and ``AxesDummyHandler``
652 handler backends for processing user login and logout events and failures.
653 Handlers are configurable with the ``AXES_HANDLER`` setting.
654 [aleksihakli, jorlugaqui, joshua-s]
655
656 - Improve management commands and separate commands for resetting
657 all access attempts, attempts by IP, and attempts by username.
658 New command names are ``axes_reset``, ``axes_reset_ip`` and ``axes_reset_username``.
659 [aleksihakli]
660
661 - Add support for string import for ``AXES_USERNAME_CALLABLE``
662 that supports dotted paths in addition to the old
663 callable type such as a function or a class method.
664 [aleksihakli]
665
666 - Deprecate one argument call signature for ``AXES_USERNAME_CALLABLE``.
667 From now on, the callable needs to accept two arguments,
668 the HttpRequest and credentials that are supplied to the
669 Django ``authenticate`` method in authentication backends.
670 [aleksihakli]
671
672 - Move ``axes.attempts.is_already_locked`` function to ``axes.handlers.AxesProxyHandler.is_locked``.
673 Various other previously undocumented methods have been deprecated and moved inside the project.
674 The new documented public APIs can be considered as stable and can be safely utilized by other projects.
675 [aleksihakli]
676
677 - Improve documentation layouting and contents. Add public API reference section.
678 [aleksihakli]
679
680
681 4.5.4 (2019-01-15)
682 ------------------
683
684 - Improve README and documentation
685 [aleksihakli]
686
687
688 4.5.3 (2019-01-14)
689 ------------------
690
691 - Remove the unused ``AccessAttempt.trusted`` flag from models
692 [aleksihakli]
693
694 - Improve README and Travis CI setups
695 [aleksihakli]
696
697
698 4.5.2 (2019-01-12)
699 ------------------
700
701 - Added Turkish translations
702 [obayhan]
703
704
705 4.5.1 (2019-01-11)
706 ------------------
707
708 - Removed duplicated check that was causing issues when using APIs.
709 [camilonova]
710
711 - Added Russian translations
712 [lubicz-sielski]
713
714
715 4.5.0 (2018-12-25)
716 ------------------
717
718 - Improve support for custom authentication credentials using the
719 ``AXES_USERNAME_FORM_FIELD`` and ``AXES_USERNAME_CALLABLE`` settings.
720 [mastacheata]
721
722 - Updated behaviour for fetching username from request or credentials:
723 If no ``AXES_USERNAME_CALLABLE`` is configured, the optional
724 ``credentials`` that are supplied to the axes utility methods
725 are now the default source for client username and the HTTP
726 request POST is the fallback for fetching the user information.
727 ``AXES_USERNAME_CALLABLE`` implements an alternative signature with two
728 arguments ``request, credentials`` in addition to the old ``request``
729 call argument signature in a backwards compatible fashion.
730 [aleksihakli]
731
732 - Add official support for the Django 2.1 version and Python 3.7.
733 [aleksihakli]
734
735 - Improve the requirements, documentation, tests, and CI setup.
736 [aleksihakli]
737
738
739 4.4.3 (2018-12-08)
740 ------------------
741
742 - Fix MANIFEST.in missing German translations
743 [aleksihakli]
744
745 - Add `AXES_RESET_ON_SUCCESS` configuration flag
746 [arjenzijlstra]
747
748
749 4.4.2 (2018-10-30)
750 ------------------
751
752 - fix missing migration and add check to prevent it happening again.
753 [markddavidoff]
754
755
756 4.4.1 (2018-10-24)
757 ------------------
758
759 - Add a German translation
760 [adonig]
761
762 - Documentation wording changes
763 [markddavidoff]
764
765 - Use `get_client_username` in `log_user_login_failed` instead of credentials
766 [markddavidoff]
767
768 - pin prospector to 0.12.11, and pin astroid to 1.6.5
769 [hsiaoyi0504]
770
771
772 4.4.0 (2018-05-26)
773 ------------------
774
775 - Added AXES_USERNAME_CALLABLE
776 [jaadus]
777
778
779 4.3.1 (2018-04-21)
780 ------------------
781
782 - Change custom authentication backend failures from error to warning log level
783 [aleksihakli]
784
785 - Set up strict code linting for CI pipeline that fails builds if linting does not pass
786 [aleksihakli]
787
788 - Clean up old code base and tests based on linter errors
789 [aleksihakli]
790
791
792 4.3.0 (2018-04-21)
793 ------------------
794
795 - Refactor and clean up code layout
796 [aleksihakli]
797
798 - Add prospector linting and code checks to toolchain
799 [aleksihakli]
800
801 - Clean up log message formatting and refactor type checks
802 [EvaSDK]
803
804 - Fix faulty user locking with user agent when AXES_ONLY_USER_FAILURES is set
805 [EvaSDK]
806
807
808 4.2.1 (2018-04-18)
809 ------------------
810
811 - Fix unicode string interpolation on Python 2.7
812 [aleksihakli]
813
814
815 4.2.0 (2018-04-13)
816 ------------------
817
818 - Add configuration flags for client IP resolving
819 [aleksihakli]
820
821 - Add AxesModelBackend authentication backend
822 [markdaviddoff]
823
824
825 4.1.0 (2018-02-18)
826 ------------------
827
828 - Add AXES_CACHE setting for configuring `axes` specific caching.
829 [JWvDronkelaar]
830
831 - Add checks and tests for faulty LocMemCache usage in application setup.
832 [aleksihakli]
833
834
835 4.0.2 (2018-01-19)
836 ------------------
837
838 - Improve Windows compatibility on Python < 3.4 by utilizing win_inet_pton
839 [hsiaoyi0504]
840
841 - Add documentation on django-allauth integration
842 [grucha]
843
844 - Add documentation on known AccessAttempt caching configuration problems
845 when using axes with the `django.core.cache.backends.locmem.LocMemCache`
846 [aleksihakli]
847
848 - Refactor and improve existing AccessAttempt cache reset utility
849 [aleksihakli]
850
851
852 4.0.1 (2017-12-19)
853 ------------------
854
855 - Fixes issue when not using `AXES_USERNAME_FORM_FIELD`
856 [camilonova]
857
858
859 4.0.0 (2017-12-18)
860 ------------------
861
862 - *BREAKING CHANGES*. `AXES_BEHIND_REVERSE_PROXY` `AXES_REVERSE_PROXY_HEADER`
863 `AXES_NUM_PROXIES` were removed in order to use `django-ipware` to get
864 the user ip address
865 [camilonova]
866
867 - Added support for custom username field
868 [kakulukia]
869
870 - Customizing Axes doc updated
871 [pckapps]
872
873 - Remove filtering by username
874 [camilonova]
875
876 - Fixed logging failed attempts to authenticate using a custom authentication
877 backend.
878 [D3X]
879
880
881 3.0.3 (2017-11-23)
882 ------------------
883
884 - Test against Python 2.7.
885 [mbaechtold]
886
887 - Test against Python 3.4.
888 [pope1ni]
889
890
891 3.0.2 (2017-11-21)
892 ------------------
893
894 - Added form_invalid decorator. Fixes #265
895 [camilonova]
896
897
898 3.0.1 (2017-11-17)
899 ------------------
900
901 - Fix DeprecationWarning for logger warning
902 [richardowen]
903
904 - Fixes global lockout possibility
905 [joeribekker]
906
907 - Changed the way output is handled in the management commands
908 [ataylor32]
909
910
911 3.0.0 (2017-11-17)
912 ------------------
913
914 - BREAKING CHANGES. Support for Django >= 1.11 and signals, see issue #215.
915 Drop support for Python < 3.6
916 [camilonova]
917
918
919 2.3.3 (2017-07-20)
920 ------------------
921
922 - Many tweaks and handles successful AJAX logins.
923 [Jack Sullivan]
924
925 - Add tests for proxy number parametrization
926 [aleksihakli]
927
928 - Add AXES_NUM_PROXIES setting
929 [aleksihakli]
930
931 - Log failed access attempts regardless of settings
932 [jimr]
933
934 - Updated configuration docs to include AXES_IP_WHITELIST
935 [Minkey27]
936
937 - Add test for get_cache_key function
938 [jorlugaqui]
939
940 - Delete cache key in reset command line
941 [jorlugaqui]
942
943 - Add signals for setting/deleting cache keys
944 [jorlugaqui]
945
946
947 2.3.2 (2016-11-24)
948 ------------------
949
950 - Only look for lockable users on a POST
951 [schinckel]
952
953 - Fix and add tests for IPv4 and IPv6 parsing
954 [aleksihakli]
955
956
957 2.3.1 (2016-11-12)
958 ------------------
959
960 - Added settings for disabling success accesslogs
961 [Minkey27]
962
963 - Fixed illegal IP address string passed to inet_pton
964 [samkuehn]
965
966
967 2.3.0 (2016-11-04)
968 ------------------
969
970 - Fixed ``axes_reset`` management command to skip "ip" prefix to command
971 arguments.
972 [EvaMarques]
973
974 - Added ``axes_reset_user`` management command to reset lockouts and failed
975 login records for given users.
976 [vladimirnani]
977
978 - Fixed Travis-PyPI release configuration.
979 [jezdez]
980
981 - Make IP position argument optional.
982 [aredalen]
983
984 - Added possibility to disable access log
985 [svenhertle]
986
987 - Fix for IIS used as reverse proxy adding port number
988 [Dmitri-Sintsov]
989
990 - Made the signal race condition safe.
991 [Minkey27]
992
993 - Added AXES_ONLY_USER_FAILURES to support only looking at the user ID.
994 [lip77us]
995
996
997 2.2.0 (2016-07-20)
998 ------------------
999
1000 - Improve the logic when using a reverse proxy to avoid possible attacks.
1001 [camilonova]
1002
1003
1004 2.1.0 (2016-07-14)
1005 ------------------
1006
1007 - Add `default_app_config` so you can just use `axes` in `INSTALLED_APPS`
1008 [vdboor]
1009
1010
1011 2.0.0 (2016-06-24)
1012 ------------------
1013
1014 - Removed middleware to use app_config
1015 [camilonova]
1016
1017 - Lots of cleaning
1018 [camilonova]
1019
1020 - Improved test suite and versions
1021 [camilonova]
1022
1023
1024 1.7.0 (2016-06-10)
1025 ------------------
1026
1027 - Use render shortcut for rendering LOCKOUT_TEMPLATE
1028 [Radoslaw Luter]
1029
1030 - Added app_label for RemovedInDjango19Warning
1031 [yograterol]
1032
1033 - Add iso8601 translator.
1034 [mullakhmetov]
1035
1036 - Edit json response. Context now contains ISO 8601 formatted cooloff time
1037 [mullakhmetov]
1038
1039 - Add json response and iso8601 tests.
1040 [mullakhmetov]
1041
1042 - Fixes issue 162: UnicodeDecodeError on pip install
1043 [joeribekker]
1044
1045 - Added AXES_NEVER_LOCKOUT_WHITELIST option to prevent certain IPs from being locked out.
1046 [joeribekker]
1047
1048
1049 1.6.1 (2016-05-13)
1050 ------------------
1051
1052 - Fixes whitelist check when BEHIND_REVERSE_PROXY
1053 [Patrick Hagemeister]
1054
1055 - Made migrations py3 compatible
1056 [mvdwaeter]
1057
1058 - Fixing #126, possibly breaking compatibility with Django<=1.7
1059 [int-ua]
1060
1061 - Add note for upgrading users about new migration files
1062 [kelseyq]
1063
1064 - Fixes #148
1065 [camilonova]
1066
1067 - Decorate auth_views.login only once
1068 [teeberg]
1069
1070 - Set IP public/private classifier to be compliant with RFC 1918.
1071 [SilasX]
1072
1073 - Issue #155. Lockout response status code changed to 403.
1074 [Arthur Mullahmetov]
1075
1076 - BUGFIX: Missing migration
1077 [smeinel]
1078
1079
1080 1.6.0 (2016-01-07)
1081 ------------------
1082
1083 - Stopped using render_to_response so that other template engines work
1084 [tarkatronic]
1085
1086 - Improved performance & DoS prevention on query2str
1087 [tarkatronic]
1088
1089 - Immediately return from is_already_locked if the user is not lockable
1090 [jdunck]
1091
1092 - Iterate over ip addresses only once
1093 [annp89]
1094
1095 - added initial migration files to support django 1.7 &up. Upgrading users should run migrate --fake-initial after update
1096 [ibaguio]
1097
1098 - Add db indexes to CommonAccess model
1099 [Schweigi]
1100
1101
1102 1.5.0 (2015-09-11)
1103 ------------------
1104
1105 - Fix #_get_user_attempts to include username when filtering AccessAttempts if AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True
1106 [afioca]
1107
1108
1109 1.4.0 (2015-08-09)
1110 ------------------
1111
1112 - Send the user_locked_out signal. Fixes #94.
1113 [toabi]
1114
1115
1116 1.3.9 (2015-02-11)
1117 ------------------
1118
1119 - Python 3 fix (#104)
1120
1121
1122 1.3.8 (2014-10-07)
1123 ------------------
1124
1125 - Rename GitHub organization from django-security to django-pci to emphasize focus on providing assistance with building PCI compliant websites with Django.
1126 [aclark4life]
1127
1128
1129 1.3.7 (2014-10-05)
1130 ------------------
1131
1132 - Explain common issues where Axes fails silently
1133 [cericoda]
1134
1135 - Allow for user-defined username field for lookup in POST data
1136 [SteveByerly]
1137
1138 - Log out only if user was logged in
1139 [zoten]
1140
1141 - Support for floats in cooloff time (i.e: 0.1 == 6 minutes)
1142 [marianov]
1143
1144 - Limit amount of POST data logged (#73). Limiting the length of value is not enough, as there could be arbitrary number of them, or very long key names.
1145 [peterkuma]
1146
1147 - Improve get_ip to try for real ip address
1148 [7wonders]
1149
1150 - Change IPAddressField to GenericIPAddressField. When using a PostgreSQL database and the client does not pass an IP address you get an inet error. This is a known problem with PostgreSQL and the IPAddressField. https://code.djangoproject.com/ticket/5622. It can be fixed by using a GenericIPAddressField instead.
1151 [polvoblanco]
1152
1153 - Get first X-Forwarded-For IP
1154 [tutumcloud]
1155
1156 - White listing IP addresses behind reverse proxy. Allowing some IP addresses to have direct access to the app even if they are behind a reverse proxy. Those IP addresses must still be on a white list.
1157 [ericbulloch]
1158
1159 - Reduce logging of reverse proxy IP lookup and use configured logger. Fixes #76. Instead of logging the notice that django.axes looks for a HTTP header set by a reverse proxy on each attempt, just log it one-time on first module import. Also use the configured logger (by default axes.watch_login) for the message to be more consistent in logging.
1160 [eht16]
1161
1162 - Limit the length of the values logged into the database. Refs #73
1163 [camilonova]
1164
1165 - Refactored tests to be more stable and faster
1166 [camilonova]
1167
1168 - Clean client references
1169 [camilonova]
1170
1171 - Fixed admin login url
1172 [camilonova]
1173
1174 - Added django 1.7 for testing
1175 [camilonova]
1176
1177 - Travis file cleanup
1178 [camilonova]
1179
1180 - Remove hardcoded url path
1181 [camilonova]
1182
1183 - Fixing tests for django 1.7
1184 [Andrew-Crosio]
1185
1186 - Fix for django 1.7 exception not existing
1187 [Andrew-Crosio]
1188
1189 - Removed python 2.6 from testing
1190 [camilonova]
1191
1192 - Use django built-in six version
1193 [camilonova]
1194
1195 - Added six as requirement
1196 [camilonova]
1197
1198 - Added python 2.6 for travis testing
1199 [camilonova]
1200
1201 - Replaced u string literal prefixes with six.u() calls
1202 [amrhassan]
1203
1204 - Fixes object type issue, response is not an string
1205 [camilonova]
1206
1207 - Python 3 compatibility fix for db_reset
1208 [nicois]
1209
1210 - Added example project and helper scripts
1211 [barseghyanartur]
1212
1213 - Admin command to list login attemps
1214 [marianov]
1215
1216 - Replaced six imports with django.utils.six ones
1217 [amrhassan]
1218
1219 - Replaced u string literal prefixes with six.u() calls to make it compatible with Python 3.2
1220 [amrhassan]
1221
1222 - Replaced `assertIn`s and `assertNotIn`s with `assertContains` and `assertNotContains`
1223 [fcurella]
1224
1225 - Added py3k to travis
1226 [fcurella]
1227
1228 - Update test cases to be python3 compatible
1229 [nicois]
1230
1231 - Python 3 compatibility fix for db_reset
1232 [nicois]
1233
1234 - Removed trash from example urls
1235 [barseghyanartur]
1236
1237 - Added django installer
1238 [barseghyanartur]
1239
1240 - Added example project and helper scripts
1241 [barseghyanartur]
1242
1243
1244 1.3.6 (2013-11-23)
1245 ------------------
1246
1247 - Added AttributeError in case get_profile doesn't exist
1248 [camilonova]
1249
1250 - Improved axes_reset command
1251 [camilonova]
1252
1253
1254 1.3.5 (2013-11-01)
1255 ------------------
1256
1257 - Fix an issue with __version__ loading the wrong version
1258 [graingert]
1259
1260
1261 1.3.4 (2013-11-01)
1262 ------------------
1263
1264 - Update README.rst for PyPI
1265 [marty, camilonova, graingert]
1266
1267 - Add cooloff period
1268 [visualspace]
1269
1270
1271 1.3.3 (2013-07-05)
1272 ------------------
1273
1274 - Added 'username' field to the Admin table
1275 [bkvirendra]
1276
1277 - Removed fallback logging creation since logging cames by default on django 1.4 or later,
1278 if you don't have it is because you explicitly wanted. Fixes #45
1279 [camilonova]
1280
1281
1282 1.3.2 (2013-03-28)
1283 ------------------
1284
1285 - Fix an issue when a user logout
1286 [camilonova]
1287
1288 - Match pypi version
1289 [camilonova]
1290
1291 - Better User model import method
1292 [camilonova]
1293
1294 - Use only one place to get the version number
1295 [camilonova]
1296
1297 - Fixed an issue when a user on django 1.4 logout
1298 [camilonova]
1299
1300 - Handle exception if there is not user profile model set
1301 [camilonova]
1302
1303 - Made some cleanup and remove a pokemon exception handling
1304 [camilonova]
1305
1306 - Improved tests so it really looks for the rabbit in the hole
1307 [camilonova]
1308
1309 - Match pypi version
1310 [camilonova]
1311
1312
1313 1.3.1 (2013-03-19)
1314 ------------------
1315
1316 - Add support for Django 1.5
1317 [camilonova]
1318
1319
1320 1.3.0 (2013-02-27)
1321 ------------------
1322
1323 - Bug fix: get_version() format string
1324 [csghormley]
1325
1326
1327 1.2.9 (2013-02-20)
1328 ------------------
1329
1330 - Add to and improve test cases
1331 [camilonova]
1332
1333
1334 1.2.8 (2013-01-23)
1335 ------------------
1336
1337 - Increased http accept header length
1338 [jslatts]
1339
1340
1341 1.2.7 (2013-01-17)
1342 ------------------
1343
1344 - Reverse proxy support
1345 [rmagee]
1346
1347 - Clean up README
1348 [martey]
1349
1350
1351 1.2.6 (2012-12-04)
1352 ------------------
1353
1354 - Remove unused import
1355 [aclark4life]
1356
1357
1358 1.2.5 (2012-11-28)
1359 ------------------
1360
1361 - Fix setup.py
1362 [aclark4life]
1363
1364 - Added ability to flag user accounts as unlockable.
1365 [kencochrane]
1366
1367 - Added ipaddress as a param to the user_locked_out signal.
1368 [kencochrane]
1369
1370 - Added a signal receiver for user_logged_out.
1371 [kencochrane]
1372
1373 - Added a signal for when a user gets locked out.
1374 [kencochrane]
1375
1376 - Added AccessLog model to log all access attempts.
1377 [kencochrane]
1378
1379 Keywords: authentication django pci security
1380 Platform: UNKNOWN
1381 Classifier: Development Status :: 5 - Production/Stable
1382 Classifier: Environment :: Web Environment
1383 Classifier: Environment :: Plugins
1384 Classifier: Framework :: Django
1385 Classifier: Framework :: Django :: 2.2
1386 Classifier: Framework :: Django :: 3.1
1387 Classifier: Framework :: Django :: 3.2
1388 Classifier: Intended Audience :: Developers
1389 Classifier: Intended Audience :: System Administrators
1390 Classifier: License :: OSI Approved :: MIT License
1391 Classifier: Operating System :: OS Independent
1392 Classifier: Programming Language :: Python
1393 Classifier: Programming Language :: Python :: 3
1394 Classifier: Programming Language :: Python :: 3.6
1395 Classifier: Programming Language :: Python :: 3.7
1396 Classifier: Programming Language :: Python :: 3.8
1397 Classifier: Programming Language :: Python :: 3.9
1398 Classifier: Programming Language :: Python :: Implementation :: CPython
1399 Classifier: Programming Language :: Python :: Implementation :: PyPy
1400 Classifier: Topic :: Internet :: Log Analysis
1401 Classifier: Topic :: Security
1402 Classifier: Topic :: System :: Logging
1403 Requires-Python: ~=3.6
1313 :target: https://pypi.org/project/django-axes/
1414 :alt: PyPI release
1515
16 .. image:: https://img.shields.io/pypi/pyversions/django-axes.svg
17 :target: https://pypi.org/project/django-axes/
18 :alt: Supported Python versions
19
20 .. image:: https://img.shields.io/pypi/djversions/django-axes.svg
21 :target: https://pypi.org/project/django-axes/
22 :alt: Supported Django versions
23
1624 .. image:: https://img.shields.io/readthedocs/django-axes.svg
1725 :target: https://django-axes.readthedocs.io/
1826 :alt: Documentation
1927
20 .. image:: https://secure.travis-ci.org/jazzband/django-axes.svg?branch=master
21 :target: http://travis-ci.org/jazzband/django-axes
22 :alt: Build Status
28 .. image:: https://github.com/jazzband/django-axes/workflows/Test/badge.svg
29 :target: https://github.com/jazzband/django-axes/actions
30 :alt: GitHub Actions
2331
2432 .. image:: https://codecov.io/gh/jazzband/django-axes/branch/master/graph/badge.svg
2533 :target: https://codecov.io/gh/jazzband/django-axes
2634 :alt: Coverage
2735
2836
29 Axes is a very simple way for you to keep track of failed
30 authentication attempts for your login views.
37 Axes is a Django plugin for keeping track of suspicious
38 login attempts for your Django based website
39 and implementing simple brute-force attack blocking.
3140
3241 The name is sort of a geeky pun, since it can be interpreted as:
3342
3443 * ``access``, as in monitoring access attempts, or
3544 * ``axes``, as in tools you can use to hack (generally on wood).
3645
37 In this case, however, the hacking part of it can be taken a bit further:
38 **Axes is intended to help you stop people from brute forcing your Django site**.
39
4046
4147 Functionality
4248 -------------
4349
4450 Axes records login attempts to your Django powered site and prevents attackers
45 from brute forcing the site when they exceed the configured attempt limit.
51 from attempting further logins to your site when they exceed the configured attempt limit.
4652
4753 Axes can track the attempts and persist them in the database indefinitely,
4854 or alternatively use a fast and DDoS resistant cache implementation.
7379 Contributions
7480 -------------
7581
76 This is a `Jazzband <https://jazzband.co>`_ project.
82 All contributions are welcome!
83
84 It is best to separate proposed changes and PRs into small, distinct patches
85 by type so that they can be merged faster into upstream and released quicker.
86
87 One way to organize contributions would be to separate PRs for e.g.
88
89 * bugfixes,
90 * new features,
91 * code and design improvements,
92 * documentation improvements, or
93 * tooling and CI improvements.
94
95 Merging contributions requires passing the checks configured
96 with the CI. This includes running tests and linters successfully
97 on the currently officially supported Python and Django versions.
98
99 The test automation is run automatically with GitHub Actions, but you can
100 run it locally with the ``tox`` command before pushing commits.
101
102 Please note that this is a `Jazzband <https://jazzband.co>`_ project.
77103 By contributing you agree to abide by the
78104 `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_
79105 and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
80
81 It is best to separate proposed changes and PRs into small, distinct patches
82 by type so that they can be merged faster into upstream and released quicker:
83
84 * features,
85 * bugfixes,
86 * code style improvements, and
87 * documentation improvements.
88
89 All contributions are required to pass the quality gates configured
90 with the CI. This includes running tests and linters successfully
91 on the currently officially supported Python and Django versions.
92
93 The test automation is run automatically by Travis CI, but you can
94 run it locally with the ``tox`` command before pushing commits.
00 from pkg_resources import get_distribution
11
2 default_app_config = "axes.apps.AppConfig"
2 import django
3
4 if django.VERSION < (3, 2):
5 default_app_config = "axes.apps.AppConfig"
36
47 __version__ = get_distribution("django-axes").version
0 from django.conf import settings
10 from django.contrib import admin
21 from django.utils.translation import gettext_lazy as _
32
3 from axes.conf import settings
44 from axes.models import AccessAttempt, AccessLog
55
66
00 from logging import getLogger
1
2 from django import apps
13 from pkg_resources import get_distribution
24
3 from django import apps
4
5 from axes.conf import settings
6
7 log = getLogger(settings.AXES_LOGGER)
5 log = getLogger(__name__)
86
97
108 class AppConfig(apps.AppConfig):
9 default_auto_field = "django.db.models.AutoField"
1110 name = "axes"
12 logging_initialized = False
11 initialized = False
1312
1413 @classmethod
1514 def initialize(cls):
2019 It displays version information exactly once at application startup.
2120 """
2221
23 if not settings.AXES_ENABLED:
22 if cls.initialized:
2423 return
24 cls.initialized = True
2525
26 if not settings.AXES_VERBOSE:
27 return
26 # Only import settings, checks, and signals one time after Django has been initialized
27 from axes.conf import settings # noqa
28 from axes import checks, signals # noqa
2829
29 if cls.logging_initialized:
30 return
31 cls.logging_initialized = True
30 # Skip startup log messages if Axes is not set to verbose
31 if settings.AXES_VERBOSE:
32 log.info("AXES: BEGIN LOG")
33 log.info(
34 "AXES: Using django-axes version %s",
35 get_distribution("django-axes").version,
36 )
3237
33 log.info("AXES: BEGIN LOG")
34 log.info(
35 "AXES: Using django-axes version %s",
36 get_distribution("django-axes").version,
37 )
38
39 if settings.AXES_ONLY_USER_FAILURES:
40 log.info("AXES: blocking by username only.")
41 elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
42 log.info("AXES: blocking by combination of username and IP.")
43 else:
44 log.info("AXES: blocking by IP only.")
38 if settings.AXES_ONLY_USER_FAILURES:
39 log.info("AXES: blocking by username only.")
40 elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
41 log.info("AXES: blocking by combination of username and IP.")
42 elif settings.AXES_LOCK_OUT_BY_USER_OR_IP:
43 log.info("AXES: blocking by username or IP.")
44 else:
45 log.info("AXES: blocking by IP only.")
4546
4647 def ready(self):
4748 self.initialize()
48
49 from axes import checks, signals # noqa
00 from logging import getLogger
1 from typing import List
12
23 from django.db.models import QuerySet
34 from django.utils.timezone import datetime, now
45
56 from axes.conf import settings
7 from axes.helpers import get_client_username, get_client_parameters, get_cool_off
68 from axes.models import AccessAttempt
7 from axes.helpers import get_client_username, get_client_parameters, get_cool_off
89
9 log = getLogger(settings.AXES_LOGGER)
10 log = getLogger(__name__)
1011
1112
1213 def get_cool_off_threshold(attempt_time: datetime = None) -> datetime:
2526 return attempt_time - cool_off
2627
2728
28 def filter_user_attempts(request, credentials: dict = None) -> QuerySet:
29 def filter_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
2930 """
30 Return a queryset of AccessAttempts that match the given request and credentials.
31 Return a list querysets of AccessAttempts that match the given request and credentials.
3132 """
3233
3334 username = get_client_username(request, credentials)
3435
35 filter_kwargs = get_client_parameters(
36 filter_kwargs_list = get_client_parameters(
3637 username, request.axes_ip_address, request.axes_user_agent
3738 )
38
39 return AccessAttempt.objects.filter(**filter_kwargs)
39 attempts_list = [
40 AccessAttempt.objects.filter(**filter_kwargs)
41 for filter_kwargs in filter_kwargs_list
42 ]
43 return attempts_list
4044
4145
42 def get_user_attempts(request, credentials: dict = None) -> QuerySet:
46 def get_user_attempts(request, credentials: dict = None) -> List[QuerySet]:
4347 """
44 Get valid user attempts that match the given request and credentials.
48 Get list of querysets with valid user attempts that match the given request and credentials.
4549 """
4650
47 attempts = filter_user_attempts(request, credentials)
51 attempts_list = filter_user_attempts(request, credentials)
4852
4953 if settings.AXES_COOLOFF_TIME is None:
5054 log.debug(
5155 "AXES: Getting all access attempts from database because no AXES_COOLOFF_TIME is configured"
5256 )
53 return attempts
57 return attempts_list
5458
5559 threshold = get_cool_off_threshold(request.axes_attempt_time)
5660 log.debug("AXES: Getting access attempts that are newer than %s", threshold)
57 return attempts.filter(attempt_time__gte=threshold)
61 return [attempts.filter(attempt_time__gte=threshold) for attempts in attempts_list]
5862
5963
6064 def clean_expired_user_attempts(attempt_time: datetime = None) -> int:
8387 Reset all user attempts that match the given request and credentials.
8488 """
8589
86 attempts = filter_user_attempts(request, credentials)
90 attempts_list = filter_user_attempts(request, credentials)
8791
88 count, _ = attempts.delete()
92 count = 0
93 for attempts in attempts_list:
94 _count, _ = attempts.delete()
95 count += _count
8996 log.info("AXES: Reset %s access attempts from database.", count)
9097
9198 return count
120120 def axes_deprecation_check(app_configs, **kwargs): # pylint: disable=unused-argument
121121 warnings = []
122122
123 deprecated_settings = ["AXES_DISABLE_SUCCESS_ACCESS_LOG"]
123 deprecated_settings = [
124 "AXES_DISABLE_SUCCESS_ACCESS_LOG",
125 "AXES_LOGGER",
126 ]
124127
125128 for deprecated_setting in deprecated_settings:
126129 try:
00 from django.conf import settings
11 from django.utils.translation import gettext_lazy as _
22
3 from appconf import AppConf
43
4 # disable plugin when set to False
5 settings.AXES_ENABLED = getattr(settings, "AXES_ENABLED", True)
56
6 class AxesAppConf(AppConf):
7 class Meta:
8 prefix = "axes"
7 # see if the user has overridden the failure limit
8 settings.AXES_FAILURE_LIMIT = getattr(settings, "AXES_FAILURE_LIMIT", 3)
99
10 # disable plugin when set to False
11 ENABLED = True
10 # see if the user has set axes to lock out logins after failure limit
11 settings.AXES_LOCK_OUT_AT_FAILURE = getattr(settings, "AXES_LOCK_OUT_AT_FAILURE", True)
1212
13 # see if the user has overridden the failure limit
14 FAILURE_LIMIT = 3
13 # lock out with the combination of username and IP address
14 settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP = getattr(
15 settings, "AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP", False
16 )
1517
16 # see if the user has set axes to lock out logins after failure limit
17 LOCK_OUT_AT_FAILURE = True
18 # lock out with the username or IP address
19 settings.AXES_LOCK_OUT_BY_USER_OR_IP = getattr(
20 settings, "AXES_LOCK_OUT_BY_USER_OR_IP", False
21 )
1822
19 # lock out with the combination of username and IP address
20 LOCK_OUT_BY_COMBINATION_USER_AND_IP = False
23 # lock out with username and never the IP or user agent
24 settings.AXES_ONLY_USER_FAILURES = getattr(settings, "AXES_ONLY_USER_FAILURES", False)
2125
22 # lock out with username and never the IP or user agent
23 ONLY_USER_FAILURES = False
26 # lock out just for admin site
27 settings.AXES_ONLY_ADMIN_SITE = getattr(settings, "AXES_ONLY_ADMIN_SITE", False)
2428
25 # lock out just for admin site
26 ONLY_ADMIN_SITE = False
29 # show Axes logs in admin
30 settings.AXES_ENABLE_ADMIN = getattr(settings, "AXES_ENABLE_ADMIN", True)
2731
28 # show Axes logs in admin
29 ENABLE_ADMIN = True
32 # lock out with the user agent, has no effect when ONLY_USER_FAILURES is set
33 settings.AXES_USE_USER_AGENT = getattr(settings, "AXES_USE_USER_AGENT", False)
3034
31 # lock out with the user agent, has no effect when ONLY_USER_FAILURES is set
32 USE_USER_AGENT = False
35 # use a specific username field to retrieve from login POST data
36 settings.AXES_USERNAME_FORM_FIELD = getattr(
37 settings, "AXES_USERNAME_FORM_FIELD", "username"
38 )
3339
34 # use a specific username field to retrieve from login POST data
35 USERNAME_FORM_FIELD = "username"
40 # use a specific password field to retrieve from login POST data
41 settings.AXES_PASSWORD_FORM_FIELD = getattr(
42 settings, "AXES_PASSWORD_FORM_FIELD", "password"
43 ) # noqa
3644
37 # use a specific password field to retrieve from login POST data
38 PASSWORD_FORM_FIELD = "password" # noqa
45 # use a provided callable to transform the POSTed username into the one used in credentials
46 settings.AXES_USERNAME_CALLABLE = getattr(settings, "AXES_USERNAME_CALLABLE", None)
3947
40 # use a provided callable to transform the POSTed username into the one used in credentials
41 USERNAME_CALLABLE = None
48 # determine if given user should be always allowed to attempt authentication
49 settings.AXES_WHITELIST_CALLABLE = getattr(settings, "AXES_WHITELIST_CALLABLE", None)
4250
43 # determine if given user should be always allowed to attempt authentication
44 WHITELIST_CALLABLE = None
51 # return custom lockout response if configured
52 settings.AXES_LOCKOUT_CALLABLE = getattr(settings, "AXES_LOCKOUT_CALLABLE", None)
4553
46 # return custom lockout response if configured
47 LOCKOUT_CALLABLE = None
54 # reset the number of failed attempts after one successful attempt
55 settings.AXES_RESET_ON_SUCCESS = getattr(settings, "AXES_RESET_ON_SUCCESS", False)
4856
49 # reset the number of failed attempts after one successful attempt
50 RESET_ON_SUCCESS = False
57 settings.AXES_DISABLE_ACCESS_LOG = getattr(settings, "AXES_DISABLE_ACCESS_LOG", False)
5158
52 DISABLE_ACCESS_LOG = False
59 settings.AXES_HANDLER = getattr(
60 settings, "AXES_HANDLER", "axes.handlers.database.AxesDatabaseHandler"
61 )
5362
54 HANDLER = "axes.handlers.database.AxesDatabaseHandler"
63 settings.AXES_LOCKOUT_TEMPLATE = getattr(settings, "AXES_LOCKOUT_TEMPLATE", None)
5564
56 LOGGER = "axes.watch_login"
65 settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None)
5766
58 LOCKOUT_TEMPLATE = None
67 settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None)
5968
60 LOCKOUT_URL = None
69 settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED)
6170
62 COOLOFF_TIME = None
71 # whitelist and blacklist
72 settings.AXES_NEVER_LOCKOUT_WHITELIST = getattr(
73 settings, "AXES_NEVER_LOCKOUT_WHITELIST", False
74 )
6375
64 VERBOSE = True
76 settings.AXES_NEVER_LOCKOUT_GET = getattr(settings, "AXES_NEVER_LOCKOUT_GET", False)
6577
66 # whitelist and blacklist
67 NEVER_LOCKOUT_WHITELIST = False
78 settings.AXES_ONLY_WHITELIST = getattr(settings, "AXES_ONLY_WHITELIST", False)
6879
69 NEVER_LOCKOUT_GET = False
80 settings.AXES_IP_WHITELIST = getattr(settings, "AXES_IP_WHITELIST", None)
7081
71 ONLY_WHITELIST = False
82 settings.AXES_IP_BLACKLIST = getattr(settings, "AXES_IP_BLACKLIST", None)
7283
73 IP_WHITELIST = None
84 # message to show when locked out and have cooloff enabled
85 settings.AXES_COOLOFF_MESSAGE = getattr(
86 settings,
87 "AXES_COOLOFF_MESSAGE",
88 _("Account locked: too many login attempts. Please try again later."),
89 )
7490
75 IP_BLACKLIST = None
91 # message to show when locked out and have cooloff disabled
92 settings.AXES_PERMALOCK_MESSAGE = getattr(
93 settings,
94 "AXES_PERMALOCK_MESSAGE",
95 _(
96 "Account locked: too many login attempts. Contact an admin to unlock your account."
97 ),
98 )
7699
77 # message to show when locked out and have cooloff enabled
78 COOLOFF_MESSAGE = _(
79 "Account locked: too many login attempts. Please try again later"
80 )
100 # if your deployment is using reverse proxies, set this value to 'left-most' or 'right-most' per your configuration
101 settings.AXES_PROXY_ORDER = getattr(settings, "AXES_PROXY_ORDER", "left-most")
81102
82 # message to show when locked out and have cooloff disabled
83 PERMALOCK_MESSAGE = _(
84 "Account locked: too many login attempts. Contact an admin to unlock your account."
85 )
103 # if your deployment is using reverse proxies, set this value to the number of proxies in front of Django
104 settings.AXES_PROXY_COUNT = getattr(settings, "AXES_PROXY_COUNT", None)
86105
87 # if your deployment is using reverse proxies, set this value to 'left-most' or 'right-most' per your configuration
88 PROXY_ORDER = "left-most"
106 # if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed
107 settings.AXES_PROXY_TRUSTED_IPS = getattr(settings, "AXES_PROXY_TRUSTED_IPS", None)
89108
90 # if your deployment is using reverse proxies, set this value to the number of proxies in front of Django
91 PROXY_COUNT = None
109 # set to the names of request.META attributes that should be checked for the IP address of the client
110 # if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy
111 # ensure that the client can not spoof the headers by setting them and sending them through the proxy
112 settings.AXES_META_PRECEDENCE_ORDER = getattr(
113 settings,
114 "AXES_META_PRECEDENCE_ORDER",
115 getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)),
116 )
92117
93 # if your deployment is using reverse proxies, set to your trusted proxy IP addresses prefixes if needed
94 PROXY_TRUSTED_IPS = None
118 # set CORS allowed origins when calling authentication over ajax
119 settings.AXES_ALLOWED_CORS_ORIGINS = getattr(settings, "AXES_ALLOWED_CORS_ORIGINS", "*")
95120
96 # set to the names of request.META attributes that should be checked for the IP address of the client
97 # if your deployment is using reverse proxies, ensure that the header attributes are securely set by the proxy
98 # ensure that the client can not spoof the headers by setting them and sending them through the proxy
99 META_PRECEDENCE_ORDER = getattr(
100 settings,
101 "AXES_META_PRECEDENCE_ORDER",
102 getattr(settings, "IPWARE_META_PRECEDENCE_ORDER", ("REMOTE_ADDR",)),
103 )
121 # set the list of sensitive parameters to cleanse from get/post data before logging
122 settings.AXES_SENSITIVE_PARAMETERS = getattr(
123 settings,
124 "AXES_SENSITIVE_PARAMETERS",
125 [],
126 )
104127
105 # set to `True` if using with Django REST Framework
106 REST_FRAMEWORK_ACTIVE = False
128 # set the callable for the readable string that can be used in
129 # e.g. logging to distinguish client requests
130 settings.AXES_CLIENT_STR_CALLABLE = getattr(settings, "AXES_CLIENT_STR_CALLABLE", None)
44
55
66 def axes_dispatch(func):
7 @wraps(func)
78 def inner(request, *args, **kwargs):
89 if AxesProxyHandler.is_allowed(request):
910 return func(request, *args, **kwargs)
0 import re
01 from abc import ABC, abstractmethod
1 import re
22
33 from django.urls import reverse
44 from django.urls.exceptions import NoReverseMatch
145145
146146 return False
147147
148 def reset_attempts(self, *, ip_address: str = None, username: str = None) -> int:
148 def reset_attempts(
149 self,
150 *,
151 ip_address: str = None,
152 username: str = None,
153 ip_or_username: bool = False,
154 ) -> int:
149155 """
150156 Resets access attempts that match the given IP address or username.
151157
11
22 from axes.conf import settings
33 from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
4 from axes.signals import user_locked_out
54 from axes.helpers import (
65 get_cache,
76 get_cache_timeout,
1110 get_credentials,
1211 get_failure_limit,
1312 )
13 from axes.models import AccessAttempt
14 from axes.signals import user_locked_out
1415
15 log = getLogger(settings.AXES_LOGGER)
16 log = getLogger(__name__)
1617
1718
1819 class AxesCacheHandler(AbstractAxesHandler, AxesBaseHandler):
2425 self.cache = get_cache()
2526 self.cache_timeout = get_cache_timeout()
2627
28 def reset_attempts(
29 self,
30 *,
31 ip_address: str = None,
32 username: str = None,
33 ip_or_username: bool = False,
34 ) -> int:
35 cache_keys: list = []
36 count = 0
37
38 if ip_address is None and username is None:
39 raise NotImplementedError("Cannot clear all entries from cache")
40 if ip_or_username:
41 raise NotImplementedError(
42 "Due to the cache key ip_or_username=True is not supported"
43 )
44
45 cache_keys.extend(
46 get_client_cache_key(
47 AccessAttempt(username=username, ip_address=ip_address)
48 )
49 )
50
51 for cache_key in cache_keys:
52 deleted = self.cache.delete(cache_key)
53 count += int(deleted) if deleted is not None else 1
54
55 log.info("AXES: Reset %d access attempts from database.", count)
56
57 return count
58
2759 def get_failures(self, request, credentials: dict = None) -> int:
28 cache_key = get_client_cache_key(request, credentials)
29 return self.cache.get(cache_key, default=0)
60 cache_keys = get_client_cache_key(request, credentials)
61 failure_count = max(
62 self.cache.get(cache_key, default=0) for cache_key in cache_keys
63 )
64 return failure_count
3065
3166 def user_login_failed(
3267 self, sender, credentials: dict, request=None, **kwargs
4479 return
4580
4681 username = get_client_username(request, credentials)
82 if settings.AXES_ONLY_USER_FAILURES and username is None:
83 log.warning(
84 "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
85 )
86 return
87
4788 client_str = get_client_str(
4889 username,
4990 request.axes_ip_address,
70111 client_str,
71112 )
72113
73 cache_key = get_client_cache_key(request, credentials)
74 self.cache.set(cache_key, failures_since_start, self.cache_timeout)
114 cache_keys = get_client_cache_key(request, credentials)
115 for cache_key in cache_keys:
116 failures = self.cache.get(cache_key, default=0)
117 self.cache.set(cache_key, failures + 1, self.cache_timeout)
75118
76119 if (
77120 settings.AXES_LOCK_OUT_AT_FAILURE
108151 log.info("AXES: Successful login by %s.", client_str)
109152
110153 if settings.AXES_RESET_ON_SUCCESS:
111 cache_key = get_client_cache_key(request, credentials)
112 failures_since_start = self.cache.get(cache_key, default=0)
113 self.cache.delete(cache_key)
114 log.info(
115 "AXES: Deleted %d failed login attempts by %s from cache.",
116 failures_since_start,
117 client_str,
118 )
154 cache_keys = get_client_cache_key(request, credentials)
155 for cache_key in cache_keys:
156 failures_since_start = self.cache.get(cache_key, default=0)
157 self.cache.delete(cache_key)
158 log.info(
159 "AXES: Deleted %d failed login attempts by %s from cache.",
160 failures_since_start,
161 client_str,
162 )
119163
120164 def user_logged_out(self, sender, request, user, **kwargs):
121165 username = user.get_username() if user else None
00 from logging import getLogger
11
2 from django.db.models import Max, Value
2 from django.db.models import Sum, Value, Q
33 from django.db.models.functions import Concat
44 from django.utils import timezone
55
1010 )
1111 from axes.conf import settings
1212 from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
13 from axes.models import AccessLog, AccessAttempt
14 from axes.signals import user_locked_out
1513 from axes.helpers import (
1614 get_client_str,
1715 get_client_username,
1917 get_failure_limit,
2018 get_query_str,
2119 )
22
23
24 log = getLogger(settings.AXES_LOGGER)
20 from axes.models import AccessLog, AccessAttempt
21 from axes.signals import user_locked_out
22
23 log = getLogger(__name__)
2524
2625
2726 class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler):
3231 process, caching its output can be dangerous.
3332 """
3433
35 def reset_attempts(self, *, ip_address: str = None, username: str = None) -> int:
34 def reset_attempts(
35 self,
36 *,
37 ip_address: str = None,
38 username: str = None,
39 ip_or_username: bool = False,
40 ) -> int:
3641 attempts = AccessAttempt.objects.all()
3742
38 if ip_address:
39 attempts = attempts.filter(ip_address=ip_address)
40 if username:
41 attempts = attempts.filter(username=username)
43 if ip_or_username:
44 attempts = attempts.filter(Q(ip_address=ip_address) | Q(username=username))
45 else:
46 if ip_address:
47 attempts = attempts.filter(ip_address=ip_address)
48 if username:
49 attempts = attempts.filter(username=username)
4250
4351 count, _ = attempts.delete()
4452 log.info("AXES: Reset %d access attempts from database.", count)
6169 return count
6270
6371 def get_failures(self, request, credentials: dict = None) -> int:
64 attempts = get_user_attempts(request, credentials)
65 return (
66 attempts.aggregate(Max("failures_since_start"))["failures_since_start__max"]
67 or 0
68 )
72 attempts_list = get_user_attempts(request, credentials)
73 attempt_count = max(
74 (
75 attempts.aggregate(Sum("failures_since_start"))[
76 "failures_since_start__sum"
77 ]
78 or 0
79 )
80 for attempts in attempts_list
81 )
82 return attempt_count
6983
7084 def user_login_failed(
7185 self, sender, credentials: dict, request=None, **kwargs
7286 ): # pylint: disable=too-many-locals
7387 """
74 When user login fails, save AccessAttempt record in database and lock user out if necessary.
75
76 :raises AxesSignalPermissionDenied: if user should be locked out.
77 """
88 When user login fails, save AccessAttempt record in database, mark request with lockout attribute and emit lockout signal.
89 """
90
91 log.info("AXES: User login failed, running database handler for failure.")
7892
7993 if request is None:
8094 log.error(
93107 request.axes_path_info,
94108 )
95109
96 # This replaces null byte chars that crash saving failures, meaning an attacker doesn't get locked out.
110 # This replaces null byte chars that crash saving failures.
97111 get_data = get_query_str(request.GET).replace("\0", "0x00")
98112 post_data = get_query_str(request.POST).replace("\0", "0x00")
99113
101115 log.info("AXES: Login failed from whitelisted client %s.", client_str)
102116 return
103117
104 # 2. database query: Calculate the current maximum failure number from the existing attempts
105 failures_since_start = 1 + self.get_failures(request, credentials)
106
107 # 3. database query: Insert or update access records with the new failure data
108 if failures_since_start > 1:
118 # 2. database query: Get or create access record with the new failure data
119 if settings.AXES_ONLY_USER_FAILURES and username is None:
120 log.warning(
121 "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
122 )
123 else:
124 attempt, created = AccessAttempt.objects.get_or_create(
125 username=username,
126 ip_address=request.axes_ip_address,
127 user_agent=request.axes_user_agent,
128 defaults={
129 "get_data": get_data,
130 "post_data": post_data,
131 "http_accept": request.axes_http_accept,
132 "path_info": request.axes_path_info,
133 "failures_since_start": 1,
134 "attempt_time": request.axes_attempt_time,
135 },
136 )
137
138 # Record failed attempt with all the relevant information.
139 # Filtering based on username, IP address and user agent handled elsewhere,
140 # and this handler just records the available information for further use.
141 if created:
142 log.warning(
143 "AXES: New login failure by %s. Created new record in the database.",
144 client_str,
145 )
146
147 # 3. database query if there were previous attempts in the database
109148 # Update failed attempt information but do not touch the username, IP address, or user agent fields,
110149 # because attackers can request the site with multiple different configurations
111150 # in order to bypass the defense mechanisms that are used by the site.
112
113 log.warning(
114 "AXES: Repeated login failure by %s. Count = %d of %d. Updating existing record in the database.",
115 client_str,
116 failures_since_start,
117 get_failure_limit(request, credentials),
118 )
119
120 separator = "\n---------\n"
121
122 attempts = get_user_attempts(request, credentials)
123 attempts.update(
124 get_data=Concat("get_data", Value(separator + get_data)),
125 post_data=Concat("post_data", Value(separator + post_data)),
126 http_accept=request.axes_http_accept,
127 path_info=request.axes_path_info,
128 failures_since_start=failures_since_start,
129 attempt_time=request.axes_attempt_time,
130 username=username,
131 )
132 else:
133 # Record failed attempt with all the relevant information.
134 # Filtering based on username, IP address and user agent handled elsewhere,
135 # and this handler just records the available information for further use.
136
137 log.warning(
138 "AXES: New login failure by %s. Creating new record in the database.",
139 client_str,
140 )
141
142 AccessAttempt.objects.create(
143 username=username,
144 ip_address=request.axes_ip_address,
145 user_agent=request.axes_user_agent,
146 get_data=get_data,
147 post_data=post_data,
148 http_accept=request.axes_http_accept,
149 path_info=request.axes_path_info,
150 failures_since_start=failures_since_start,
151 attempt_time=request.axes_attempt_time,
152 )
151 else:
152 separator = "\n---------\n"
153
154 attempt.get_data = Concat("get_data", Value(separator + get_data))
155 attempt.post_data = Concat("post_data", Value(separator + post_data))
156 attempt.http_accept = request.axes_http_accept
157 attempt.path_info = request.axes_path_info
158 attempt.failures_since_start += 1
159 attempt.attempt_time = request.axes_attempt_time
160 attempt.save()
161
162 log.warning(
163 "AXES: Repeated login failure by %s. Count = %d of %d. Updated existing record in the database.",
164 client_str,
165 attempt.failures_since_start,
166 get_failure_limit(request, credentials),
167 )
168
169 # 3. or 4. database query: Calculate the current maximum failure number from the existing attempts
170 failures_since_start = self.get_failures(request, credentials)
153171
154172 if (
155173 settings.AXES_LOCK_OUT_AT_FAILURE
160178 )
161179
162180 request.axes_locked_out = True
163
164181 user_locked_out.send(
165182 "axes",
166183 request=request,
1212 toggleable,
1313 )
1414
15 log = getLogger(settings.AXES_LOGGER)
15 log = getLogger(__name__)
1616
1717
1818 class AxesProxyHandler(AbstractAxesHandler, AxesBaseHandler):
4242 return cls.implementation
4343
4444 @classmethod
45 def reset_attempts(cls, *, ip_address: str = None, username: str = None) -> int:
45 def reset_attempts(
46 cls,
47 *,
48 ip_address: str = None,
49 username: str = None,
50 ip_or_username: bool = False,
51 ) -> int:
4652 return cls.get_implementation().reset_attempts(
47 ip_address=ip_address, username=username
53 ip_address=ip_address, username=username, ip_or_username=ip_or_username
4854 )
4955
5056 @classmethod
112118 return cls.get_implementation().post_save_access_attempt(instance, **kwargs)
113119
114120 @classmethod
121 @toggleable
115122 def post_delete_access_attempt(cls, instance, **kwargs):
116123 return cls.get_implementation().post_delete_access_attempt(instance, **kwargs)
55 Signal handler implementation that does nothing, ideal for a test suite.
66 """
77
8 def reset_attempts(self, *, ip_address: str = None, username: str = None) -> int:
8 def reset_attempts(
9 self,
10 *,
11 ip_address: str = None,
12 username: str = None,
13 ip_or_username: bool = False,
14 ) -> int:
915 return 0
1016
1117 def reset_logs(self, *, age_days: int = None) -> int:
44 from typing import Callable, Optional, Type, Union
55 from urllib.parse import urlencode
66
7 import ipware.ip
78 from django.core.cache import caches, BaseCache
89 from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict
910 from django.shortcuts import render, redirect
1011 from django.utils.module_loading import import_string
11
12 import ipware.ip
1312
1413 from axes.conf import settings
1514 from axes.models import AccessBase
173172 return request.META.get("HTTP_ACCEPT", "<unknown>")[:1025]
174173
175174
176 def get_client_parameters(username: str, ip_address: str, user_agent: str) -> dict:
175 def get_client_parameters(username: str, ip_address: str, user_agent: str) -> list:
177176 """
178177 Get query parameters for filtering AccessAttempt queryset.
179178
180179 This method returns a dict that guarantees iteration order for keys and values,
181180 and can so be used in e.g. the generation of hash keys or other deterministic functions.
182 """
183
184 filter_kwargs = dict()
181
182 Returns list of dict, every item of list are separate parameters
183 """
185184
186185 if settings.AXES_ONLY_USER_FAILURES:
187186 # 1. Only individual usernames can be tracked with parametrization
188 filter_kwargs["username"] = username
187 filter_query = [{"username": username}]
189188 else:
190 if settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
189 if settings.AXES_LOCK_OUT_BY_USER_OR_IP:
190 # One of `username` or `IP address` is used
191 filter_query = [{"username": username}, {"ip_address": ip_address}]
192 elif settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP:
191193 # 2. A combination of username and IP address can be used as well
192 filter_kwargs["username"] = username
193 filter_kwargs["ip_address"] = ip_address
194 filter_query = [{"username": username, "ip_address": ip_address}]
194195 else:
195196 # 3. Default case is to track the IP address only, which is the most secure option
196 filter_kwargs["ip_address"] = ip_address
197 filter_query = [{"ip_address": ip_address}]
197198
198199 if settings.AXES_USE_USER_AGENT:
199200 # 4. The HTTP User-Agent can be used to track e.g. one browser
200 filter_kwargs["user_agent"] = user_agent
201
202 return filter_kwargs
201 filter_query.append({"user_agent": user_agent})
202
203 return filter_query
204
205
206 def make_cache_key_list(filter_kwargs_list):
207 cache_keys = []
208 for filter_kwargs in filter_kwargs_list:
209 cache_key_components = "".join(
210 value for value in filter_kwargs.values() if value
211 )
212 cache_key_digest = md5(cache_key_components.encode()).hexdigest()
213 cache_keys.append(f"axes-{cache_key_digest}")
214 return cache_keys
203215
204216
205217 def get_client_cache_key(
222234 ip_address = get_client_ip_address(request_or_attempt)
223235 user_agent = get_client_user_agent(request_or_attempt)
224236
225 filter_kwargs = get_client_parameters(username, ip_address, user_agent)
226
227 cache_key_components = "".join(value for value in filter_kwargs.values() if value)
228 cache_key_digest = md5(cache_key_components.encode()).hexdigest()
229 cache_key = f"axes-{cache_key_digest}"
230
231 return cache_key
237 filter_kwargs_list = get_client_parameters(username, ip_address, user_agent)
238
239 return make_cache_key_list(filter_kwargs_list)
232240
233241
234242 def get_client_str(
240248 Example log format would be
241249 ``{username: "example", ip_address: "127.0.0.1", path_info: "/example/"}``
242250 """
251
252 if settings.AXES_CLIENT_STR_CALLABLE:
253 log.debug("Using settings.AXES_CLIENT_STR_CALLABLE to get client string.")
254
255 if callable(settings.AXES_CLIENT_STR_CALLABLE):
256 return settings.AXES_CLIENT_STR_CALLABLE(
257 username, ip_address, user_agent, path_info
258 )
259 if isinstance(settings.AXES_CLIENT_STR_CALLABLE, str):
260 return import_string(settings.AXES_CLIENT_STR_CALLABLE)(
261 username, ip_address, user_agent, path_info
262 )
263 raise TypeError(
264 "settings.AXES_CLIENT_STR_CALLABLE needs to be a string, callable or None."
265 )
243266
244267 client_dict = dict()
245268
250273 client_dict["user_agent"] = user_agent
251274 else:
252275 # Other modes initialize the attributes that are used for the actual lockouts
253 client_dict = get_client_parameters(username, ip_address, user_agent)
276 client_list = get_client_parameters(username, ip_address, user_agent)
277 client_dict = {}
278 for client in client_list:
279 client_dict.update(client)
254280
255281 # Path info is always included as last component in the client string for traceability purposes
256282 if path_info and isinstance(path_info, (tuple, list)):
265291 return client_str
266292
267293
294 def cleanse_parameters(params: dict) -> dict:
295 """
296 Replace sensitive parameter values in a parameter dict with
297 a safe placeholder value.
298
299 Parameters name ``'password'`` will always be cleansed. Additionally,
300 parameters named in ``settings.AXES_SENSITIVE_PARAMETERS`` and
301 ``settings.AXES_PASSWORD_FORM_FIELD will be cleansed.
302
303 This is used to prevent passwords and similar values from
304 being logged in cleartext.
305 """
306 sensitive_parameters = ["password"] + settings.AXES_SENSITIVE_PARAMETERS
307 if settings.AXES_PASSWORD_FORM_FIELD:
308 sensitive_parameters.append(settings.AXES_PASSWORD_FORM_FIELD)
309
310 if sensitive_parameters:
311 cleansed = params.copy()
312 for param in sensitive_parameters:
313 if param in cleansed:
314 cleansed[param] = "********************"
315 return cleansed
316 return params
317
318
268319 def get_query_str(query: Type[QueryDict], max_length: int = 1024) -> str:
269320 """
270321 Turns a query dictionary into an easy-to-read list of key-value pairs.
271322
272 If a field is called either ``'password'`` or ``settings.AXES_PASSWORD_FORM_FIELD`` it will be excluded.
323 If a field is called either ``'password'`` or ``settings.AXES_PASSWORD_FORM_FIELD`` or if the fieldname is included
324 in ``settings.AXES_SENSITIVE_PARAMETERS`` its value will be masked.
273325
274326 The length of the output is limited to max_length to avoid a DoS attack via excessively large payloads.
275327 """
276328
277 query_dict = query.copy()
278 query_dict.pop("password", None)
279 query_dict.pop(settings.AXES_PASSWORD_FORM_FIELD, None)
329 query_dict = cleanse_parameters(query.copy())
280330
281331 template = Template("$key=$value")
282332 items = [{"key": k, "value": v} for k, v in query_dict.items()]
328378 }
329379 )
330380
331 if request.is_ajax():
332 return JsonResponse(context, status=status)
381 if request.META.get("HTTP_X_REQUESTED_WITH") == "XMLHttpRequest":
382 json_response = JsonResponse(context, status=status)
383 json_response[
384 "Access-Control-Allow-Origin"
385 ] = settings.AXES_ALLOWED_CORS_ORIGINS
386 json_response["Access-Control-Allow-Methods"] = "POST, OPTIONS"
387 json_response[
388 "Access-Control-Allow-Headers"
389 ] = "Origin, Content-Type, Accept, Authorization, x-requested-with"
390 return json_response
333391
334392 if settings.AXES_LOCKOUT_TEMPLATE:
335393 return render(request, settings.AXES_LOCKOUT_TEMPLATE, context, status=status)
2626 msgstr "Meta-Daten"
2727
2828 #: axes/conf.py:58
29 msgid "Account locked: too many login attempts. Please try again later"
29 msgid "Account locked: too many login attempts. Please try again later."
3030 msgstr ""
3131 "Zugang gesperrt: zu viele fehlgeschlagene Anmeldeversuche. Bitte versuchen "
3232 "Sie es später erneut."
0 # SOME DESCRIPTIVE TITLE.
1 # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
2 # This file is distributed under the same license as the PACKAGE package.
3 # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
4 #
5 msgid ""
6 msgstr ""
7 "Project-Id-Version: \n"
8 "Report-Msgid-Bugs-To: \n"
9 "POT-Creation-Date: 2021-06-11 23:36+0200\n"
10 "PO-Revision-Date: 2021-06-16 10:51+0300\n"
11 "Language: pl\n"
12 "MIME-Version: 1.0\n"
13 "Content-Type: text/plain; charset=UTF-8\n"
14 "Content-Transfer-Encoding: 8bit\n"
15 "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n"
16 "%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n"
17 "%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
18 "Last-Translator: \n"
19 "Language-Team: \n"
20 "X-Generator: Poedit 3.0\n"
21
22 #: .\axes\admin.py:26
23 msgid "Form Data"
24 msgstr "Dane formularza"
25
26 #: .\axes\admin.py:27 .\axes\admin.py:64
27 msgid "Meta Data"
28 msgstr "Metadane"
29
30 #: .\axes\conf.py:89
31 msgid "Account locked: too many login attempts. Please try again later."
32 msgstr ""
33 "Konto zablokowane: zbyt wiele prób logowania. Spróbuj ponownie później."
34
35 #: .\axes\conf.py:97
36 msgid ""
37 "Account locked: too many login attempts. Contact an admin to unlock your "
38 "account."
39 msgstr ""
40 "Konto zablokowane: zbyt wiele prób logowania. Skontaktuj się z "
41 "administratorem, aby odblokować swoje konto."
42
43 #: .\axes\models.py:6
44 #, fuzzy
45 msgid "User Agent"
46 msgstr "User Agent"
47
48 #: .\axes\models.py:8
49 msgid "IP Address"
50 msgstr "Adres IP"
51
52 #: .\axes\models.py:10
53 msgid "Username"
54 msgstr "Nazwa Użytkownika"
55
56 #: .\axes\models.py:12
57 #, fuzzy
58 msgid "HTTP Accept"
59 msgstr "HTTP Accept"
60
61 #: .\axes\models.py:14
62 msgid "Path"
63 msgstr "Ścieżka"
64
65 #: .\axes\models.py:16
66 msgid "Attempt Time"
67 msgstr "Czas wystąpienia"
68
69 #: .\axes\models.py:25
70 msgid "GET Data"
71 msgstr "Dane GET"
72
73 #: .\axes\models.py:27
74 msgid "POST Data"
75 msgstr "Dane POST"
76
77 #: .\axes\models.py:29
78 msgid "Failed Logins"
79 msgstr "Nieudane logowania"
80
81 #: .\axes\models.py:35
82 msgid "access attempt"
83 msgstr "próba dostępu"
84
85 #: .\axes\models.py:36
86 msgid "access attempts"
87 msgstr "próby dostępu"
88
89 #: .\axes\models.py:40
90 msgid "Logout Time"
91 msgstr "Czas wylogowania"
92
93 #: .\axes\models.py:46
94 msgid "access log"
95 msgstr "dziennik logowania"
96
97 #: .\axes\models.py:47
98 msgid "access logs"
99 msgstr "dzienniki logowania"
2626 msgstr "Метаданные"
2727
2828 #: axes/conf.py:58
29 msgid "Account locked: too many login attempts. Please try again later"
29 msgid "Account locked: too many login attempts. Please try again later."
3030 msgstr ""
3131 "Учетная запись заблокирована: слишком много попыток входа. "
3232 "Повторите попытку позже."
2626 msgstr "Meta-Verisi"
2727
2828 #: axes/conf.py:58
29 msgid "Account locked: too many login attempts. Please try again later"
29 msgid "Account locked: too many login attempts. Please try again later."
3030 msgstr ""
31 "Hesap kilitlendi: cok fazla erişim denemesi. Lütfen daha sonra tekrar deneyiniz"
31 "Hesap kilitlendi: cok fazla erişim denemesi. Lütfen daha sonra tekrar deneyiniz."
3232
3333 #: axes/conf.py:61
3434 msgid ""
3636 "account."
3737 msgstr ""
3838 "Hesap kilitlendi: cok fazla erişim denemesi. Hesabını açtırmak için yöneticiyle iletişime"
39 "geçin"
39 "geçin."
4040
4141 #: axes/models.py:9
4242 msgid "User Agent"
+0
-1
axes/management/commands/axes_reset_user.py less more
0 axes_reset_username.py
0 from django.core.management.base import BaseCommand
1
2 from axes.utils import reset
3
4
5 class Command(BaseCommand):
6 help = "Reset all access attempts and lockouts for given usernames"
7
8 def add_arguments(self, parser):
9 parser.add_argument("username", nargs="+", type=str)
10
11 def handle(self, *args, **options):
12 count = 0
13
14 for username in options["username"]:
15 count += reset(username=username)
16
17 if count:
18 self.stdout.write(f"{count} attempts removed.")
19 else:
20 self.stdout.write("No attempts found.")
00 from typing import Callable
1
2 from django.conf import settings
13
24 from axes.helpers import get_lockout_response
35
68 """
79 Middleware that calculates necessary HTTP request attributes for attempt monitoring
810 and maps lockout signals into readable HTTP 403 Forbidden responses.
11
12 If a project uses ``django rest framework`` then the middleware updates the
13 request and checks whether the limit has been exceeded. It's needed only
14 for integration with DRF because it uses its own request object.
915
1016 This middleware recognizes a logout monitoring flag in the request and
1117 and uses the ``axes.helpers.get_lockout_response`` handler for returning
2834 def __call__(self, request):
2935 response = self.get_response(request)
3036
31 if getattr(request, "axes_locked_out", None):
32 response = get_lockout_response(request) # type: ignore
37 if settings.AXES_ENABLED:
38 if getattr(request, "axes_locked_out", None):
39 response = get_lockout_response(request) # type: ignore
3340
3441 return response
66 )
77 from django.core.signals import setting_changed
88 from django.db.models.signals import post_save, post_delete
9 from django.dispatch import Signal
910 from django.dispatch import receiver
10 from django.dispatch import Signal
1111
12 from axes.conf import settings
12 from axes.handlers.proxy import AxesProxyHandler
1313 from axes.models import AccessAttempt
14 from axes.handlers.proxy import AxesProxyHandler
1514
16 log = getLogger(settings.AXES_LOGGER)
15 log = getLogger(__name__)
1716
1817
19 user_locked_out = Signal(providing_args=["request", "username", "ip_address"])
18 # This signal provides the following arguments to any listeners:
19 # request - The current Request object.
20 # username - The username of the User who has been locked out.
21 # ip_address - The IP of the user who has been locked out.
22 user_locked_out = Signal()
2023
2124
2225 @receiver(user_login_failed)
+0
-0
axes/tests/__init__.py less more
(Empty file)
+0
-185
axes/tests/base.py less more
0 from random import choice
1 from string import ascii_letters, digits
2 from time import sleep
3
4 from django.contrib.auth import get_user_model
5 from django.http import HttpRequest
6 from django.test import TestCase
7 from django.urls import reverse
8 from django.utils.timezone import now
9
10 from axes.utils import reset
11 from axes.conf import settings
12 from axes.helpers import (
13 get_cache,
14 get_client_http_accept,
15 get_client_ip_address,
16 get_client_path_info,
17 get_client_user_agent,
18 get_cool_off,
19 get_credentials,
20 get_failure_limit,
21 )
22 from axes.models import AccessAttempt, AccessLog
23
24
25 def custom_failure_limit(request, credentials):
26 return 3
27
28
29 class AxesTestCase(TestCase):
30 """
31 Test case using custom settings for testing.
32 """
33
34 VALID_USERNAME = "axes-valid-username"
35 VALID_PASSWORD = "axes-valid-password"
36 VALID_EMAIL = "axes-valid-email@example.com"
37 VALID_USER_AGENT = "axes-user-agent"
38 VALID_IP_ADDRESS = "127.0.0.1"
39
40 INVALID_USERNAME = "axes-invalid-username"
41 INVALID_PASSWORD = "axes-invalid-password"
42 INVALID_EMAIL = "axes-invalid-email@example.com"
43
44 LOCKED_MESSAGE = "Account locked: too many login attempts."
45 LOGOUT_MESSAGE = "Logged out"
46 LOGIN_FORM_KEY = '<input type="submit" value="Log in" />'
47
48 STATUS_SUCCESS = 200
49 ALLOWED = 302
50 BLOCKED = 403
51
52 def setUp(self):
53 """
54 Create a valid user for login.
55 """
56
57 self.username = self.VALID_USERNAME
58 self.password = self.VALID_PASSWORD
59 self.email = self.VALID_EMAIL
60
61 self.ip_address = self.VALID_IP_ADDRESS
62 self.user_agent = self.VALID_USER_AGENT
63 self.path_info = reverse("admin:login")
64
65 self.user = get_user_model().objects.create_superuser(
66 username=self.username, password=self.password, email=self.email
67 )
68
69 self.request = HttpRequest()
70 self.request.method = "POST"
71 self.request.META["REMOTE_ADDR"] = self.ip_address
72 self.request.META["HTTP_USER_AGENT"] = self.user_agent
73 self.request.META["PATH_INFO"] = self.path_info
74
75 self.request.axes_attempt_time = now()
76 self.request.axes_ip_address = get_client_ip_address(self.request)
77 self.request.axes_user_agent = get_client_user_agent(self.request)
78 self.request.axes_path_info = get_client_path_info(self.request)
79 self.request.axes_http_accept = get_client_http_accept(self.request)
80
81 self.credentials = get_credentials(self.username)
82
83 def tearDown(self):
84 get_cache().clear()
85
86 def get_kwargs_with_defaults(self, **kwargs):
87 defaults = {
88 "user_agent": self.user_agent,
89 "ip_address": self.ip_address,
90 "username": self.username,
91 }
92
93 defaults.update(kwargs)
94 return defaults
95
96 def create_attempt(self, **kwargs):
97 kwargs = self.get_kwargs_with_defaults(**kwargs)
98 kwargs.setdefault("failures_since_start", 1)
99 return AccessAttempt.objects.create(**kwargs)
100
101 def create_log(self, **kwargs):
102 return AccessLog.objects.create(**self.get_kwargs_with_defaults(**kwargs))
103
104 def reset(self, ip=None, username=None):
105 return reset(ip, username)
106
107 def login(self, is_valid_username=False, is_valid_password=False, **kwargs):
108 """
109 Login a user.
110
111 A valid credential is used when is_valid_username is True,
112 otherwise it will use a random string to make a failed login.
113 """
114
115 if is_valid_username:
116 username = self.VALID_USERNAME
117 else:
118 username = "".join(choice(ascii_letters + digits) for _ in range(10))
119
120 if is_valid_password:
121 password = self.VALID_PASSWORD
122 else:
123 password = self.INVALID_PASSWORD
124
125 post_data = {"username": username, "password": password, **kwargs}
126
127 return self.client.post(
128 reverse("admin:login"),
129 post_data,
130 REMOTE_ADDR=self.ip_address,
131 HTTP_USER_AGENT=self.user_agent,
132 )
133
134 def logout(self):
135 return self.client.post(
136 reverse("admin:logout"),
137 REMOTE_ADDR=self.ip_address,
138 HTTP_USER_AGENT=self.user_agent,
139 )
140
141 def check_login(self):
142 response = self.login(is_valid_username=True, is_valid_password=True)
143 self.assertNotContains(
144 response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
145 )
146
147 def almost_lockout(self):
148 for _ in range(1, get_failure_limit(None, None)):
149 response = self.login()
150 self.assertContains(response, self.LOGIN_FORM_KEY, html=True)
151
152 def lockout(self):
153 self.almost_lockout()
154 return self.login()
155
156 def check_lockout(self):
157 response = self.lockout()
158 if settings.AXES_LOCK_OUT_AT_FAILURE == True:
159 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
160 else:
161 self.assertNotContains(
162 response, self.LOCKED_MESSAGE, status_code=self.STATUS_SUCCESS
163 )
164
165 def cool_off(self):
166 sleep(get_cool_off().total_seconds())
167
168 def check_logout(self):
169 response = self.logout()
170 self.assertContains(
171 response, self.LOGOUT_MESSAGE, status_code=self.STATUS_SUCCESS
172 )
173
174 def check_handler(self):
175 """
176 Check a handler and its basic functionality with lockouts, cool offs, login, and logout.
177
178 This is a check that is intended to successfully run for each and every new handler.
179 """
180
181 self.check_lockout()
182 self.cool_off()
183 self.check_login()
184 self.check_logout()
+0
-74
axes/tests/settings.py less more
0 DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
1
2 CACHES = {
3 "default": {
4 # This cache backend is OK to use in development and testing
5 # but has the potential to break production setups with more than on process
6 # due to each process having their own local memory based cache
7 "BACKEND": "django.core.cache.backends.locmem.LocMemCache"
8 }
9 }
10
11 SITE_ID = 1
12
13 MIDDLEWARE = [
14 "django.middleware.common.CommonMiddleware",
15 "django.contrib.sessions.middleware.SessionMiddleware",
16 "django.contrib.auth.middleware.AuthenticationMiddleware",
17 "django.contrib.messages.middleware.MessageMiddleware",
18 "axes.middleware.AxesMiddleware",
19 ]
20
21 AUTHENTICATION_BACKENDS = [
22 "axes.backends.AxesBackend",
23 "django.contrib.auth.backends.ModelBackend",
24 ]
25
26 PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
27
28 ROOT_URLCONF = "axes.tests.urls"
29
30 INSTALLED_APPS = [
31 "django.contrib.auth",
32 "django.contrib.contenttypes",
33 "django.contrib.sessions",
34 "django.contrib.sites",
35 "django.contrib.messages",
36 "django.contrib.admin",
37 "axes",
38 ]
39
40 TEMPLATES = [
41 {
42 "BACKEND": "django.template.backends.django.DjangoTemplates",
43 "DIRS": [],
44 "APP_DIRS": True,
45 "OPTIONS": {
46 "context_processors": [
47 "django.template.context_processors.debug",
48 "django.template.context_processors.request",
49 "django.contrib.auth.context_processors.auth",
50 "django.contrib.messages.context_processors.messages",
51 ]
52 },
53 }
54 ]
55
56 LOGGING = {
57 "version": 1,
58 "disable_existing_loggers": False,
59 "handlers": {"console": {"class": "logging.StreamHandler"}},
60 "loggers": {"axes": {"handlers": ["console"], "level": "INFO", "propagate": False}},
61 }
62
63 SECRET_KEY = "too-secret-for-test"
64
65 USE_I18N = False
66
67 USE_L10N = False
68
69 USE_TZ = False
70
71 LOGIN_REDIRECT_URL = "/admin/"
72
73 AXES_FAILURE_LIMIT = 10
+0
-28
axes/tests/test_admin.py less more
0 from contextlib import suppress
1 from importlib import reload
2
3 from django.contrib import admin
4 from django.test import override_settings
5
6 import axes.admin
7 from axes.models import AccessAttempt, AccessLog
8 from axes.tests.base import AxesTestCase
9
10
11 class AxesEnableAdminFlag(AxesTestCase):
12 def setUp(self):
13 with suppress(admin.sites.NotRegistered):
14 admin.site.unregister(AccessAttempt)
15 with suppress(admin.sites.NotRegistered):
16 admin.site.unregister(AccessLog)
17
18 @override_settings(AXES_ENABLE_ADMIN=False)
19 def test_disable_admin(self):
20 reload(axes.admin)
21 self.assertFalse(admin.site.is_registered(AccessAttempt))
22 self.assertFalse(admin.site.is_registered(AccessLog))
23
24 def test_enable_admin_by_default(self):
25 reload(axes.admin)
26 self.assertTrue(admin.site.is_registered(AccessAttempt))
27 self.assertTrue(admin.site.is_registered(AccessLog))
+0
-46
axes/tests/test_attempts.py less more
0 from unittest.mock import patch
1
2 from django.test import override_settings
3 from django.utils.timezone import now
4
5 from axes.attempts import get_cool_off_threshold
6 from axes.models import AccessAttempt
7 from axes.tests.base import AxesTestCase
8 from axes.utils import reset
9
10
11 class GetCoolOffThresholdTestCase(AxesTestCase):
12 @override_settings(AXES_COOLOFF_TIME=42)
13 def test_get_cool_off_threshold(self):
14 timestamp = now()
15
16 with patch("axes.attempts.now", return_value=timestamp):
17 attempt_time = timestamp
18 threshold_now = get_cool_off_threshold(attempt_time)
19
20 attempt_time = None
21 threshold_none = get_cool_off_threshold(attempt_time)
22
23 self.assertEqual(threshold_now, threshold_none)
24
25 @override_settings(AXES_COOLOFF_TIME=None)
26 def test_get_cool_off_threshold_error(self):
27 with self.assertRaises(TypeError):
28 get_cool_off_threshold()
29
30
31 class ResetTestCase(AxesTestCase):
32 def test_reset(self):
33 self.create_attempt()
34 reset()
35 self.assertFalse(AccessAttempt.objects.count())
36
37 def test_reset_ip(self):
38 self.create_attempt(ip_address=self.ip_address)
39 reset(ip=self.ip_address)
40 self.assertFalse(AccessAttempt.objects.count())
41
42 def test_reset_username(self):
43 self.create_attempt(username=self.username)
44 reset(username=self.username)
45 self.assertFalse(AccessAttempt.objects.count())
+0
-23
axes/tests/test_backends.py less more
0 from unittest.mock import patch, MagicMock
1
2 from axes.backends import AxesBackend
3 from axes.exceptions import (
4 AxesBackendRequestParameterRequired,
5 AxesBackendPermissionDenied,
6 )
7 from axes.tests.base import AxesTestCase
8
9
10 class BackendTestCase(AxesTestCase):
11 def test_authenticate_raises_on_missing_request(self):
12 request = None
13
14 with self.assertRaises(AxesBackendRequestParameterRequired):
15 AxesBackend().authenticate(request)
16
17 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
18 def test_authenticate_raises_on_locked_request(self, _):
19 request = MagicMock()
20
21 with self.assertRaises(AxesBackendPermissionDenied):
22 AxesBackend().authenticate(request)
+0
-110
axes/tests/test_checks.py less more
0 from django.core.checks import run_checks, Warning # pylint: disable=redefined-builtin
1 from django.test import override_settings, modify_settings
2
3 from axes.backends import AxesBackend
4 from axes.checks import Messages, Hints, Codes
5 from axes.tests.base import AxesTestCase
6
7
8 class CacheCheckTestCase(AxesTestCase):
9 @override_settings(
10 AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
11 CACHES={
12 "default": {
13 "BACKEND": "django.core.cache.backends.db.DatabaseCache",
14 "LOCATION": "axes_cache",
15 }
16 },
17 )
18 def test_cache_check(self):
19 warnings = run_checks()
20 self.assertEqual(warnings, [])
21
22 @override_settings(
23 AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
24 CACHES={
25 "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}
26 },
27 )
28 def test_cache_check_warnings(self):
29 warnings = run_checks()
30 warning = Warning(
31 msg=Messages.CACHE_INVALID, hint=Hints.CACHE_INVALID, id=Codes.CACHE_INVALID
32 )
33
34 self.assertEqual(warnings, [warning])
35
36 @override_settings(
37 AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler",
38 CACHES={
39 "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}
40 },
41 )
42 def test_cache_check_does_not_produce_check_warnings_with_database_handler(self):
43 warnings = run_checks()
44 self.assertEqual(warnings, [])
45
46
47 class MiddlewareCheckTestCase(AxesTestCase):
48 @modify_settings(MIDDLEWARE={"remove": ["axes.middleware.AxesMiddleware"]})
49 def test_cache_check_warnings(self):
50 warnings = run_checks()
51 warning = Warning(
52 msg=Messages.MIDDLEWARE_INVALID,
53 hint=Hints.MIDDLEWARE_INVALID,
54 id=Codes.MIDDLEWARE_INVALID,
55 )
56
57 self.assertEqual(warnings, [warning])
58
59
60 class AxesSpecializedBackend(AxesBackend):
61 pass
62
63
64 class BackendCheckTestCase(AxesTestCase):
65 @modify_settings(AUTHENTICATION_BACKENDS={"remove": ["axes.backends.AxesBackend"]})
66 def test_backend_missing(self):
67 warnings = run_checks()
68 warning = Warning(
69 msg=Messages.BACKEND_INVALID,
70 hint=Hints.BACKEND_INVALID,
71 id=Codes.BACKEND_INVALID,
72 )
73
74 self.assertEqual(warnings, [warning])
75
76 @override_settings(
77 AUTHENTICATION_BACKENDS=["axes.tests.test_checks.AxesSpecializedBackend"]
78 )
79 def test_specialized_backend(self):
80 warnings = run_checks()
81 self.assertEqual(warnings, [])
82
83 @override_settings(
84 AUTHENTICATION_BACKENDS=["axes.tests.test_checks.AxesNotDefinedBackend"]
85 )
86 def test_import_error(self):
87 with self.assertRaises(ImportError):
88 run_checks()
89
90 @override_settings(AUTHENTICATION_BACKENDS=["module.not_defined"])
91 def test_module_not_found_error(self):
92 with self.assertRaises(ModuleNotFoundError):
93 run_checks()
94
95
96 class DeprecatedSettingsTestCase(AxesTestCase):
97 def setUp(self):
98 self.disable_success_access_log_warning = Warning(
99 msg=Messages.SETTING_DEPRECATED.format(
100 deprecated_setting="AXES_DISABLE_SUCCESS_ACCESS_LOG"
101 ),
102 hint=Hints.SETTING_DEPRECATED,
103 id=Codes.SETTING_DEPRECATED,
104 )
105
106 @override_settings(AXES_DISABLE_SUCCESS_ACCESS_LOG=True)
107 def test_deprecated_success_access_log_flag(self):
108 warnings = run_checks()
109 self.assertEqual(warnings, [self.disable_success_access_log_warning])
+0
-40
axes/tests/test_decorators.py less more
0 from unittest.mock import MagicMock, patch
1
2 from django.http import HttpResponse
3
4 from axes.decorators import axes_dispatch, axes_form_invalid
5 from axes.tests.base import AxesTestCase
6
7
8 class DecoratorTestCase(AxesTestCase):
9 SUCCESS_RESPONSE = HttpResponse(status=200, content="Dispatched")
10 LOCKOUT_RESPONSE = HttpResponse(status=403, content="Locked out")
11
12 def setUp(self):
13 self.request = MagicMock()
14 self.cls = MagicMock(return_value=self.request)
15 self.func = MagicMock(return_value=self.SUCCESS_RESPONSE)
16
17 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
18 @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
19 def test_axes_dispatch_locks_out(self, _, __):
20 response = axes_dispatch(self.func)(self.request)
21 self.assertEqual(response.content, self.LOCKOUT_RESPONSE.content)
22
23 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=True)
24 @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
25 def test_axes_dispatch_dispatches(self, _, __):
26 response = axes_dispatch(self.func)(self.request)
27 self.assertEqual(response.content, self.SUCCESS_RESPONSE.content)
28
29 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
30 @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
31 def test_axes_form_invalid_locks_out(self, _, __):
32 response = axes_form_invalid(self.func)(self.cls)
33 self.assertEqual(response.content, self.LOCKOUT_RESPONSE.content)
34
35 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=True)
36 @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
37 def test_axes_form_invalid_dispatches(self, _, __):
38 response = axes_form_invalid(self.func)(self.cls)
39 self.assertEqual(response.content, self.SUCCESS_RESPONSE.content)
+0
-293
axes/tests/test_handlers.py less more
0 from unittest.mock import MagicMock, patch
1
2 from django.test import override_settings
3 from django.urls import reverse
4 from django.utils import timezone
5 from django.utils.timezone import timedelta
6
7 from axes.conf import settings
8 from axes.handlers.proxy import AxesProxyHandler
9 from axes.helpers import get_client_str
10 from axes.models import AccessAttempt, AccessLog
11 from axes.tests.base import AxesTestCase
12
13
14 @override_settings(AXES_HANDLER="axes.handlers.base.AxesHandler")
15 class AxesHandlerTestCase(AxesTestCase):
16 @override_settings(AXES_IP_BLACKLIST=["127.0.0.1"])
17 def test_is_allowed_with_blacklisted_ip_address(self):
18 self.assertFalse(AxesProxyHandler.is_allowed(self.request))
19
20 @override_settings(
21 AXES_NEVER_LOCKOUT_WHITELIST=True, AXES_IP_WHITELIST=["127.0.0.1"]
22 )
23 def test_is_allowed_with_whitelisted_ip_address(self):
24 self.assertTrue(AxesProxyHandler.is_allowed(self.request))
25
26 @override_settings(AXES_NEVER_LOCKOUT_GET=True)
27 def test_is_allowed_with_whitelisted_method(self):
28 self.request.method = "GET"
29 self.assertTrue(AxesProxyHandler.is_allowed(self.request))
30
31 @override_settings(AXES_LOCK_OUT_AT_FAILURE=False)
32 def test_is_allowed_no_lock_out(self):
33 self.assertTrue(AxesProxyHandler.is_allowed(self.request))
34
35 @override_settings(AXES_ONLY_ADMIN_SITE=True)
36 def test_only_admin_site(self):
37 request = MagicMock()
38 request.path = "/test/"
39 self.assertTrue(AxesProxyHandler.is_allowed(self.request))
40
41 def test_is_admin_site(self):
42 request = MagicMock()
43 tests = ( # (AXES_ONLY_ADMIN_SITE, URL, Expected)
44 (True, "/test/", True),
45 (True, reverse("admin:index"), False),
46 (False, "/test/", False),
47 (False, reverse("admin:index"), False),
48 )
49
50 for setting_value, url, expected in tests:
51 with override_settings(AXES_ONLY_ADMIN_SITE=setting_value):
52 request.path = url
53 self.assertEqual(AxesProxyHandler().is_admin_site(request), expected)
54
55 @override_settings(ROOT_URLCONF="axes.tests.urls_empty")
56 @override_settings(AXES_ONLY_ADMIN_SITE=True)
57 def test_is_admin_site_no_admin_site(self):
58 request = MagicMock()
59 request.path = "/admin/"
60 self.assertTrue(AxesProxyHandler().is_admin_site(self.request))
61
62
63 class AxesProxyHandlerTestCase(AxesTestCase):
64 def setUp(self):
65 self.sender = MagicMock()
66 self.credentials = MagicMock()
67 self.request = MagicMock()
68 self.user = MagicMock()
69 self.instance = MagicMock()
70
71 @patch("axes.handlers.proxy.AxesProxyHandler.implementation", None)
72 def test_setting_changed_signal_triggers_handler_reimport(self):
73 self.assertIsNone(AxesProxyHandler.implementation)
74
75 with self.settings(AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler"):
76 self.assertIsNotNone(AxesProxyHandler.implementation)
77
78 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
79 def test_user_login_failed(self, handler):
80 self.assertFalse(handler.user_login_failed.called)
81 AxesProxyHandler.user_login_failed(self.sender, self.credentials, self.request)
82 self.assertTrue(handler.user_login_failed.called)
83
84 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
85 def test_user_logged_in(self, handler):
86 self.assertFalse(handler.user_logged_in.called)
87 AxesProxyHandler.user_logged_in(self.sender, self.request, self.user)
88 self.assertTrue(handler.user_logged_in.called)
89
90 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
91 def test_user_logged_out(self, handler):
92 self.assertFalse(handler.user_logged_out.called)
93 AxesProxyHandler.user_logged_out(self.sender, self.request, self.user)
94 self.assertTrue(handler.user_logged_out.called)
95
96 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
97 def test_post_save_access_attempt(self, handler):
98 self.assertFalse(handler.post_save_access_attempt.called)
99 AxesProxyHandler.post_save_access_attempt(self.instance)
100 self.assertTrue(handler.post_save_access_attempt.called)
101
102 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
103 def test_post_delete_access_attempt(self, handler):
104 self.assertFalse(handler.post_delete_access_attempt.called)
105 AxesProxyHandler.post_delete_access_attempt(self.instance)
106 self.assertTrue(handler.post_delete_access_attempt.called)
107
108
109 class AxesHandlerBaseTestCase(AxesTestCase):
110 def check_whitelist(self, log):
111 with override_settings(
112 AXES_NEVER_LOCKOUT_WHITELIST=True, AXES_IP_WHITELIST=[self.ip_address]
113 ):
114 AxesProxyHandler.user_login_failed(
115 sender=None, request=self.request, credentials=self.credentials
116 )
117 client_str = get_client_str(
118 self.username, self.ip_address, self.user_agent, self.path_info
119 )
120 log.info.assert_called_with(
121 "AXES: Login failed from whitelisted client %s.", client_str
122 )
123
124 def check_empty_request(self, log, handler):
125 AxesProxyHandler.user_login_failed(sender=None, credentials={}, request=None)
126 log.error.assert_called_with(
127 f"AXES: {handler}.user_login_failed does not function without a request."
128 )
129
130
131 @override_settings(
132 AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler",
133 AXES_COOLOFF_TIME=timedelta(seconds=1),
134 AXES_RESET_ON_SUCCESS=True,
135 )
136 class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
137 def test_handler_reset_attempts(self):
138 self.create_attempt()
139 self.assertEqual(1, AxesProxyHandler.reset_attempts())
140 self.assertFalse(AccessAttempt.objects.count())
141
142 def test_handler_reset_logs(self):
143 self.create_log()
144 self.assertEqual(1, AxesProxyHandler.reset_logs())
145 self.assertFalse(AccessLog.objects.count())
146
147 def test_handler_reset_logs_older_than_42_days(self):
148 self.create_log()
149
150 then = timezone.now() - timezone.timedelta(days=90)
151 with patch("django.utils.timezone.now", return_value=then):
152 self.create_log()
153
154 self.assertEqual(AccessLog.objects.count(), 2)
155 self.assertEqual(1, AxesProxyHandler.reset_logs(age_days=42))
156 self.assertEqual(AccessLog.objects.count(), 1)
157
158 @override_settings(AXES_RESET_ON_SUCCESS=True)
159 def test_handler(self):
160 self.check_handler()
161
162 @override_settings(AXES_RESET_ON_SUCCESS=False)
163 def test_handler_without_reset(self):
164 self.check_handler()
165
166 @override_settings(AXES_FAILURE_LIMIT=lambda *args: 3)
167 def test_handler_callable_failure_limit(self):
168 self.check_handler()
169
170 @override_settings(AXES_FAILURE_LIMIT="axes.tests.base.custom_failure_limit")
171 def test_handler_str_failure_limit(self):
172 self.check_handler()
173
174 @override_settings(AXES_FAILURE_LIMIT=None)
175 def test_handler_invalid_failure_limit(self):
176 with self.assertRaises(TypeError):
177 self.check_handler()
178
179 @override_settings(AXES_LOCK_OUT_AT_FAILURE=False)
180 def test_handler_without_lockout(self):
181 self.check_handler()
182
183 @patch("axes.handlers.database.log")
184 def test_empty_request(self, log):
185 self.check_empty_request(log, "AxesDatabaseHandler")
186
187 @patch("axes.handlers.database.log")
188 def test_whitelist(self, log):
189 self.check_whitelist(log)
190
191 def test_user_login_failed_multiple_username(self):
192 configurations = (
193 (1, 2, {}, ["admin", "admin1"]),
194 (1, 2, {"AXES_USE_USER_AGENT": True}, ["admin", "admin1"]),
195 (2, 1, {"AXES_ONLY_USER_FAILURES": True}, ["admin", "admin1"]),
196 (
197 2,
198 1,
199 {"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
200 ["admin", "admin1"],
201 ),
202 (
203 1,
204 2,
205 {"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
206 ["admin", "admin"],
207 ),
208 )
209
210 for (
211 total_attempts_count,
212 failures_since_start,
213 overrides,
214 usernames,
215 ) in configurations:
216 with self.settings(**overrides):
217 with self.subTest(
218 total_attempts_count=total_attempts_count,
219 failures_since_start=failures_since_start,
220 settings=overrides,
221 ):
222 self.login(username=usernames[0])
223 attempt = AccessAttempt.objects.get(username=usernames[0])
224 self.assertEqual(1, attempt.failures_since_start)
225
226 # check the number of failures associated to the attempt
227 self.login(username=usernames[1])
228 attempt = AccessAttempt.objects.get(username=usernames[1])
229 self.assertEqual(failures_since_start, attempt.failures_since_start)
230
231 # check the number of distinct attempts
232 self.assertEqual(
233 total_attempts_count, AccessAttempt.objects.count()
234 )
235
236 AccessAttempt.objects.all().delete()
237
238
239 @override_settings(
240 AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
241 AXES_COOLOFF_TIME=timedelta(seconds=1),
242 )
243 class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase):
244 @override_settings(AXES_RESET_ON_SUCCESS=True)
245 def test_handler(self):
246 self.check_handler()
247
248 @override_settings(AXES_RESET_ON_SUCCESS=False)
249 def test_handler_without_reset(self):
250 self.check_handler()
251
252 @override_settings(AXES_LOCK_OUT_AT_FAILURE=False)
253 def test_handler_without_lockout(self):
254 self.check_handler()
255
256 @patch("axes.handlers.cache.log")
257 def test_empty_request(self, log):
258 self.check_empty_request(log, "AxesCacheHandler")
259
260 @patch("axes.handlers.cache.log")
261 def test_whitelist(self, log):
262 self.check_whitelist(log)
263
264
265 @override_settings(AXES_HANDLER="axes.handlers.dummy.AxesDummyHandler")
266 class AxesDummyHandlerTestCase(AxesHandlerBaseTestCase):
267 def test_handler(self):
268 for _ in range(settings.AXES_FAILURE_LIMIT):
269 self.login()
270
271 self.check_login()
272
273 def test_handler_is_allowed(self):
274 self.assertEqual(True, AxesProxyHandler.is_allowed(self.request, {}))
275
276 def test_handler_get_failures(self):
277 self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
278
279
280 @override_settings(AXES_HANDLER="axes.handlers.test.AxesTestHandler")
281 class AxesTestHandlerTestCase(AxesHandlerBaseTestCase):
282 def test_handler_reset_attempts(self):
283 self.assertEqual(0, AxesProxyHandler.reset_attempts())
284
285 def test_handler_reset_logs(self):
286 self.assertEqual(0, AxesProxyHandler.reset_logs())
287
288 def test_handler_is_allowed(self):
289 self.assertEqual(True, AxesProxyHandler.is_allowed(self.request, {}))
290
291 def test_handler_get_failures(self):
292 self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
+0
-93
axes/tests/test_helpers.py less more
0 from datetime import timedelta
1
2 from django.contrib.auth import get_user_model
3 from django.http import HttpRequest, HttpResponse
4 from django.test import override_settings
5
6 from axes.helpers import get_cool_off, get_lockout_response, is_user_attempt_whitelisted
7 from axes.tests.base import AxesTestCase
8
9
10 def mock_get_cool_off_str():
11 return timedelta(seconds=30)
12
13
14 class AxesCoolOffTestCase(AxesTestCase):
15 @override_settings(AXES_COOLOFF_TIME=None)
16 def test_get_cool_off_none(self):
17 self.assertIsNone(get_cool_off())
18
19 @override_settings(AXES_COOLOFF_TIME=2)
20 def test_get_cool_off_int(self):
21 self.assertEqual(get_cool_off(), timedelta(hours=2))
22
23 @override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30))
24 def test_get_cool_off_callable(self):
25 self.assertEqual(get_cool_off(), timedelta(seconds=30))
26
27 @override_settings(
28 AXES_COOLOFF_TIME="axes.tests.test_helpers.mock_get_cool_off_str"
29 )
30 def test_get_cool_off_path(self):
31 self.assertEqual(get_cool_off(), timedelta(seconds=30))
32
33
34 def mock_is_whitelisted(request, credentials):
35 return True
36
37
38 class AxesWhitelistTestCase(AxesTestCase):
39 def setUp(self):
40 self.user_model = get_user_model()
41 self.user = self.user_model.objects.create(username="jane.doe")
42 self.request = HttpRequest()
43 self.credentials = dict()
44
45 def test_is_whitelisted(self):
46 self.assertFalse(is_user_attempt_whitelisted(self.request, self.credentials))
47
48 @override_settings(AXES_WHITELIST_CALLABLE=mock_is_whitelisted)
49 def test_is_whitelisted_override_callable(self):
50 self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials))
51
52 @override_settings(
53 AXES_WHITELIST_CALLABLE="axes.tests.test_helpers.mock_is_whitelisted"
54 )
55 def test_is_whitelisted_override_path(self):
56 self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials))
57
58 @override_settings(AXES_WHITELIST_CALLABLE=42)
59 def test_is_whitelisted_override_invalid(self):
60 with self.assertRaises(TypeError):
61 is_user_attempt_whitelisted(self.request, self.credentials)
62
63
64 def mock_get_lockout_response(request, credentials):
65 return HttpResponse(status=400)
66
67
68 class AxesLockoutTestCase(AxesTestCase):
69 def setUp(self):
70 self.request = HttpRequest()
71 self.credentials = dict()
72
73 def test_get_lockout_response(self):
74 response = get_lockout_response(self.request, self.credentials)
75 self.assertEqual(403, response.status_code)
76
77 @override_settings(AXES_LOCKOUT_CALLABLE=mock_get_lockout_response)
78 def test_get_lockout_response_override_callable(self):
79 response = get_lockout_response(self.request, self.credentials)
80 self.assertEqual(400, response.status_code)
81
82 @override_settings(
83 AXES_LOCKOUT_CALLABLE="axes.tests.test_helpers.mock_get_lockout_response"
84 )
85 def test_get_lockout_response_override_path(self):
86 response = get_lockout_response(self.request, self.credentials)
87 self.assertEqual(400, response.status_code)
88
89 @override_settings(AXES_LOCKOUT_CALLABLE=42)
90 def test_get_lockout_response_override_invalid(self):
91 with self.assertRaises(TypeError):
92 get_lockout_response(self.request, self.credentials)
+0
-116
axes/tests/test_logging.py less more
0 from unittest.mock import patch
1
2 from django.test import override_settings
3 from django.urls import reverse
4
5 from axes.apps import AppConfig
6 from axes.models import AccessAttempt, AccessLog
7 from axes.tests.base import AxesTestCase
8
9
10 @patch("axes.apps.AppConfig.logging_initialized", False)
11 @patch("axes.apps.log")
12 class AppsTestCase(AxesTestCase):
13 def test_axes_config_log_re_entrant(self, log):
14 """
15 Test that initialize call count does not increase on repeat calls.
16 """
17
18 AppConfig.initialize()
19 calls = log.info.call_count
20
21 AppConfig.initialize()
22 self.assertTrue(
23 calls == log.info.call_count and calls > 0,
24 "AxesConfig.initialize needs to be re-entrant",
25 )
26
27 @override_settings(AXES_VERBOSE=False)
28 def test_axes_config_log_not_verbose(self, log):
29 AppConfig.initialize()
30 self.assertFalse(log.info.called)
31
32 @override_settings(AXES_ONLY_USER_FAILURES=True)
33 def test_axes_config_log_user_only(self, log):
34 AppConfig.initialize()
35 log.info.assert_called_with("AXES: blocking by username only.")
36
37 @override_settings(AXES_ONLY_USER_FAILURES=False)
38 def test_axes_config_log_ip_only(self, log):
39 AppConfig.initialize()
40 log.info.assert_called_with("AXES: blocking by IP only.")
41
42 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
43 def test_axes_config_log_user_ip(self, log):
44 AppConfig.initialize()
45 log.info.assert_called_with("AXES: blocking by combination of username and IP.")
46
47
48 class AccessLogTestCase(AxesTestCase):
49 def test_access_log_on_logout(self):
50 """
51 Test a valid logout and make sure the logout_time is updated.
52 """
53
54 self.login(is_valid_username=True, is_valid_password=True)
55 self.assertIsNone(AccessLog.objects.latest("id").logout_time)
56
57 response = self.client.get(reverse("admin:logout"))
58 self.assertContains(response, "Logged out")
59
60 self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
61
62 def test_log_data_truncated(self):
63 """
64 Test that get_query_str properly truncates data to the max_length (default 1024).
65 """
66
67 # An impossibly large post dict
68 extra_data = {"a" * x: x for x in range(1024)}
69 self.login(**extra_data)
70 self.assertEqual(len(AccessAttempt.objects.latest("id").post_data), 1024)
71
72 @override_settings(AXES_DISABLE_ACCESS_LOG=True)
73 def test_valid_logout_without_success_log(self):
74 AccessLog.objects.all().delete()
75
76 response = self.login(is_valid_username=True, is_valid_password=True)
77 response = self.client.get(reverse("admin:logout"))
78
79 self.assertEqual(AccessLog.objects.all().count(), 0)
80 self.assertContains(response, "Logged out", html=True)
81
82 @override_settings(AXES_DISABLE_ACCESS_LOG=True)
83 def test_valid_login_without_success_log(self):
84 """
85 Test that a valid login does not generate an AccessLog when DISABLE_SUCCESS_ACCESS_LOG is True.
86 """
87
88 AccessLog.objects.all().delete()
89
90 response = self.login(is_valid_username=True, is_valid_password=True)
91
92 self.assertEqual(response.status_code, 302)
93 self.assertEqual(AccessLog.objects.all().count(), 0)
94
95 @override_settings(AXES_DISABLE_ACCESS_LOG=True)
96 def test_valid_logout_without_log(self):
97 AccessLog.objects.all().delete()
98
99 response = self.login(is_valid_username=True, is_valid_password=True)
100 response = self.client.get(reverse("admin:logout"))
101
102 self.assertEqual(AccessLog.objects.count(), 0)
103 self.assertContains(response, "Logged out", html=True)
104
105 @override_settings(AXES_DISABLE_ACCESS_LOG=True)
106 def test_non_valid_login_without_log(self):
107 """
108 Test that a non-valid login does generate an AccessLog when DISABLE_ACCESS_LOG is True.
109 """
110 AccessLog.objects.all().delete()
111
112 response = self.login(is_valid_username=True, is_valid_password=False)
113 self.assertEqual(response.status_code, 200)
114
115 self.assertEqual(AccessLog.objects.all().count(), 0)
+0
-491
axes/tests/test_login.py less more
0 """
1 Integration tests for the login handling.
2
3 TODO: Clean up the tests in this module.
4 """
5
6 from importlib import import_module
7
8 from django.http import HttpRequest
9 from django.test import override_settings, TestCase
10 from django.urls import reverse
11 from django.contrib.auth import get_user_model, login, logout
12
13 from axes.conf import settings
14 from axes.models import AccessAttempt
15 from axes.tests.base import AxesTestCase
16
17
18 class DjangoLoginTestCase(TestCase):
19 def setUp(self):
20 engine = import_module(settings.SESSION_ENGINE)
21
22 self.request = HttpRequest()
23 self.request.session = engine.SessionStore()
24
25 self.username = "john.doe"
26 self.password = "hunter2"
27
28 self.user = get_user_model().objects.create(username=self.username)
29 self.user.set_password(self.password)
30 self.user.save()
31 self.user.backend = "django.contrib.auth.backends.ModelBackend"
32
33
34 class DjangoContribAuthLoginTestCase(DjangoLoginTestCase):
35 def test_login(self):
36 login(self.request, self.user)
37
38 def test_logout(self):
39 login(self.request, self.user)
40 logout(self.request)
41
42
43 @override_settings(AXES_ENABLED=False)
44 class DjangoTestClientLoginTestCase(DjangoLoginTestCase):
45 def test_client_login(self):
46 self.client.login(username=self.username, password=self.password)
47
48 def test_client_logout(self):
49 self.client.login(username=self.username, password=self.password)
50 self.client.logout()
51
52 def test_client_force_login(self):
53 self.client.force_login(self.user)
54
55
56 class LoginTestCase(AxesTestCase):
57 """
58 Test for lockouts under different configurations and circumstances to prevent false positives and false negatives.
59
60 Always block attempted logins for the same user from the same IP.
61 Always allow attempted logins for a different user from a different IP.
62 """
63
64 IP_1 = "10.1.1.1"
65 IP_2 = "10.2.2.2"
66 USER_1 = "valid-user-1"
67 USER_2 = "valid-user-2"
68 EMAIL_1 = "valid-email-1@example.com"
69 EMAIL_2 = "valid-email-2@example.com"
70
71 VALID_USERNAME = USER_1
72 VALID_EMAIL = EMAIL_1
73 VALID_PASSWORD = "valid-password"
74
75 VALID_IP_ADDRESS = IP_1
76
77 WRONG_PASSWORD = "wrong-password"
78 LOCKED_MESSAGE = "Account locked: too many login attempts."
79 LOGIN_FORM_KEY = '<input type="submit" value="Log in" />'
80 ALLOWED = 302
81 BLOCKED = 403
82
83 def _login(self, username, password, ip_addr="127.0.0.1", **kwargs):
84 """
85 Login a user and get the response.
86
87 IP address can be configured to test IP blocking functionality.
88 """
89
90 post_data = {"username": username, "password": password}
91
92 post_data.update(kwargs)
93
94 return self.client.post(
95 reverse("admin:login"),
96 post_data,
97 REMOTE_ADDR=ip_addr,
98 HTTP_USER_AGENT="test-browser",
99 )
100
101 def _lockout_user_from_ip(self, username, ip_addr):
102 for _ in range(settings.AXES_FAILURE_LIMIT):
103 response = self._login(
104 username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr
105 )
106 return response
107
108 def _lockout_user1_from_ip1(self):
109 return self._lockout_user_from_ip(username=self.USER_1, ip_addr=self.IP_1)
110
111 def setUp(self):
112 """
113 Create two valid users for authentication.
114 """
115
116 super().setUp()
117
118 self.user2 = get_user_model().objects.create_superuser(
119 username=self.USER_2,
120 email=self.EMAIL_2,
121 password=self.VALID_PASSWORD,
122 is_staff=True,
123 is_superuser=True,
124 )
125
126 def test_login(self):
127 """
128 Test a valid login for a real username.
129 """
130
131 response = self._login(self.username, self.password)
132 self.assertNotContains(
133 response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
134 )
135
136 def test_lockout_limit_once(self):
137 """
138 Test the login lock trying to login one more time than failure limit.
139 """
140
141 response = self.lockout()
142 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
143
144 def test_lockout_limit_many(self):
145 """
146 Test the login lock trying to login a lot of times more than failure limit.
147 """
148
149 self.lockout()
150
151 for _ in range(settings.AXES_FAILURE_LIMIT):
152 response = self.login()
153 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
154
155 @override_settings(AXES_RESET_ON_SUCCESS=False)
156 def test_reset_on_success_false(self):
157 self.almost_lockout()
158 self.login(is_valid_username=True, is_valid_password=True)
159
160 response = self.login()
161 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
162 self.assertTrue(AccessAttempt.objects.count())
163
164 @override_settings(AXES_RESET_ON_SUCCESS=True)
165 def test_reset_on_success_true(self):
166 self.almost_lockout()
167 self.assertTrue(AccessAttempt.objects.count())
168
169 self.login(is_valid_username=True, is_valid_password=True)
170 self.assertFalse(AccessAttempt.objects.count())
171
172 response = self.lockout()
173 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
174 self.assertTrue(AccessAttempt.objects.count())
175
176 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
177 def test_lockout_by_combination_user_and_ip(self):
178 """
179 Test login failure when AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True.
180 """
181
182 # test until one try before the limit
183 for _ in range(1, settings.AXES_FAILURE_LIMIT):
184 response = self.login(is_valid_username=True, is_valid_password=False)
185 # Check if we are in the same login page
186 self.assertContains(response, self.LOGIN_FORM_KEY, html=True)
187
188 # So, we shouldn't have gotten a lock-out yet.
189 # But we should get one now
190 response = self.login(is_valid_username=True, is_valid_password=False)
191 self.assertContains(response, self.LOCKED_MESSAGE, status_code=403)
192
193 @override_settings(AXES_ONLY_USER_FAILURES=True)
194 def test_lockout_by_only_user_failures(self):
195 """
196 Test login failure when AXES_ONLY_USER_FAILURES is True.
197 """
198
199 # test until one try before the limit
200 for _ in range(1, settings.AXES_FAILURE_LIMIT):
201 response = self._login(self.username, self.WRONG_PASSWORD)
202
203 # Check if we are in the same login page
204 self.assertContains(response, self.LOGIN_FORM_KEY, html=True)
205
206 # So, we shouldn't have gotten a lock-out yet.
207 # But we should get one now
208 response = self._login(self.username, self.WRONG_PASSWORD)
209 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
210
211 # reset the username only and make sure we can log in now even though our IP has failed each time
212 self.reset(username=self.username)
213
214 response = self._login(self.username, self.password)
215
216 # Check if we are still in the login page
217 self.assertNotContains(
218 response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
219 )
220
221 # now create failure_limit + 1 failed logins and then we should still
222 # be able to login with valid_username
223 for _ in range(settings.AXES_FAILURE_LIMIT):
224 response = self._login(self.username, self.password)
225
226 # Check if we can still log in with valid user
227 response = self._login(self.username, self.password)
228 self.assertNotContains(
229 response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
230 )
231
232 # Test for true and false positives when blocking by IP *OR* user (default)
233 # Cache disabled. Default settings.
234 def test_lockout_by_ip_blocks_when_same_user_same_ip_without_cache(self):
235 # User 1 is locked out from IP 1.
236 self._lockout_user1_from_ip1()
237
238 # User 1 is still blocked from IP 1.
239 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
240 self.assertEqual(response.status_code, self.BLOCKED)
241
242 def test_lockout_by_ip_allows_when_same_user_diff_ip_without_cache(self):
243 # User 1 is locked out from IP 1.
244 self._lockout_user1_from_ip1()
245
246 # User 1 can still login from IP 2.
247 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
248 self.assertEqual(response.status_code, self.ALLOWED)
249
250 def test_lockout_by_ip_blocks_when_diff_user_same_ip_without_cache(self):
251 # User 1 is locked out from IP 1.
252 self._lockout_user1_from_ip1()
253
254 # User 2 is also locked out from IP 1.
255 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
256 self.assertEqual(response.status_code, self.BLOCKED)
257
258 def test_lockout_by_ip_allows_when_diff_user_diff_ip_without_cache(self):
259 # User 1 is locked out from IP 1.
260 self._lockout_user1_from_ip1()
261
262 # User 2 can still login from IP 2.
263 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
264 self.assertEqual(response.status_code, self.ALLOWED)
265
266 # Test for true and false positives when blocking by user only.
267 # Cache disabled. When AXES_ONLY_USER_FAILURES = True
268 @override_settings(AXES_ONLY_USER_FAILURES=True)
269 def test_lockout_by_user_blocks_when_same_user_same_ip_without_cache(self):
270 # User 1 is locked out from IP 1.
271 self._lockout_user1_from_ip1()
272
273 # User 1 is still blocked from IP 1.
274 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
275 self.assertEqual(response.status_code, self.BLOCKED)
276
277 @override_settings(AXES_ONLY_USER_FAILURES=True)
278 def test_lockout_by_user_blocks_when_same_user_diff_ip_without_cache(self):
279 # User 1 is locked out from IP 1.
280 self._lockout_user1_from_ip1()
281
282 # User 1 is also locked out from IP 2.
283 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
284 self.assertEqual(response.status_code, self.BLOCKED)
285
286 @override_settings(AXES_ONLY_USER_FAILURES=True)
287 def test_lockout_by_user_allows_when_diff_user_same_ip_without_cache(self):
288 # User 1 is locked out from IP 1.
289 self._lockout_user1_from_ip1()
290
291 # User 2 can still login from IP 1.
292 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
293 self.assertEqual(response.status_code, self.ALLOWED)
294
295 @override_settings(AXES_ONLY_USER_FAILURES=True)
296 def test_lockout_by_user_allows_when_diff_user_diff_ip_without_cache(self):
297 # User 1 is locked out from IP 1.
298 self._lockout_user1_from_ip1()
299
300 # User 2 can still login from IP 2.
301 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
302 self.assertEqual(response.status_code, self.ALLOWED)
303
304 @override_settings(AXES_ONLY_USER_FAILURES=True)
305 def test_lockout_by_user_with_empty_username_allows_other_users_without_cache(self):
306 # User with empty username is locked out from IP 1.
307 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
308
309 # Still possible to access the login page
310 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
311 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
312
313 # Test for true and false positives when blocking by user and IP together.
314 # Cache disabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
315 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
316 def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_without_cache(self):
317 # User 1 is locked out from IP 1.
318 self._lockout_user1_from_ip1()
319
320 # User 1 is still blocked from IP 1.
321 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
322 self.assertEqual(response.status_code, self.BLOCKED)
323
324 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
325 def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_without_cache(self):
326 # User 1 is locked out from IP 1.
327 self._lockout_user1_from_ip1()
328
329 # User 1 can still login from IP 2.
330 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
331 self.assertEqual(response.status_code, self.ALLOWED)
332
333 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
334 def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_without_cache(self):
335 # User 1 is locked out from IP 1.
336 self._lockout_user1_from_ip1()
337
338 # User 2 can still login from IP 1.
339 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
340 self.assertEqual(response.status_code, self.ALLOWED)
341
342 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
343 def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_without_cache(self):
344 # User 1 is locked out from IP 1.
345 self._lockout_user1_from_ip1()
346
347 # User 2 can still login from IP 2.
348 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
349 self.assertEqual(response.status_code, self.ALLOWED)
350
351 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
352 def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_without_cache(
353 self,
354 ):
355 # User with empty username is locked out from IP 1.
356 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
357
358 # Still possible to access the login page
359 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
360 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
361
362 # Test for true and false positives when blocking by IP *OR* user (default)
363 # With cache enabled. Default criteria.
364 def test_lockout_by_ip_blocks_when_same_user_same_ip_using_cache(self):
365 # User 1 is locked out from IP 1.
366 self._lockout_user1_from_ip1()
367
368 # User 1 is still blocked from IP 1.
369 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
370 self.assertEqual(response.status_code, self.BLOCKED)
371
372 def test_lockout_by_ip_allows_when_same_user_diff_ip_using_cache(self):
373 # User 1 is locked out from IP 1.
374 self._lockout_user1_from_ip1()
375
376 # User 1 can still login from IP 2.
377 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
378 self.assertEqual(response.status_code, self.ALLOWED)
379
380 def test_lockout_by_ip_blocks_when_diff_user_same_ip_using_cache(self):
381 # User 1 is locked out from IP 1.
382 self._lockout_user1_from_ip1()
383
384 # User 2 is also locked out from IP 1.
385 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
386 self.assertEqual(response.status_code, self.BLOCKED)
387
388 def test_lockout_by_ip_allows_when_diff_user_diff_ip_using_cache(self):
389 # User 1 is locked out from IP 1.
390 self._lockout_user1_from_ip1()
391
392 # User 2 can still login from IP 2.
393 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
394 self.assertEqual(response.status_code, self.ALLOWED)
395
396 @override_settings(AXES_ONLY_USER_FAILURES=True)
397 def test_lockout_by_user_with_empty_username_allows_other_users_using_cache(self):
398 # User with empty username is locked out from IP 1.
399 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
400
401 # Still possible to access the login page
402 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
403 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
404
405 # Test for true and false positives when blocking by user only.
406 # With cache enabled. When AXES_ONLY_USER_FAILURES = True
407 @override_settings(AXES_ONLY_USER_FAILURES=True)
408 def test_lockout_by_user_blocks_when_same_user_same_ip_using_cache(self):
409 # User 1 is locked out from IP 1.
410 self._lockout_user1_from_ip1()
411
412 # User 1 is still blocked from IP 1.
413 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
414 self.assertEqual(response.status_code, self.BLOCKED)
415
416 @override_settings(AXES_ONLY_USER_FAILURES=True)
417 def test_lockout_by_user_blocks_when_same_user_diff_ip_using_cache(self):
418 # User 1 is locked out from IP 1.
419 self._lockout_user1_from_ip1()
420
421 # User 1 is also locked out from IP 2.
422 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
423 self.assertEqual(response.status_code, self.BLOCKED)
424
425 @override_settings(AXES_ONLY_USER_FAILURES=True)
426 def test_lockout_by_user_allows_when_diff_user_same_ip_using_cache(self):
427 # User 1 is locked out from IP 1.
428 self._lockout_user1_from_ip1()
429
430 # User 2 can still login from IP 1.
431 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
432 self.assertEqual(response.status_code, self.ALLOWED)
433
434 @override_settings(AXES_ONLY_USER_FAILURES=True)
435 def test_lockout_by_user_allows_when_diff_user_diff_ip_using_cache(self):
436 # User 1 is locked out from IP 1.
437 self._lockout_user1_from_ip1()
438
439 # User 2 can still login from IP 2.
440 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
441 self.assertEqual(response.status_code, self.ALLOWED)
442
443 # Test for true and false positives when blocking by user and IP together.
444 # With cache enabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
445 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
446 def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_using_cache(self):
447 # User 1 is locked out from IP 1.
448 self._lockout_user1_from_ip1()
449
450 # User 1 is still blocked from IP 1.
451 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
452 self.assertEqual(response.status_code, self.BLOCKED)
453
454 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
455 def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_using_cache(self):
456 # User 1 is locked out from IP 1.
457 self._lockout_user1_from_ip1()
458
459 # User 1 can still login from IP 2.
460 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
461 self.assertEqual(response.status_code, self.ALLOWED)
462
463 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
464 def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache(self):
465 # User 1 is locked out from IP 1.
466 self._lockout_user1_from_ip1()
467
468 # User 2 can still login from IP 1.
469 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
470 self.assertEqual(response.status_code, self.ALLOWED)
471
472 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
473 def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_using_cache(self):
474 # User 1 is locked out from IP 1.
475 self._lockout_user1_from_ip1()
476
477 # User 2 can still login from IP 2.
478 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
479 self.assertEqual(response.status_code, self.ALLOWED)
480
481 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
482 def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_using_cache(
483 self,
484 ):
485 # User with empty username is locked out from IP 1.
486 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
487
488 # Still possible to access the login page
489 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
490 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
+0
-109
axes/tests/test_management.py less more
0 from io import StringIO
1 from unittest.mock import patch, Mock
2
3 from django.core.management import call_command
4 from django.utils import timezone
5
6 from axes.models import AccessAttempt, AccessLog
7 from axes.tests.base import AxesTestCase
8
9
10 class ResetAccessLogsManagementCommandTestCase(AxesTestCase):
11 def setUp(self):
12 self.msg_not_found = "No logs found.\n"
13 self.msg_num_found = "{} logs removed.\n"
14
15 days_3 = timezone.now() - timezone.timedelta(days=3)
16 with patch("django.utils.timezone.now", Mock(return_value=days_3)):
17 AccessLog.objects.create()
18
19 days_13 = timezone.now() - timezone.timedelta(days=9)
20 with patch("django.utils.timezone.now", Mock(return_value=days_13)):
21 AccessLog.objects.create()
22
23 days_30 = timezone.now() - timezone.timedelta(days=27)
24 with patch("django.utils.timezone.now", Mock(return_value=days_30)):
25 AccessLog.objects.create()
26
27 def test_axes_delete_access_logs_default(self):
28 out = StringIO()
29 call_command("axes_reset_logs", stdout=out)
30 self.assertEqual(self.msg_not_found, out.getvalue())
31
32 def test_axes_delete_access_logs_older_than_2_days(self):
33 out = StringIO()
34 call_command("axes_reset_logs", age=2, stdout=out)
35 self.assertEqual(self.msg_num_found.format(3), out.getvalue())
36
37 def test_axes_delete_access_logs_older_than_4_days(self):
38 out = StringIO()
39 call_command("axes_reset_logs", age=4, stdout=out)
40 self.assertEqual(self.msg_num_found.format(2), out.getvalue())
41
42 def test_axes_delete_access_logs_older_than_16_days(self):
43 out = StringIO()
44 call_command("axes_reset_logs", age=16, stdout=out)
45 self.assertEqual(self.msg_num_found.format(1), out.getvalue())
46
47
48 class ManagementCommandTestCase(AxesTestCase):
49 def setUp(self):
50 AccessAttempt.objects.create(
51 username="jane.doe", ip_address="10.0.0.1", failures_since_start="4"
52 )
53
54 AccessAttempt.objects.create(
55 username="john.doe", ip_address="10.0.0.2", failures_since_start="15"
56 )
57
58 def test_axes_list_attempts(self):
59 out = StringIO()
60 call_command("axes_list_attempts", stdout=out)
61
62 expected = "10.0.0.1\tjane.doe\t4\n10.0.0.2\tjohn.doe\t15\n"
63 self.assertEqual(expected, out.getvalue())
64
65 def test_axes_reset(self):
66 out = StringIO()
67 call_command("axes_reset", stdout=out)
68
69 expected = "2 attempts removed.\n"
70 self.assertEqual(expected, out.getvalue())
71
72 def test_axes_reset_not_found(self):
73 out = StringIO()
74 call_command("axes_reset", stdout=out)
75
76 out = StringIO()
77 call_command("axes_reset", stdout=out)
78
79 expected = "No attempts found.\n"
80 self.assertEqual(expected, out.getvalue())
81
82 def test_axes_reset_ip(self):
83 out = StringIO()
84 call_command("axes_reset_ip", "10.0.0.1", stdout=out)
85
86 expected = "1 attempts removed.\n"
87 self.assertEqual(expected, out.getvalue())
88
89 def test_axes_reset_ip_not_found(self):
90 out = StringIO()
91 call_command("axes_reset_ip", "10.0.0.3", stdout=out)
92
93 expected = "No attempts found.\n"
94 self.assertEqual(expected, out.getvalue())
95
96 def test_axes_reset_username(self):
97 out = StringIO()
98 call_command("axes_reset_username", "john.doe", stdout=out)
99
100 expected = "1 attempts removed.\n"
101 self.assertEqual(expected, out.getvalue())
102
103 def test_axes_reset_username_not_found(self):
104 out = StringIO()
105 call_command("axes_reset_username", "ivan.renko", stdout=out)
106
107 expected = "No attempts found.\n"
108 self.assertEqual(expected, out.getvalue())
+0
-28
axes/tests/test_middleware.py less more
0 from django.http import HttpResponse, HttpRequest
1
2 from axes.middleware import AxesMiddleware
3 from axes.tests.base import AxesTestCase
4
5
6 class MiddlewareTestCase(AxesTestCase):
7 STATUS_SUCCESS = 200
8 STATUS_LOCKOUT = 403
9
10 def setUp(self):
11 self.request = HttpRequest()
12
13 def test_success_response(self):
14 def get_response(request):
15 request.axes_locked_out = False
16 return HttpResponse()
17
18 response = AxesMiddleware(get_response)(self.request)
19 self.assertEqual(response.status_code, self.STATUS_SUCCESS)
20
21 def test_lockout_response(self):
22 def get_response(request):
23 request.axes_locked_out = True
24 return HttpResponse()
25
26 response = AxesMiddleware(get_response)(self.request)
27 self.assertEqual(response.status_code, self.STATUS_LOCKOUT)
+0
-36
axes/tests/test_models.py less more
0 from django.apps.registry import apps
1 from django.db import connection
2 from django.db.migrations.autodetector import MigrationAutodetector
3 from django.db.migrations.executor import MigrationExecutor
4 from django.db.migrations.state import ProjectState
5
6 from axes.models import AccessAttempt, AccessLog
7 from axes.tests.base import AxesTestCase
8
9
10 class ModelsTestCase(AxesTestCase):
11 def setUp(self):
12 self.failures_since_start = 42
13
14 self.access_attempt = AccessAttempt(
15 failures_since_start=self.failures_since_start
16 )
17 self.access_log = AccessLog()
18
19 def test_access_attempt_str(self):
20 self.assertIn("Access", str(self.access_attempt))
21
22 def test_access_log_str(self):
23 self.assertIn("Access", str(self.access_log))
24
25
26 class MigrationsTestCase(AxesTestCase):
27 def test_missing_migrations(self):
28 executor = MigrationExecutor(connection)
29 autodetector = MigrationAutodetector(
30 executor.loader.project_state(), ProjectState.from_apps(apps)
31 )
32
33 changes = autodetector.changes(graph=executor.loader.graph)
34
35 self.assertEqual({}, changes)
+0
-18
axes/tests/test_signals.py less more
0 from unittest.mock import MagicMock
1
2 from axes.tests.base import AxesTestCase
3 from axes.signals import user_locked_out
4
5
6 class SignalTestCase(AxesTestCase):
7 def test_send_lockout_signal(self):
8 """
9 Test if the lockout signal is correctly emitted when user is locked out.
10 """
11
12 handler = MagicMock()
13 user_locked_out.connect(handler)
14
15 self.assertEqual(0, handler.call_count)
16 self.lockout()
17 self.assertEqual(1, handler.call_count)
+0
-599
axes/tests/test_utils.py less more
0 from datetime import timedelta
1 from hashlib import md5
2 from unittest.mock import patch
3
4 from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest
5 from django.test import override_settings, RequestFactory
6
7 from axes.apps import AppConfig
8 from axes.models import AccessAttempt
9 from axes.tests.base import AxesTestCase
10 from axes.helpers import (
11 get_cache_timeout,
12 get_client_str,
13 get_client_username,
14 get_client_cache_key,
15 get_client_parameters,
16 get_cool_off_iso8601,
17 get_lockout_response,
18 is_client_ip_address_blacklisted,
19 is_client_ip_address_whitelisted,
20 is_ip_address_in_blacklist,
21 is_ip_address_in_whitelist,
22 is_client_method_whitelisted,
23 toggleable,
24 )
25
26
27 @override_settings(AXES_ENABLED=False)
28 class AxesDisabledTestCase(AxesTestCase):
29 def test_initialize(self):
30 AppConfig.logging_initialized = False
31 AppConfig.initialize()
32 self.assertFalse(AppConfig.logging_initialized)
33
34 def test_toggleable(self):
35 def is_true():
36 return True
37
38 self.assertTrue(is_true())
39 self.assertIsNone(toggleable(is_true)())
40
41
42 class CacheTestCase(AxesTestCase):
43 @override_settings(AXES_COOLOFF_TIME=3) # hours
44 def test_get_cache_timeout_integer(self):
45 timeout_seconds = float(60 * 60 * 3)
46 self.assertEqual(get_cache_timeout(), timeout_seconds)
47
48 @override_settings(AXES_COOLOFF_TIME=timedelta(seconds=420))
49 def test_get_cache_timeout_timedelta(self):
50 self.assertEqual(get_cache_timeout(), 420)
51
52 @override_settings(AXES_COOLOFF_TIME=None)
53 def test_get_cache_timeout_none(self):
54 self.assertEqual(get_cache_timeout(), None)
55
56
57 class TimestampTestCase(AxesTestCase):
58 def test_iso8601(self):
59 """
60 Test get_cool_off_iso8601 correctly translates datetime.timedelta to ISO 8601 formatted duration.
61 """
62
63 expected = {
64 timedelta(days=1, hours=25, minutes=42, seconds=8): "P2DT1H42M8S",
65 timedelta(days=7, seconds=342): "P7DT5M42S",
66 timedelta(days=0, hours=2, minutes=42): "PT2H42M",
67 timedelta(hours=20, seconds=42): "PT20H42S",
68 timedelta(seconds=300): "PT5M",
69 timedelta(seconds=9005): "PT2H30M5S",
70 timedelta(minutes=9005): "P6DT6H5M",
71 timedelta(days=15): "P15D",
72 }
73
74 for delta, iso_duration in expected.items():
75 with self.subTest(iso_duration):
76 self.assertEqual(get_cool_off_iso8601(delta), iso_duration)
77
78
79 class ClientStringTestCase(AxesTestCase):
80 @staticmethod
81 def get_expected_client_str(*args, **kwargs):
82 client_str_template = '{{username: "{0}", ip_address: "{1}", user_agent: "{2}", path_info: "{3}"}}'
83 return client_str_template.format(*args, **kwargs)
84
85 @override_settings(AXES_VERBOSE=True)
86 def test_verbose_ip_only_client_details(self):
87 username = "test@example.com"
88 ip_address = "127.0.0.1"
89 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
90 path_info = "/admin/"
91
92 expected = self.get_expected_client_str(
93 username, ip_address, user_agent, path_info
94 )
95 actual = get_client_str(username, ip_address, user_agent, path_info)
96
97 self.assertEqual(expected, actual)
98
99 @override_settings(AXES_VERBOSE=True)
100 def test_imbalanced_quotes(self):
101 username = "butterfly.. },,,"
102 ip_address = "127.0.0.1"
103 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
104 path_info = "/admin/"
105
106 expected = self.get_expected_client_str(
107 username, ip_address, user_agent, path_info
108 )
109 actual = get_client_str(username, ip_address, user_agent, path_info)
110
111 self.assertEqual(expected, actual)
112
113 @override_settings(AXES_VERBOSE=True)
114 def test_verbose_ip_only_client_details_tuple(self):
115 username = "test@example.com"
116 ip_address = "127.0.0.1"
117 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
118 path_info = ("admin", "login")
119
120 expected = self.get_expected_client_str(
121 username, ip_address, user_agent, path_info[0]
122 )
123 actual = get_client_str(username, ip_address, user_agent, path_info)
124
125 self.assertEqual(expected, actual)
126
127 @override_settings(AXES_VERBOSE=False)
128 def test_non_verbose_ip_only_client_details(self):
129 username = "test@example.com"
130 ip_address = "127.0.0.1"
131 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
132 path_info = "/admin/"
133
134 expected = '{ip_address: "127.0.0.1", path_info: "/admin/"}'
135 actual = get_client_str(username, ip_address, user_agent, path_info)
136
137 self.assertEqual(expected, actual)
138
139 @override_settings(AXES_ONLY_USER_FAILURES=True)
140 @override_settings(AXES_VERBOSE=True)
141 def test_verbose_user_only_client_details(self):
142 username = "test@example.com"
143 ip_address = "127.0.0.1"
144 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
145 path_info = "/admin/"
146
147 expected = self.get_expected_client_str(
148 username, ip_address, user_agent, path_info
149 )
150 actual = get_client_str(username, ip_address, user_agent, path_info)
151
152 self.assertEqual(expected, actual)
153
154 @override_settings(AXES_ONLY_USER_FAILURES=True)
155 @override_settings(AXES_VERBOSE=False)
156 def test_non_verbose_user_only_client_details(self):
157 username = "test@example.com"
158 ip_address = "127.0.0.1"
159 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
160 path_info = "/admin/"
161
162 expected = '{username: "test@example.com", path_info: "/admin/"}'
163 actual = get_client_str(username, ip_address, user_agent, path_info)
164
165 self.assertEqual(expected, actual)
166
167 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
168 @override_settings(AXES_VERBOSE=True)
169 def test_verbose_user_ip_combo_client_details(self):
170 username = "test@example.com"
171 ip_address = "127.0.0.1"
172 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
173 path_info = "/admin/"
174
175 expected = self.get_expected_client_str(
176 username, ip_address, user_agent, path_info
177 )
178 actual = get_client_str(username, ip_address, user_agent, path_info)
179
180 self.assertEqual(expected, actual)
181
182 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
183 @override_settings(AXES_VERBOSE=False)
184 def test_non_verbose_user_ip_combo_client_details(self):
185 username = "test@example.com"
186 ip_address = "127.0.0.1"
187 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
188 path_info = "/admin/"
189
190 expected = '{username: "test@example.com", ip_address: "127.0.0.1", path_info: "/admin/"}'
191 actual = get_client_str(username, ip_address, user_agent, path_info)
192
193 self.assertEqual(expected, actual)
194
195 @override_settings(AXES_USE_USER_AGENT=True)
196 @override_settings(AXES_VERBOSE=True)
197 def test_verbose_user_agent_client_details(self):
198 username = "test@example.com"
199 ip_address = "127.0.0.1"
200 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
201 path_info = "/admin/"
202
203 expected = self.get_expected_client_str(
204 username, ip_address, user_agent, path_info
205 )
206 actual = get_client_str(username, ip_address, user_agent, path_info)
207
208 self.assertEqual(expected, actual)
209
210 @override_settings(AXES_USE_USER_AGENT=True)
211 @override_settings(AXES_VERBOSE=False)
212 def test_non_verbose_user_agent_client_details(self):
213 username = "test@example.com"
214 ip_address = "127.0.0.1"
215 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
216 path_info = "/admin/"
217
218 expected = '{ip_address: "127.0.0.1", user_agent: "Googlebot/2.1 (+http://www.googlebot.com/bot.html)", path_info: "/admin/"}'
219 actual = get_client_str(username, ip_address, user_agent, path_info)
220
221 self.assertEqual(expected, actual)
222
223
224 class ClientParametersTestCase(AxesTestCase):
225 @override_settings(AXES_ONLY_USER_FAILURES=True)
226 def test_get_filter_kwargs_user(self):
227 self.assertEqual(
228 dict(
229 get_client_parameters(self.username, self.ip_address, self.user_agent)
230 ),
231 {"username": self.username},
232 )
233
234 @override_settings(
235 AXES_ONLY_USER_FAILURES=False,
236 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
237 AXES_USE_USER_AGENT=False,
238 )
239 def test_get_filter_kwargs_ip(self):
240 self.assertEqual(
241 dict(
242 get_client_parameters(self.username, self.ip_address, self.user_agent)
243 ),
244 {"ip_address": self.ip_address},
245 )
246
247 @override_settings(
248 AXES_ONLY_USER_FAILURES=False,
249 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
250 AXES_USE_USER_AGENT=False,
251 )
252 def test_get_filter_kwargs_user_and_ip(self):
253 self.assertEqual(
254 dict(
255 get_client_parameters(self.username, self.ip_address, self.user_agent)
256 ),
257 {"username": self.username, "ip_address": self.ip_address},
258 )
259
260 @override_settings(
261 AXES_ONLY_USER_FAILURES=False,
262 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
263 AXES_USE_USER_AGENT=True,
264 )
265 def test_get_filter_kwargs_ip_and_agent(self):
266 self.assertEqual(
267 dict(
268 get_client_parameters(self.username, self.ip_address, self.user_agent)
269 ),
270 {"ip_address": self.ip_address, "user_agent": self.user_agent},
271 )
272
273 @override_settings(
274 AXES_ONLY_USER_FAILURES=False,
275 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
276 AXES_USE_USER_AGENT=True,
277 )
278 def test_get_filter_kwargs_user_ip_agent(self):
279 self.assertEqual(
280 dict(
281 get_client_parameters(self.username, self.ip_address, self.user_agent)
282 ),
283 {
284 "username": self.username,
285 "ip_address": self.ip_address,
286 "user_agent": self.user_agent,
287 },
288 )
289
290
291 class ClientCacheKeyTestCase(AxesTestCase):
292 def test_get_cache_key(self):
293 """
294 Test the cache key format.
295 """
296
297 cache_hash_digest = md5(self.ip_address.encode()).hexdigest()
298 cache_hash_key = f"axes-{cache_hash_digest}"
299
300 # Getting cache key from request
301 request_factory = RequestFactory()
302 request = request_factory.post(
303 "/admin/login/", data={"username": self.username, "password": "test"}
304 )
305
306 self.assertEqual(cache_hash_key, get_client_cache_key(request))
307
308 # Getting cache key from AccessAttempt Object
309 attempt = AccessAttempt(
310 user_agent="<unknown>",
311 ip_address=self.ip_address,
312 username=self.username,
313 get_data="",
314 post_data="",
315 http_accept=request.META.get("HTTP_ACCEPT", "<unknown>"),
316 path_info=request.META.get("PATH_INFO", "<unknown>"),
317 failures_since_start=0,
318 )
319
320 self.assertEqual(cache_hash_key, get_client_cache_key(attempt))
321
322 def test_get_cache_key_empty_ip_address(self):
323 """
324 Simulate an empty IP address in the request.
325 """
326
327 empty_ip_address = ""
328
329 cache_hash_digest = md5(empty_ip_address.encode()).hexdigest()
330 cache_hash_key = f"axes-{cache_hash_digest}"
331
332 # Getting cache key from request
333 request_factory = RequestFactory()
334 request = request_factory.post(
335 "/admin/login/",
336 data={"username": self.username, "password": "test"},
337 REMOTE_ADDR=empty_ip_address,
338 )
339
340 self.assertEqual(cache_hash_key, get_client_cache_key(request))
341
342 # Getting cache key from AccessAttempt Object
343 attempt = AccessAttempt(
344 user_agent="<unknown>",
345 ip_address=empty_ip_address,
346 username=self.username,
347 get_data="",
348 post_data="",
349 http_accept=request.META.get("HTTP_ACCEPT", "<unknown>"),
350 path_info=request.META.get("PATH_INFO", "<unknown>"),
351 failures_since_start=0,
352 )
353
354 self.assertEqual(cache_hash_key, get_client_cache_key(attempt))
355
356 def test_get_cache_key_credentials(self):
357 """
358 Test the cache key format.
359 """
360
361 ip_address = self.ip_address
362 cache_hash_digest = md5(ip_address.encode()).hexdigest()
363 cache_hash_key = f"axes-{cache_hash_digest}"
364
365 # Getting cache key from request
366 request_factory = RequestFactory()
367 request = request_factory.post(
368 "/admin/login/", data={"username": self.username, "password": "test"}
369 )
370
371 # Difference between the upper test: new call signature with credentials
372 credentials = {"username": self.username}
373
374 self.assertEqual(cache_hash_key, get_client_cache_key(request, credentials))
375
376 # Getting cache key from AccessAttempt Object
377 attempt = AccessAttempt(
378 user_agent="<unknown>",
379 ip_address=ip_address,
380 username=self.username,
381 get_data="",
382 post_data="",
383 http_accept=request.META.get("HTTP_ACCEPT", "<unknown>"),
384 path_info=request.META.get("PATH_INFO", "<unknown>"),
385 failures_since_start=0,
386 )
387 self.assertEqual(cache_hash_key, get_client_cache_key(attempt))
388
389
390 class UsernameTestCase(AxesTestCase):
391 @override_settings(AXES_USERNAME_FORM_FIELD="username")
392 def test_default_get_client_username(self):
393 expected = "test-username"
394
395 request = HttpRequest()
396 request.POST["username"] = expected
397
398 actual = get_client_username(request)
399
400 self.assertEqual(expected, actual)
401
402 def test_default_get_client_username_drf(self):
403 class DRFRequest:
404 def __init__(self):
405 self.data = {}
406 self.POST = {}
407
408 expected = "test-username"
409
410 request = DRFRequest()
411 request.data["username"] = expected
412
413 actual = get_client_username(request)
414
415 self.assertEqual(expected, actual)
416
417 @override_settings(AXES_USERNAME_FORM_FIELD="username")
418 def test_default_get_client_username_credentials(self):
419 expected = "test-username"
420 expected_in_credentials = "test-credentials-username"
421
422 request = HttpRequest()
423 request.POST["username"] = expected
424 credentials = {"username": expected_in_credentials}
425
426 actual = get_client_username(request, credentials)
427
428 self.assertEqual(expected_in_credentials, actual)
429
430 def sample_customize_username(request, credentials):
431 return "prefixed-" + request.POST.get("username")
432
433 @override_settings(AXES_USERNAME_FORM_FIELD="username")
434 @override_settings(AXES_USERNAME_CALLABLE=sample_customize_username)
435 def test_custom_get_client_username_from_request(self):
436 provided = "test-username"
437 expected = "prefixed-" + provided
438 provided_in_credentials = "test-credentials-username"
439
440 request = HttpRequest()
441 request.POST["username"] = provided
442 credentials = {"username": provided_in_credentials}
443
444 actual = get_client_username(request, credentials)
445
446 self.assertEqual(expected, actual)
447
448 def sample_customize_username_credentials(request, credentials):
449 return "prefixed-" + credentials.get("username")
450
451 @override_settings(AXES_USERNAME_FORM_FIELD="username")
452 @override_settings(AXES_USERNAME_CALLABLE=sample_customize_username_credentials)
453 def test_custom_get_client_username_from_credentials(self):
454 provided = "test-username"
455 provided_in_credentials = "test-credentials-username"
456 expected_in_credentials = "prefixed-" + provided_in_credentials
457
458 request = HttpRequest()
459 request.POST["username"] = provided
460 credentials = {"username": provided_in_credentials}
461
462 actual = get_client_username(request, credentials)
463
464 self.assertEqual(expected_in_credentials, actual)
465
466 @override_settings(
467 AXES_USERNAME_CALLABLE=lambda request, credentials: "example"
468 ) # pragma: no cover
469 def test_get_client_username(self):
470 self.assertEqual(get_client_username(HttpRequest(), {}), "example")
471
472 @override_settings(AXES_USERNAME_CALLABLE=lambda request: None) # pragma: no cover
473 def test_get_client_username_invalid_callable_too_few_arguments(self):
474 with self.assertRaises(TypeError):
475 get_client_username(HttpRequest(), {})
476
477 @override_settings(
478 AXES_USERNAME_CALLABLE=lambda request, credentials, extra: None
479 ) # pragma: no cover
480 def test_get_client_username_invalid_callable_too_many_arguments(self):
481 with self.assertRaises(TypeError):
482 get_client_username(HttpRequest(), {})
483
484 @override_settings(AXES_USERNAME_CALLABLE=True)
485 def test_get_client_username_not_callable(self):
486 with self.assertRaises(TypeError):
487 get_client_username(HttpRequest(), {})
488
489 @override_settings(AXES_USERNAME_CALLABLE="axes.tests.test_utils.get_username")
490 def test_get_client_username_str(self):
491 self.assertEqual(get_client_username(HttpRequest(), {}), "username")
492
493
494 def get_username(request, credentials: dict) -> str:
495 return "username"
496
497
498 class IPWhitelistTestCase(AxesTestCase):
499 def setUp(self):
500 self.request = HttpRequest()
501 self.request.method = "POST"
502 self.request.META["REMOTE_ADDR"] = "127.0.0.1"
503 self.request.axes_ip_address = "127.0.0.1"
504
505 @override_settings(AXES_IP_WHITELIST=None)
506 def test_ip_in_whitelist_none(self):
507 self.assertFalse(is_ip_address_in_whitelist("127.0.0.2"))
508
509 @override_settings(AXES_IP_WHITELIST=["127.0.0.1"])
510 def test_ip_in_whitelist(self):
511 self.assertTrue(is_ip_address_in_whitelist("127.0.0.1"))
512 self.assertFalse(is_ip_address_in_whitelist("127.0.0.2"))
513
514 @override_settings(AXES_IP_BLACKLIST=None)
515 def test_ip_in_blacklist_none(self):
516 self.assertFalse(is_ip_address_in_blacklist("127.0.0.2"))
517
518 @override_settings(AXES_IP_BLACKLIST=["127.0.0.1"])
519 def test_ip_in_blacklist(self):
520 self.assertTrue(is_ip_address_in_blacklist("127.0.0.1"))
521 self.assertFalse(is_ip_address_in_blacklist("127.0.0.2"))
522
523 @override_settings(AXES_IP_BLACKLIST=["127.0.0.1"])
524 def test_is_client_ip_address_blacklisted_ip_in_blacklist(self):
525 self.assertTrue(is_client_ip_address_blacklisted(self.request))
526
527 @override_settings(AXES_IP_BLACKLIST=["127.0.0.2"])
528 def test_is_is_client_ip_address_blacklisted_ip_not_in_blacklist(self):
529 self.assertFalse(is_client_ip_address_blacklisted(self.request))
530
531 @override_settings(AXES_NEVER_LOCKOUT_WHITELIST=True)
532 @override_settings(AXES_IP_WHITELIST=["127.0.0.1"])
533 def test_is_client_ip_address_blacklisted_ip_in_whitelist(self):
534 self.assertFalse(is_client_ip_address_blacklisted(self.request))
535
536 @override_settings(AXES_ONLY_WHITELIST=True)
537 @override_settings(AXES_IP_WHITELIST=["127.0.0.2"])
538 def test_is_already_locked_ip_not_in_whitelist(self):
539 self.assertTrue(is_client_ip_address_blacklisted(self.request))
540
541 @override_settings(AXES_NEVER_LOCKOUT_WHITELIST=True)
542 @override_settings(AXES_IP_WHITELIST=["127.0.0.1"])
543 def test_is_client_ip_address_whitelisted_never_lockout(self):
544 self.assertTrue(is_client_ip_address_whitelisted(self.request))
545
546 @override_settings(AXES_ONLY_WHITELIST=True)
547 @override_settings(AXES_IP_WHITELIST=["127.0.0.1"])
548 def test_is_client_ip_address_whitelisted_only_allow(self):
549 self.assertTrue(is_client_ip_address_whitelisted(self.request))
550
551 @override_settings(AXES_ONLY_WHITELIST=True)
552 @override_settings(AXES_IP_WHITELIST=["127.0.0.2"])
553 def test_is_client_ip_address_whitelisted_not(self):
554 self.assertFalse(is_client_ip_address_whitelisted(self.request))
555
556
557 class MethodWhitelistTestCase(AxesTestCase):
558 def setUp(self):
559 self.request = HttpRequest()
560 self.request.method = "GET"
561
562 @override_settings(AXES_NEVER_LOCKOUT_GET=True)
563 def test_is_client_method_whitelisted(self):
564 self.assertTrue(is_client_method_whitelisted(self.request))
565
566 @override_settings(AXES_NEVER_LOCKOUT_GET=False)
567 def test_is_client_method_whitelisted_not(self):
568 self.assertFalse(is_client_method_whitelisted(self.request))
569
570
571 class LockoutResponseTestCase(AxesTestCase):
572 def setUp(self):
573 self.request = HttpRequest()
574
575 @override_settings(AXES_COOLOFF_TIME=42)
576 def test_get_lockout_response_cool_off(self):
577 get_lockout_response(request=self.request)
578
579 @override_settings(AXES_LOCKOUT_TEMPLATE="example.html")
580 @patch("axes.helpers.render")
581 def test_get_lockout_response_lockout_template(self, render):
582 self.assertFalse(render.called)
583 get_lockout_response(request=self.request)
584 self.assertTrue(render.called)
585
586 @override_settings(AXES_LOCKOUT_URL="https://example.com")
587 def test_get_lockout_response_lockout_url(self):
588 response = get_lockout_response(request=self.request)
589 self.assertEqual(type(response), HttpResponseRedirect)
590
591 def test_get_lockout_response_lockout_json(self):
592 self.request.is_ajax = lambda: True
593 response = get_lockout_response(request=self.request)
594 self.assertEqual(type(response), JsonResponse)
595
596 def test_get_lockout_response_lockout_response(self):
597 response = get_lockout_response(request=self.request)
598 self.assertEqual(type(response), HttpResponse)
+0
-5
axes/tests/urls.py less more
0 from django.conf.urls import url
1 from django.contrib import admin
2
3
4 urlpatterns = [url(r"^admin/", admin.site.urls)]
+0
-1
axes/tests/urls_empty.py less more
0 urlpatterns: list = []
55 """
66
77 from logging import getLogger
8 from typing import Optional
89
10 from django.http import HttpRequest
11
12 from axes.conf import settings
913 from axes.handlers.proxy import AxesProxyHandler
14 from axes.helpers import get_client_ip_address
1015
1116 log = getLogger(__name__)
1217
1318
14 def reset(ip: str = None, username: str = None) -> int:
19 def reset(ip: str = None, username: str = None, ip_or_username=False) -> int:
1520 """
1621 Reset records that match IP or username, and return the count of removed attempts.
1722
1823 This utility method is meant to be used from the CLI or via Python API.
1924 """
2025
21 return AxesProxyHandler.reset_attempts(ip_address=ip, username=username)
26 return AxesProxyHandler.reset_attempts(
27 ip_address=ip, username=username, ip_or_username=ip_or_username
28 )
29
30
31 def reset_request(request: HttpRequest) -> int:
32 """
33 Reset records that match IP or username, and return the count of removed attempts.
34
35 This utility method is meant to be used from the CLI or via Python API.
36 """
37
38 ip: Optional[str] = get_client_ip_address(request)
39 username = request.GET.get("username", None)
40
41 ip_or_username = settings.AXES_LOCK_OUT_BY_USER_OR_IP
42 if settings.AXES_ONLY_USER_FAILURES:
43 ip = None
44 elif not (
45 settings.AXES_LOCK_OUT_BY_USER_OR_IP
46 or settings.AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP
47 ):
48 username = None
49
50 if not ip and not username:
51 return 0
52 # We don't want to reset everything, if there is some wrong request parameter
53
54 # if settings.AXES_USE_USER_AGENT:
55 # TODO: reset based on user_agent?
56 return reset(ip, username, ip_or_username)
0 Metadata-Version: 1.2
1 Name: django-axes
2 Version: 0.1.dev1265+geddac32
3 Summary: Keep track of failed login attempts in Django-powered sites.
4 Home-page: https://github.com/jazzband/django-axes
5 Author: Josh VanderLinden, Philip Neustrom, Michael Blume, Alex Clark, Camilo Nova, Aleksi Hakli
6 Author-email: security@jazzband.co
7 Maintainer: Jazzband
8 Maintainer-email: security@jazzband.co
9 License: MIT
10 Project-URL: Documentation, https://django-axes.readthedocs.io/
11 Project-URL: Source, https://github.com/jazzband/django-axes
12 Project-URL: Tracker, https://github.com/jazzband/django-axes/issues
13 Description:
14 django-axes
15 ===========
16
17 .. image:: https://jazzband.co/static/img/badge.svg
18 :target: https://jazzband.co/
19 :alt: Jazzband
20
21 .. image:: https://img.shields.io/github/stars/jazzband/django-axes.svg?label=Stars&style=socialcA
22 :target: https://github.com/jazzband/django-axes
23 :alt: GitHub
24
25 .. image:: https://img.shields.io/pypi/v/django-axes.svg
26 :target: https://pypi.org/project/django-axes/
27 :alt: PyPI release
28
29 .. image:: https://img.shields.io/pypi/pyversions/django-axes.svg
30 :target: https://pypi.org/project/django-axes/
31 :alt: Supported Python versions
32
33 .. image:: https://img.shields.io/pypi/djversions/django-axes.svg
34 :target: https://pypi.org/project/django-axes/
35 :alt: Supported Django versions
36
37 .. image:: https://img.shields.io/readthedocs/django-axes.svg
38 :target: https://django-axes.readthedocs.io/
39 :alt: Documentation
40
41 .. image:: https://github.com/jazzband/django-axes/workflows/Test/badge.svg
42 :target: https://github.com/jazzband/django-axes/actions
43 :alt: GitHub Actions
44
45 .. image:: https://codecov.io/gh/jazzband/django-axes/branch/master/graph/badge.svg
46 :target: https://codecov.io/gh/jazzband/django-axes
47 :alt: Coverage
48
49
50 Axes is a Django plugin for keeping track of suspicious
51 login attempts for your Django based website
52 and implementing simple brute-force attack blocking.
53
54 The name is sort of a geeky pun, since it can be interpreted as:
55
56 * ``access``, as in monitoring access attempts, or
57 * ``axes``, as in tools you can use to hack (generally on wood).
58
59
60 Functionality
61 -------------
62
63 Axes records login attempts to your Django powered site and prevents attackers
64 from attempting further logins to your site when they exceed the configured attempt limit.
65
66 Axes can track the attempts and persist them in the database indefinitely,
67 or alternatively use a fast and DDoS resistant cache implementation.
68
69 Axes can be configured to monitor login attempts by
70 IP address, username, user agent, or their combinations.
71
72 Axes supports cool off periods, IP address whitelisting and blacklisting,
73 user account whitelisting, and other features for Django access management.
74
75
76 Documentation
77 -------------
78
79 For more information on installation and configuration see the documentation at:
80
81 https://django-axes.readthedocs.io/
82
83
84 Issues
85 ------
86
87 If you have questions or have trouble using the app please file a bug report at:
88
89 https://github.com/jazzband/django-axes/issues
90
91
92 Contributions
93 -------------
94
95 All contributions are welcome!
96
97 It is best to separate proposed changes and PRs into small, distinct patches
98 by type so that they can be merged faster into upstream and released quicker.
99
100 One way to organize contributions would be to separate PRs for e.g.
101
102 * bugfixes,
103 * new features,
104 * code and design improvements,
105 * documentation improvements, or
106 * tooling and CI improvements.
107
108 Merging contributions requires passing the checks configured
109 with the CI. This includes running tests and linters successfully
110 on the currently officially supported Python and Django versions.
111
112 The test automation is run automatically with GitHub Actions, but you can
113 run it locally with the ``tox`` command before pushing commits.
114
115 Please note that this is a `Jazzband <https://jazzband.co>`_ project.
116 By contributing you agree to abide by the
117 `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_
118 and follow the `guidelines <https://jazzband.co/about/guidelines>`_.
119
120
121 Changes
122 =======
123
124
125 5.20.0 (2021-06-29)
126 -------------------
127
128 - Improve race condition handling in e.g. multi-process environments by using
129 ``get_or_create`` for access attempt fetching and updates.
130 [uli-klank]
131
132
133 5.19.0 (2021-06-16)
134 -------------------
135
136 - Add Polish locale.
137 [Quadric]
138
139
140 5.18.0 (2021-06-09)
141 -------------------
142
143 - Fix ``default_auto_field`` warning.
144 [zkanda]
145
146
147 5.17.0 (2021-06-05)
148 -------------------
149
150 - Fix ``default_app_config`` deprecation.
151 Django 3.2 automatically detects ``AppConfig`` and therefore this setting is no longer required.
152 [nikolaik]
153
154
155 5.16.0 (2021-05-19)
156 -------------------
157
158 - Add ``AXES_CLIENT_STR_CALLABLE`` setting.
159 [smtydn]
160
161
162 5.15.0 (2021-05-03)
163 -------------------
164
165 - Add option to cleanse sensitive GET and POST params in database handler
166 with the ``AXES_SENSITIVE_PARAMETERS`` setting.
167 [mcoconnor]
168
169
170 5.14.0 (2021-04-06)
171 -------------------
172
173 - Improve message formatting for lockout message and translations.
174 [ashokdelphia]
175 - Remove support for Django 3.0.
176 [hramezani]
177 - Add support for Django 3.2.
178 [hramezani]
179
180
181 5.13.1 (2021-02-22)
182 -------------------
183
184 - Default ``AXES_VERBOSE`` to ``AXES_ENABLED`` configuration setting,
185 disabling verbose startup logging when Axes itself is disabled.
186 [christianbundy]
187 - Update documentation.
188 [KStenK]
189
190
191 5.13.0 (2021-02-15)
192 -------------------
193
194 - Add support for resetting attempts with cache backend.
195 [nattyg93]
196
197
198 5.12.0 (2021-01-07)
199 -------------------
200
201 - Clean up test structure and migrate tests outside
202 the main package for a smaller wheel distributions.
203 [aleksihakli]
204 - Move configuration to pyproject.toml for cleaner layout.
205 [aleksihakli]
206 - Clean up test settings override configuration.
207 [hramezani]
208
209
210 5.11.1 (2021-01-06)
211 -------------------
212
213 - Fix cache entry creations for None username.
214 [cabarnes]
215
216
217 5.11.0 (2021-01-05)
218 -------------------
219
220 - Add lockout view CORS support with ``AXES_ALLOWED_CORS_ORIGINS`` configuration flag.
221 [vladox]
222 - Add missing ``@wraps`` decorator to ``axes.decorators.axes_dispatch``.
223 [aleksihakli]
224
225
226 5.10.1 (2021-01-04)
227 -------------------
228
229 - Add ``DEFAULT_AUTO_FIELD`` to test settings.
230 [hramezani]
231 - Fix documentation language.
232 [danielquinn]
233 - Fix Python package version specifiers and remove redundant imports.
234 [aleksihakli]
235
236
237 5.10.0 (2020-12-18)
238 -------------------
239
240 - Deprecate stock DRF support from 5.8.0,
241 require users to set it up per project.
242 Check the documentation for more information.
243 [aleksihakli]
244
245
246 5.9.1 (2020-12-02)
247 ------------------
248
249 - Move tests to GitHub Actions
250 [jezdez]
251 - Fix running Axes code in middleware when ``AXES_ENABLED`` is ``False``.
252 [ashokdelphia]
253
254
255 5.9.0 (2020-11-05)
256 ------------------
257
258 - Add Python 3.9 support.
259 [hramezani]
260 - Prevent ``AccessAttempt`` creation with database handler when
261 username is not set and ``AXES_ONLY_USER_FAILURES`` setting is not set.
262 [hramezani]
263
264
265 5.8.0 (2020-10-16)
266 ------------------
267
268 - Improve Django REST Framework (DRF) integration.
269 [Anatoly]
270
271
272 5.7.1 (2020-09-27)
273 ------------------
274
275 - Adjust settings import and handling chain
276 for cleaner module import and invocation order.
277 [aleksihakli]
278 - Adjust the use of ``AXES_ENABLED`` flag so that
279 imports are always done the same way and initial log
280 is written regardless of the setting and it only affects
281 code that is decorated or wrapped with ``toggleable``.
282 [alekshakli]
283
284
285 5.7.0 (2020-09-26)
286 ------------------
287
288 - Deprecate ``AXES_LOGGER`` Axes setting and move to ``__name__``
289 based logging and fully qualified Python module name log identifiers.
290 [aleksihakli]
291
292
293 5.6.2 (2020-09-20)
294 ------------------
295
296 - Fix regression in ``axes_reset_user`` management command.
297 [aleksihakli]
298
299
300 5.6.1 (2020-09-17)
301 ------------------
302
303 - Improve test dependency management and upgrade black code formatter.
304 [smithdc1]
305
306
307 5.6.0 (2020-09-12)
308 ------------------
309
310 - Add proper development ``subTest`` support via ``pytest-subtests`` package.
311 [smithdc1]
312 - Deprecate ``django-appconf`` and use plain settings for Axes.
313 [aleksihakli]
314
315
316 5.5.2 (2020-09-11)
317 ------------------
318
319 - Update deprecating use of the ``request.is_ajax`` method.
320 [smithdc1]
321
322
323 5.5.1 (2020-09-10)
324 ------------------
325
326 - Update deprecated uses of Django modules and members.
327 [smithdc1]
328
329
330 5.5.0 (2020-08-21)
331 ------------------
332
333 - Add support for locking requests based on
334 username OR IP address with inclusive or
335 using the ``LOCK_OUT_BY_USER_OR_IP`` flag.
336 [PetrDlouhy]
337 - Deprecate Signal ``providing_args`` for Django 3.1 support.
338 [coredumperror]
339
340
341 5.4.3 (2020-08-06)
342 ------------------
343
344 - Add Django 3.1 support.
345 [hramezani]
346
347
348 5.4.2 (2020-07-28)
349 ------------------
350
351 - Add ABC or abstract base class implementation for handlers.
352 [jorlugaqui]
353
354
355 5.4.1 (2020-07-03)
356 ------------------
357
358 - Fix code styling for linters.
359 [aleksihakli]
360
361
362 5.4.0 (2020-07-03)
363 ------------------
364
365 - Propagate username to lockout view in URL parameters.
366 [PetrDlouhy]
367 - Update CAPTCHA examples.
368 [PetrDlouhy]
369 - Upgrade django-ipware to version 3.
370 [hramezani,mnislam01]
371
372
373 5.3.5 (2020-07-02)
374 ------------------
375
376 - Restrict ipware version for version compatibility.
377 [aleksihakli]
378
379
380 5.3.4 (2020-06-09)
381 ------------------
382
383 - Deprecate Django 1.11 LTS support.
384 [aleksihakli]
385
386
387 5.3.3 (2020-05-22)
388 ------------------
389
390 - Fix ``AXES_ONLY_ADMIN_SITE`` functionality when
391 no default admin site is defined in the URL configuration.
392 [igor-shevchenko]
393
394
395 5.3.2 (2020-05-15)
396 ------------------
397
398 - Fix AppConf settings prefix for Fargate.
399 [marksweb]
400
401
402 5.3.1 (2020-03-23)
403 ------------------
404
405 - Fix null byte ValueError bug in ORM.
406 [ddimmich]
407
408
409 5.3.0 (2020-03-10)
410 ------------------
411
412 - Improve Django REST Framework compatibility.
413 [I0x4dI]
414
415
416 5.2.2 (2020-01-08)
417 ------------------
418
419 - Add missing proxy implementation for
420 ``axes.handlers.proxy.AxesProxyHandler.get_failures``.
421 [aleksihakli]
422
423
424 5.2.1 (2020-01-08)
425 ------------------
426
427 - Add django-reversion compatibility notes.
428 [mark-mishyn]
429 - Add pluggable lockout responses and the
430 ``AXES_LOCKOUT_CALLABLE`` configuration flag.
431 [aleksihakli]
432
433
434 5.2.0 (2020-01-01)
435 ------------------
436
437 - Add a test handler.
438 [aidanlister]
439
440
441 5.1.0 (2019-12-29)
442 ------------------
443
444 - Add pluggable user account whitelisting and the
445 ``AXES_WHITELIST_CALLABLE`` configuration flag.
446 [aleksihakli]
447
448
449 5.0.20 (2019-12-01)
450 -------------------
451
452 - Fix django-allauth compatibility issue.
453 [hramezani]
454 - Improve tests for login attempt monitoring.
455 [hramezani]
456 - Add reverse proxy documentation.
457 [ckcollab]
458 - Update OAuth documentation examples.
459 [aleksihakli]
460
461
462 5.0.19 (2019-11-06)
463 -------------------
464
465 - Optimize access attempt fetching in database handler.
466 [hramezani]
467 - Optimize request data fetching in proxy handler.
468 [hramezani]
469
470
471 5.0.18 (2019-10-17)
472 -------------------
473
474 - Add ``cooloff_timedelta`` context variable to lockout responses.
475 [jstockwin]
476
477
478 5.0.17 (2019-10-15)
479 -------------------
480
481 - Safer string formatting for user input.
482 [aleksihakli]
483
484
485 5.0.16 (2019-10-15)
486 -------------------
487
488 - Fix string formatting bug in logging.
489 [zerolab]
490
491
492 5.0.15 (2019-10-09)
493 -------------------
494
495 - Add ``AXES_ENABLE_ADMIN`` flag.
496 [flannelhead]
497
498
499 5.0.14 (2019-09-28)
500 -------------------
501
502 - Docs, CI pipeline, and code formatting improvements
503 [aleksihakli]
504
505
506 5.0.13 (2019-08-30)
507 -------------------
508
509 - Python 3.8 and PyPy support.
510 [aleksihakli]
511 - Migrate to ``setuptools_scm`` and automatic versioning.
512 [aleksihakli]
513
514
515 5.0.12 (2019-08-05)
516 -------------------
517
518 - Support callables for ``AXES_COOLOFF_TIME`` setting.
519 [DariaPlotnikova]
520
521
522 5.0.11 (2019-07-25)
523 -------------------
524
525 - Fix typo in rST formatting that prevented 5.0.10 release to PyPI.
526 [aleksihakli]
527
528
529 5.0.10 (2019-07-25)
530 -------------------
531
532 - Refactor type checks for ``axes.helpers.get_client_cache_key``
533 for framework compatibility, fixes #471.
534 [aleksihakli]
535
536
537 5.0.9 (2019-07-11)
538 ------------------
539
540 - Add better handling for attempt and log resets by moving them
541 into handlers which allows customization and more configurability.
542 Unimplemented handlers raise ``NotImplementedError`` by default.
543 [aleksihakli]
544 - Add Python 3.8 dev version and PyPy to the Travis test matrix.
545 [aleksihakli]
546
547
548 5.0.8 (2019-07-09)
549 ------------------
550
551 - Add ``AXES_ONLY_ADMIN_SITE`` flag for only running Axes on admin site.
552 [hramezani]
553 - Add ``axes_reset_logs`` command for removing old AccessLog records.
554 [tlebrize]
555 - Allow ``AxesBackend`` subclasses to pass the ``axes.W003`` system check.
556 [adamchainz]
557
558
559 5.0.7 (2019-06-14)
560 ------------------
561
562 - Fix lockout message showing when lockout is disabled
563 with the ``AXES_LOCK_OUT_AT_FAILURE`` setting.
564 [mogzol]
565
566 - Add support for callable ``AXES_FAILURE_LIMIT`` setting.
567 [bbayles]
568
569
570 5.0.6 (2019-05-25)
571 ------------------
572
573 - Deprecate ``AXES_DISABLE_SUCCESS_ACCESS_LOG`` flag in favour of
574 ``AXES_DISABLE_ACCESS_LOG`` which has mostly the same functionality.
575 Update documentation to better reflect the behaviour of the flag.
576 [aleksihakli]
577
578
579 5.0.5 (2019-05-19)
580 ------------------
581
582 - Change the lockout response calculation to request flagging
583 instead of exception throwing in the signal handler and middleware.
584 Move request attribute calculation from middleware to handler layer.
585 Deprecate ``axes.request.AxesHttpRequest`` object type definition.
586 [aleksihakli]
587
588 - Deprecate the old version 4.x ``axes.backends.AxesModelBackend`` class.
589 [aleksihakli]
590
591 - Improve documentation on attempt tracking, resets, Axes customization,
592 project and component compatibility and integrations, and other things.
593 [aleksihakli]
594
595
596 5.0.4 (2019-05-09)
597 ------------------
598
599 - Fix regression with OAuth2 authentication backends not having remote
600 IP addresses set and throwing an exception in cache key calculation.
601 [aleksihakli]
602
603
604 5.0.3 (2019-05-08)
605 ------------------
606
607 - Fix ``django.contrib.auth`` module ``login`` and ``logout`` functionality
608 so that they work with the handlers without the an ``AxesHttpRequest``
609 to improve cross compatibility with other Django applications.
610 [aleksihakli]
611
612 - Change IP address resolution to allow empty or missing addresses.
613 [aleksihakli]
614
615 - Add error logging for missing request attributes in the handler layer
616 so that users get better indicators of misconfigured applications.
617 [aleksihakli]
618
619
620 5.0.2 (2019-05-07)
621 ------------------
622
623 - Add ``AXES_ENABLED`` setting for disabling Axes with e.g. tests
624 that use Django test client ``login``, ``logout``, and ``force_login``
625 methods, which do not supply the ``request`` argument to views,
626 preventing Axes from functioning correctly in certain test setups.
627 [aleksihakli]
628
629
630 5.0.1 (2019-05-03)
631 ------------------
632
633 - Add changelog to documentation.
634 [aleksihakli]
635
636
637 5.0 (2019-05-01)
638 ----------------
639
640 - Deprecate Python 2.7, 3.4 and 3.5 support.
641 [aleksihakli]
642
643 - Remove automatic decoration and monkey-patching of Django views and forms.
644 Decorators are available for login function and method decoration as before.
645 [aleksihakli]
646
647 - Use backend, middleware, and signal handlers for tracking
648 login attempts and implementing user lockouts.
649 [aleksihakli, jorlugaqui, joshua-s]
650
651 - Add ``AxesDatabaseHandler``, ``AxesCacheHandler``, and ``AxesDummyHandler``
652 handler backends for processing user login and logout events and failures.
653 Handlers are configurable with the ``AXES_HANDLER`` setting.
654 [aleksihakli, jorlugaqui, joshua-s]
655
656 - Improve management commands and separate commands for resetting
657 all access attempts, attempts by IP, and attempts by username.
658 New command names are ``axes_reset``, ``axes_reset_ip`` and ``axes_reset_username``.
659 [aleksihakli]
660
661 - Add support for string import for ``AXES_USERNAME_CALLABLE``
662 that supports dotted paths in addition to the old
663 callable type such as a function or a class method.
664 [aleksihakli]
665
666 - Deprecate one argument call signature for ``AXES_USERNAME_CALLABLE``.
667 From now on, the callable needs to accept two arguments,
668 the HttpRequest and credentials that are supplied to the
669 Django ``authenticate`` method in authentication backends.
670 [aleksihakli]
671
672 - Move ``axes.attempts.is_already_locked`` function to ``axes.handlers.AxesProxyHandler.is_locked``.
673 Various other previously undocumented methods have been deprecated and moved inside the project.
674 The new documented public APIs can be considered as stable and can be safely utilized by other projects.
675 [aleksihakli]
676
677 - Improve documentation layouting and contents. Add public API reference section.
678 [aleksihakli]
679
680
681 4.5.4 (2019-01-15)
682 ------------------
683
684 - Improve README and documentation
685 [aleksihakli]
686
687
688 4.5.3 (2019-01-14)
689 ------------------
690
691 - Remove the unused ``AccessAttempt.trusted`` flag from models
692 [aleksihakli]
693
694 - Improve README and Travis CI setups
695 [aleksihakli]
696
697
698 4.5.2 (2019-01-12)
699 ------------------
700
701 - Added Turkish translations
702 [obayhan]
703
704
705 4.5.1 (2019-01-11)
706 ------------------
707
708 - Removed duplicated check that was causing issues when using APIs.
709 [camilonova]
710
711 - Added Russian translations
712 [lubicz-sielski]
713
714
715 4.5.0 (2018-12-25)
716 ------------------
717
718 - Improve support for custom authentication credentials using the
719 ``AXES_USERNAME_FORM_FIELD`` and ``AXES_USERNAME_CALLABLE`` settings.
720 [mastacheata]
721
722 - Updated behaviour for fetching username from request or credentials:
723 If no ``AXES_USERNAME_CALLABLE`` is configured, the optional
724 ``credentials`` that are supplied to the axes utility methods
725 are now the default source for client username and the HTTP
726 request POST is the fallback for fetching the user information.
727 ``AXES_USERNAME_CALLABLE`` implements an alternative signature with two
728 arguments ``request, credentials`` in addition to the old ``request``
729 call argument signature in a backwards compatible fashion.
730 [aleksihakli]
731
732 - Add official support for the Django 2.1 version and Python 3.7.
733 [aleksihakli]
734
735 - Improve the requirements, documentation, tests, and CI setup.
736 [aleksihakli]
737
738
739 4.4.3 (2018-12-08)
740 ------------------
741
742 - Fix MANIFEST.in missing German translations
743 [aleksihakli]
744
745 - Add `AXES_RESET_ON_SUCCESS` configuration flag
746 [arjenzijlstra]
747
748
749 4.4.2 (2018-10-30)
750 ------------------
751
752 - fix missing migration and add check to prevent it happening again.
753 [markddavidoff]
754
755
756 4.4.1 (2018-10-24)
757 ------------------
758
759 - Add a German translation
760 [adonig]
761
762 - Documentation wording changes
763 [markddavidoff]
764
765 - Use `get_client_username` in `log_user_login_failed` instead of credentials
766 [markddavidoff]
767
768 - pin prospector to 0.12.11, and pin astroid to 1.6.5
769 [hsiaoyi0504]
770
771
772 4.4.0 (2018-05-26)
773 ------------------
774
775 - Added AXES_USERNAME_CALLABLE
776 [jaadus]
777
778
779 4.3.1 (2018-04-21)
780 ------------------
781
782 - Change custom authentication backend failures from error to warning log level
783 [aleksihakli]
784
785 - Set up strict code linting for CI pipeline that fails builds if linting does not pass
786 [aleksihakli]
787
788 - Clean up old code base and tests based on linter errors
789 [aleksihakli]
790
791
792 4.3.0 (2018-04-21)
793 ------------------
794
795 - Refactor and clean up code layout
796 [aleksihakli]
797
798 - Add prospector linting and code checks to toolchain
799 [aleksihakli]
800
801 - Clean up log message formatting and refactor type checks
802 [EvaSDK]
803
804 - Fix faulty user locking with user agent when AXES_ONLY_USER_FAILURES is set
805 [EvaSDK]
806
807
808 4.2.1 (2018-04-18)
809 ------------------
810
811 - Fix unicode string interpolation on Python 2.7
812 [aleksihakli]
813
814
815 4.2.0 (2018-04-13)
816 ------------------
817
818 - Add configuration flags for client IP resolving
819 [aleksihakli]
820
821 - Add AxesModelBackend authentication backend
822 [markdaviddoff]
823
824
825 4.1.0 (2018-02-18)
826 ------------------
827
828 - Add AXES_CACHE setting for configuring `axes` specific caching.
829 [JWvDronkelaar]
830
831 - Add checks and tests for faulty LocMemCache usage in application setup.
832 [aleksihakli]
833
834
835 4.0.2 (2018-01-19)
836 ------------------
837
838 - Improve Windows compatibility on Python < 3.4 by utilizing win_inet_pton
839 [hsiaoyi0504]
840
841 - Add documentation on django-allauth integration
842 [grucha]
843
844 - Add documentation on known AccessAttempt caching configuration problems
845 when using axes with the `django.core.cache.backends.locmem.LocMemCache`
846 [aleksihakli]
847
848 - Refactor and improve existing AccessAttempt cache reset utility
849 [aleksihakli]
850
851
852 4.0.1 (2017-12-19)
853 ------------------
854
855 - Fixes issue when not using `AXES_USERNAME_FORM_FIELD`
856 [camilonova]
857
858
859 4.0.0 (2017-12-18)
860 ------------------
861
862 - *BREAKING CHANGES*. `AXES_BEHIND_REVERSE_PROXY` `AXES_REVERSE_PROXY_HEADER`
863 `AXES_NUM_PROXIES` were removed in order to use `django-ipware` to get
864 the user ip address
865 [camilonova]
866
867 - Added support for custom username field
868 [kakulukia]
869
870 - Customizing Axes doc updated
871 [pckapps]
872
873 - Remove filtering by username
874 [camilonova]
875
876 - Fixed logging failed attempts to authenticate using a custom authentication
877 backend.
878 [D3X]
879
880
881 3.0.3 (2017-11-23)
882 ------------------
883
884 - Test against Python 2.7.
885 [mbaechtold]
886
887 - Test against Python 3.4.
888 [pope1ni]
889
890
891 3.0.2 (2017-11-21)
892 ------------------
893
894 - Added form_invalid decorator. Fixes #265
895 [camilonova]
896
897
898 3.0.1 (2017-11-17)
899 ------------------
900
901 - Fix DeprecationWarning for logger warning
902 [richardowen]
903
904 - Fixes global lockout possibility
905 [joeribekker]
906
907 - Changed the way output is handled in the management commands
908 [ataylor32]
909
910
911 3.0.0 (2017-11-17)
912 ------------------
913
914 - BREAKING CHANGES. Support for Django >= 1.11 and signals, see issue #215.
915 Drop support for Python < 3.6
916 [camilonova]
917
918
919 2.3.3 (2017-07-20)
920 ------------------
921
922 - Many tweaks and handles successful AJAX logins.
923 [Jack Sullivan]
924
925 - Add tests for proxy number parametrization
926 [aleksihakli]
927
928 - Add AXES_NUM_PROXIES setting
929 [aleksihakli]
930
931 - Log failed access attempts regardless of settings
932 [jimr]
933
934 - Updated configuration docs to include AXES_IP_WHITELIST
935 [Minkey27]
936
937 - Add test for get_cache_key function
938 [jorlugaqui]
939
940 - Delete cache key in reset command line
941 [jorlugaqui]
942
943 - Add signals for setting/deleting cache keys
944 [jorlugaqui]
945
946
947 2.3.2 (2016-11-24)
948 ------------------
949
950 - Only look for lockable users on a POST
951 [schinckel]
952
953 - Fix and add tests for IPv4 and IPv6 parsing
954 [aleksihakli]
955
956
957 2.3.1 (2016-11-12)
958 ------------------
959
960 - Added settings for disabling success accesslogs
961 [Minkey27]
962
963 - Fixed illegal IP address string passed to inet_pton
964 [samkuehn]
965
966
967 2.3.0 (2016-11-04)
968 ------------------
969
970 - Fixed ``axes_reset`` management command to skip "ip" prefix to command
971 arguments.
972 [EvaMarques]
973
974 - Added ``axes_reset_user`` management command to reset lockouts and failed
975 login records for given users.
976 [vladimirnani]
977
978 - Fixed Travis-PyPI release configuration.
979 [jezdez]
980
981 - Make IP position argument optional.
982 [aredalen]
983
984 - Added possibility to disable access log
985 [svenhertle]
986
987 - Fix for IIS used as reverse proxy adding port number
988 [Dmitri-Sintsov]
989
990 - Made the signal race condition safe.
991 [Minkey27]
992
993 - Added AXES_ONLY_USER_FAILURES to support only looking at the user ID.
994 [lip77us]
995
996
997 2.2.0 (2016-07-20)
998 ------------------
999
1000 - Improve the logic when using a reverse proxy to avoid possible attacks.
1001 [camilonova]
1002
1003
1004 2.1.0 (2016-07-14)
1005 ------------------
1006
1007 - Add `default_app_config` so you can just use `axes` in `INSTALLED_APPS`
1008 [vdboor]
1009
1010
1011 2.0.0 (2016-06-24)
1012 ------------------
1013
1014 - Removed middleware to use app_config
1015 [camilonova]
1016
1017 - Lots of cleaning
1018 [camilonova]
1019
1020 - Improved test suite and versions
1021 [camilonova]
1022
1023
1024 1.7.0 (2016-06-10)
1025 ------------------
1026
1027 - Use render shortcut for rendering LOCKOUT_TEMPLATE
1028 [Radoslaw Luter]
1029
1030 - Added app_label for RemovedInDjango19Warning
1031 [yograterol]
1032
1033 - Add iso8601 translator.
1034 [mullakhmetov]
1035
1036 - Edit json response. Context now contains ISO 8601 formatted cooloff time
1037 [mullakhmetov]
1038
1039 - Add json response and iso8601 tests.
1040 [mullakhmetov]
1041
1042 - Fixes issue 162: UnicodeDecodeError on pip install
1043 [joeribekker]
1044
1045 - Added AXES_NEVER_LOCKOUT_WHITELIST option to prevent certain IPs from being locked out.
1046 [joeribekker]
1047
1048
1049 1.6.1 (2016-05-13)
1050 ------------------
1051
1052 - Fixes whitelist check when BEHIND_REVERSE_PROXY
1053 [Patrick Hagemeister]
1054
1055 - Made migrations py3 compatible
1056 [mvdwaeter]
1057
1058 - Fixing #126, possibly breaking compatibility with Django<=1.7
1059 [int-ua]
1060
1061 - Add note for upgrading users about new migration files
1062 [kelseyq]
1063
1064 - Fixes #148
1065 [camilonova]
1066
1067 - Decorate auth_views.login only once
1068 [teeberg]
1069
1070 - Set IP public/private classifier to be compliant with RFC 1918.
1071 [SilasX]
1072
1073 - Issue #155. Lockout response status code changed to 403.
1074 [Arthur Mullahmetov]
1075
1076 - BUGFIX: Missing migration
1077 [smeinel]
1078
1079
1080 1.6.0 (2016-01-07)
1081 ------------------
1082
1083 - Stopped using render_to_response so that other template engines work
1084 [tarkatronic]
1085
1086 - Improved performance & DoS prevention on query2str
1087 [tarkatronic]
1088
1089 - Immediately return from is_already_locked if the user is not lockable
1090 [jdunck]
1091
1092 - Iterate over ip addresses only once
1093 [annp89]
1094
1095 - added initial migration files to support django 1.7 &up. Upgrading users should run migrate --fake-initial after update
1096 [ibaguio]
1097
1098 - Add db indexes to CommonAccess model
1099 [Schweigi]
1100
1101
1102 1.5.0 (2015-09-11)
1103 ------------------
1104
1105 - Fix #_get_user_attempts to include username when filtering AccessAttempts if AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True
1106 [afioca]
1107
1108
1109 1.4.0 (2015-08-09)
1110 ------------------
1111
1112 - Send the user_locked_out signal. Fixes #94.
1113 [toabi]
1114
1115
1116 1.3.9 (2015-02-11)
1117 ------------------
1118
1119 - Python 3 fix (#104)
1120
1121
1122 1.3.8 (2014-10-07)
1123 ------------------
1124
1125 - Rename GitHub organization from django-security to django-pci to emphasize focus on providing assistance with building PCI compliant websites with Django.
1126 [aclark4life]
1127
1128
1129 1.3.7 (2014-10-05)
1130 ------------------
1131
1132 - Explain common issues where Axes fails silently
1133 [cericoda]
1134
1135 - Allow for user-defined username field for lookup in POST data
1136 [SteveByerly]
1137
1138 - Log out only if user was logged in
1139 [zoten]
1140
1141 - Support for floats in cooloff time (i.e: 0.1 == 6 minutes)
1142 [marianov]
1143
1144 - Limit amount of POST data logged (#73). Limiting the length of value is not enough, as there could be arbitrary number of them, or very long key names.
1145 [peterkuma]
1146
1147 - Improve get_ip to try for real ip address
1148 [7wonders]
1149
1150 - Change IPAddressField to GenericIPAddressField. When using a PostgreSQL database and the client does not pass an IP address you get an inet error. This is a known problem with PostgreSQL and the IPAddressField. https://code.djangoproject.com/ticket/5622. It can be fixed by using a GenericIPAddressField instead.
1151 [polvoblanco]
1152
1153 - Get first X-Forwarded-For IP
1154 [tutumcloud]
1155
1156 - White listing IP addresses behind reverse proxy. Allowing some IP addresses to have direct access to the app even if they are behind a reverse proxy. Those IP addresses must still be on a white list.
1157 [ericbulloch]
1158
1159 - Reduce logging of reverse proxy IP lookup and use configured logger. Fixes #76. Instead of logging the notice that django.axes looks for a HTTP header set by a reverse proxy on each attempt, just log it one-time on first module import. Also use the configured logger (by default axes.watch_login) for the message to be more consistent in logging.
1160 [eht16]
1161
1162 - Limit the length of the values logged into the database. Refs #73
1163 [camilonova]
1164
1165 - Refactored tests to be more stable and faster
1166 [camilonova]
1167
1168 - Clean client references
1169 [camilonova]
1170
1171 - Fixed admin login url
1172 [camilonova]
1173
1174 - Added django 1.7 for testing
1175 [camilonova]
1176
1177 - Travis file cleanup
1178 [camilonova]
1179
1180 - Remove hardcoded url path
1181 [camilonova]
1182
1183 - Fixing tests for django 1.7
1184 [Andrew-Crosio]
1185
1186 - Fix for django 1.7 exception not existing
1187 [Andrew-Crosio]
1188
1189 - Removed python 2.6 from testing
1190 [camilonova]
1191
1192 - Use django built-in six version
1193 [camilonova]
1194
1195 - Added six as requirement
1196 [camilonova]
1197
1198 - Added python 2.6 for travis testing
1199 [camilonova]
1200
1201 - Replaced u string literal prefixes with six.u() calls
1202 [amrhassan]
1203
1204 - Fixes object type issue, response is not an string
1205 [camilonova]
1206
1207 - Python 3 compatibility fix for db_reset
1208 [nicois]
1209
1210 - Added example project and helper scripts
1211 [barseghyanartur]
1212
1213 - Admin command to list login attemps
1214 [marianov]
1215
1216 - Replaced six imports with django.utils.six ones
1217 [amrhassan]
1218
1219 - Replaced u string literal prefixes with six.u() calls to make it compatible with Python 3.2
1220 [amrhassan]
1221
1222 - Replaced `assertIn`s and `assertNotIn`s with `assertContains` and `assertNotContains`
1223 [fcurella]
1224
1225 - Added py3k to travis
1226 [fcurella]
1227
1228 - Update test cases to be python3 compatible
1229 [nicois]
1230
1231 - Python 3 compatibility fix for db_reset
1232 [nicois]
1233
1234 - Removed trash from example urls
1235 [barseghyanartur]
1236
1237 - Added django installer
1238 [barseghyanartur]
1239
1240 - Added example project and helper scripts
1241 [barseghyanartur]
1242
1243
1244 1.3.6 (2013-11-23)
1245 ------------------
1246
1247 - Added AttributeError in case get_profile doesn't exist
1248 [camilonova]
1249
1250 - Improved axes_reset command
1251 [camilonova]
1252
1253
1254 1.3.5 (2013-11-01)
1255 ------------------
1256
1257 - Fix an issue with __version__ loading the wrong version
1258 [graingert]
1259
1260
1261 1.3.4 (2013-11-01)
1262 ------------------
1263
1264 - Update README.rst for PyPI
1265 [marty, camilonova, graingert]
1266
1267 - Add cooloff period
1268 [visualspace]
1269
1270
1271 1.3.3 (2013-07-05)
1272 ------------------
1273
1274 - Added 'username' field to the Admin table
1275 [bkvirendra]
1276
1277 - Removed fallback logging creation since logging cames by default on django 1.4 or later,
1278 if you don't have it is because you explicitly wanted. Fixes #45
1279 [camilonova]
1280
1281
1282 1.3.2 (2013-03-28)
1283 ------------------
1284
1285 - Fix an issue when a user logout
1286 [camilonova]
1287
1288 - Match pypi version
1289 [camilonova]
1290
1291 - Better User model import method
1292 [camilonova]
1293
1294 - Use only one place to get the version number
1295 [camilonova]
1296
1297 - Fixed an issue when a user on django 1.4 logout
1298 [camilonova]
1299
1300 - Handle exception if there is not user profile model set
1301 [camilonova]
1302
1303 - Made some cleanup and remove a pokemon exception handling
1304 [camilonova]
1305
1306 - Improved tests so it really looks for the rabbit in the hole
1307 [camilonova]
1308
1309 - Match pypi version
1310 [camilonova]
1311
1312
1313 1.3.1 (2013-03-19)
1314 ------------------
1315
1316 - Add support for Django 1.5
1317 [camilonova]
1318
1319
1320 1.3.0 (2013-02-27)
1321 ------------------
1322
1323 - Bug fix: get_version() format string
1324 [csghormley]
1325
1326
1327 1.2.9 (2013-02-20)
1328 ------------------
1329
1330 - Add to and improve test cases
1331 [camilonova]
1332
1333
1334 1.2.8 (2013-01-23)
1335 ------------------
1336
1337 - Increased http accept header length
1338 [jslatts]
1339
1340
1341 1.2.7 (2013-01-17)
1342 ------------------
1343
1344 - Reverse proxy support
1345 [rmagee]
1346
1347 - Clean up README
1348 [martey]
1349
1350
1351 1.2.6 (2012-12-04)
1352 ------------------
1353
1354 - Remove unused import
1355 [aclark4life]
1356
1357
1358 1.2.5 (2012-11-28)
1359 ------------------
1360
1361 - Fix setup.py
1362 [aclark4life]
1363
1364 - Added ability to flag user accounts as unlockable.
1365 [kencochrane]
1366
1367 - Added ipaddress as a param to the user_locked_out signal.
1368 [kencochrane]
1369
1370 - Added a signal receiver for user_logged_out.
1371 [kencochrane]
1372
1373 - Added a signal for when a user gets locked out.
1374 [kencochrane]
1375
1376 - Added AccessLog model to log all access attempts.
1377 [kencochrane]
1378
1379 Keywords: authentication django pci security
1380 Platform: UNKNOWN
1381 Classifier: Development Status :: 5 - Production/Stable
1382 Classifier: Environment :: Web Environment
1383 Classifier: Environment :: Plugins
1384 Classifier: Framework :: Django
1385 Classifier: Framework :: Django :: 2.2
1386 Classifier: Framework :: Django :: 3.1
1387 Classifier: Framework :: Django :: 3.2
1388 Classifier: Intended Audience :: Developers
1389 Classifier: Intended Audience :: System Administrators
1390 Classifier: License :: OSI Approved :: MIT License
1391 Classifier: Operating System :: OS Independent
1392 Classifier: Programming Language :: Python
1393 Classifier: Programming Language :: Python :: 3
1394 Classifier: Programming Language :: Python :: 3.6
1395 Classifier: Programming Language :: Python :: 3.7
1396 Classifier: Programming Language :: Python :: 3.8
1397 Classifier: Programming Language :: Python :: 3.9
1398 Classifier: Programming Language :: Python :: Implementation :: CPython
1399 Classifier: Programming Language :: Python :: Implementation :: PyPy
1400 Classifier: Topic :: Internet :: Log Analysis
1401 Classifier: Topic :: Security
1402 Classifier: Topic :: System :: Logging
1403 Requires-Python: ~=3.6
0 .gitignore
1 .prospector.yaml
2 CHANGES.rst
3 LICENSE
4 README.rst
5 codecov.yml
6 manage.py
7 mypy.ini
8 pyproject.toml
9 requirements-qa.txt
10 requirements-test.txt
11 requirements.txt
12 setup.py
13 .github/dependabot.yml
14 .github/workflows/release.yml
15 .github/workflows/test.yml
16 axes/__init__.py
17 axes/admin.py
18 axes/apps.py
19 axes/attempts.py
20 axes/backends.py
21 axes/checks.py
22 axes/conf.py
23 axes/decorators.py
24 axes/exceptions.py
25 axes/helpers.py
26 axes/middleware.py
27 axes/models.py
28 axes/signals.py
29 axes/utils.py
30 axes/handlers/__init__.py
31 axes/handlers/base.py
32 axes/handlers/cache.py
33 axes/handlers/database.py
34 axes/handlers/dummy.py
35 axes/handlers/proxy.py
36 axes/handlers/test.py
37 axes/locale/de/LC_MESSAGES/django.mo
38 axes/locale/de/LC_MESSAGES/django.po
39 axes/locale/pl/LC_MESSAGES/django.mo
40 axes/locale/pl/LC_MESSAGES/django.po
41 axes/locale/ru/LC_MESSAGES/django.mo
42 axes/locale/ru/LC_MESSAGES/django.po
43 axes/locale/tr/LC_MESSAGES/django.mo
44 axes/locale/tr/LC_MESSAGES/django.po
45 axes/management/__init__.py
46 axes/management/commands/__init__.py
47 axes/management/commands/axes_list_attempts.py
48 axes/management/commands/axes_reset.py
49 axes/management/commands/axes_reset_ip.py
50 axes/management/commands/axes_reset_logs.py
51 axes/management/commands/axes_reset_user.py
52 axes/management/commands/axes_reset_username.py
53 axes/migrations/0001_initial.py
54 axes/migrations/0002_auto_20151217_2044.py
55 axes/migrations/0003_auto_20160322_0929.py
56 axes/migrations/0004_auto_20181024_1538.py
57 axes/migrations/0005_remove_accessattempt_trusted.py
58 axes/migrations/0006_remove_accesslog_trusted.py
59 axes/migrations/__init__.py
60 django_axes.egg-info/PKG-INFO
61 django_axes.egg-info/SOURCES.txt
62 django_axes.egg-info/dependency_links.txt
63 django_axes.egg-info/not-zip-safe
64 django_axes.egg-info/requires.txt
65 django_axes.egg-info/top_level.txt
66 docs/10_changelog.rst
67 docs/1_requirements.rst
68 docs/2_installation.rst
69 docs/3_usage.rst
70 docs/4_configuration.rst
71 docs/5_customization.rst
72 docs/6_integration.rst
73 docs/7_architecture.rst
74 docs/8_reference.rst
75 docs/9_development.rst
76 docs/Makefile
77 docs/conf.py
78 docs/index.rst
79 docs/images/flow.png
80 tests/__init__.py
81 tests/base.py
82 tests/settings.py
83 tests/test_admin.py
84 tests/test_attempts.py
85 tests/test_backends.py
86 tests/test_checks.py
87 tests/test_decorators.py
88 tests/test_handlers.py
89 tests/test_helpers.py
90 tests/test_logging.py
91 tests/test_login.py
92 tests/test_management.py
93 tests/test_middleware.py
94 tests/test_models.py
95 tests/test_signals.py
96 tests/urls.py
97 tests/urls_empty.py
0 django-ipware<4,>=3
1 django>=2.2
66
77 Refer to the project source code repository in
88 `GitHub <https://github.com/jazzband/django-axes/>`_ and see the
9 `Travis CI configuration <https://github.com/jazzband/django-axes/blob/master/.travis.yml>`_ and
9 `Tox configuration <https://github.com/jazzband/django-axes/blob/master/tox.ini>`_ and
1010 `Python package definition <https://github.com/jazzband/django-axes/blob/master/setup.py>`_
1111 to check if your Django and Python version are supported.
1212
13 The `Travis CI builds <https://travis-ci.org/jazzband/django-axes>`_
13 The `GitHub Actions builds <https://github.com/jazzband/django-axes/actions>`_
1414 test Axes compatibility with the Django master branch for future compatibility as well.
7272 ----------------------------
7373
7474 If you are implementing custom authentication, request middleware, or signal handlers
75 the Axes checks system might false positives in the Django checks framework.
75 the Axes checks system might generate false positives in the Django checks framework.
7676
7777 You can silence the unnecessary warnings by using the following Django settings::
7878
103103
104104 This disables the Axes middleware, authentication backend and signal receivers,
105105 which might fix errors with incompatible test configurations.
106
107
108 Disabling atomic requests
109 -------------------------
110
111 Django offers atomic database transactions that are tied to HTTP requests
112 and toggled on and off with the ``ATOMIC_REQUESTS`` configuration.
113
114 When ``ATOMIC_REQUESTS`` is set to ``True`` Django will always either perform
115 all database read and write operations in one successful atomic transaction
116 or in a case of failure roll them back, leaving no trace of the failed
117 request in the database.
118
119 However, sometimes Axes or another plugin can misbehave or not act correctly with
120 other code, preventing the login mechanisms from working due to e.g. exception
121 being thrown in some part of the code, preventing access attempts being logged
122 to database with Axes or causing similar problems.
123
124 If new attempts or log objects are not being correctly written to the Axes tables,
125 it is possible to configure Django ``ATOMIC_REQUESTS`` setting to to ``False``::
126
127 ATOMIC_REQUESTS = False
128
129 Please note that atomic requests are usually desirable when writing e.g. RESTful APIs,
130 but sometimes it can be problematic and warrant a disable.
131
132 Before disabling atomic requests or configuring them please read the relevant
133 Django documentation and make sure you know what you are configuring
134 rather than just toggling the flag on and off for testing.
135
136 Also note that the cache backend can provide correct functionality with
137 Memcached or Redis caches even with exceptions being thrown in the stack.
22 Usage
33 =====
44
5 Once Axes is is installed and configured, you can login and logout
5 Once Axes is installed and configured, you can login and logout
66 of your application via the ``django.contrib.auth`` views.
77 The attempts will be logged and visible in the Access Attempts section in admin.
88
4646 from IP under a particular username if the attempt limit has been exceeded,
4747 otherwise lock out based on IP.
4848 Default: ``False``
49 * ``AXES_LOCK_OUT_BY_USER_OR_IP``: If ``True``, prevent login
50 from if the attempt limit has been exceeded for IP or username.
51 Default: ``False``
4952 * ``AXES_USE_USER_AGENT``: If ``True``, lock out and log based on the IP address
5053 and the user agent. This means requests from different user agents but from
5154 the same IP are treated differently. This settings has no effect if the
5255 ``AXES_ONLY_USER_FAILURES`` setting is active.
5356 Default: ``False``
54 * ``AXES_LOGGER``: If set, specifies a logging mechanism for Axes to use.
55 Default: ``'axes.watch_login'``
5657 * ``AXES_HANDLER``: The path to the handler class to use.
5758 If set, overrides the default signal handler backend.
58 Default: ``'axes.handlers.database.DatabaseHandler'``
59 Default: ``'axes.handlers.database.AxesDatabaseHandler'``
5960 * ``AXES_CACHE``: The name of the cache for Axes to use.
6061 Default: ``'default'``
6162 * ``AXES_LOCKOUT_TEMPLATE``: If set, specifies a template to render when a
62 user is locked out. Template receives ``cooloff_time`` and ``failure_limit`` as
63 user is locked out. Template receives ``cooloff_timedelta``, ``cooloff_time``, ``username`` and ``failure_limit`` as
6364 context variables.
6465 Default: ``None``
6566 * ``AXES_LOCKOUT_URL``: If set, specifies a URL to redirect to on lockout. If both
9293 Default: ``None``
9394 * ``AXES_PASSWORD_FORM_FIELD``: the name of the form or credentials field that contains your users password.
9495 Default: ``password``
96 * ``AXES_SENSITIVE_PARAMETERS``: Configures POST and GET parameter values (in addition to the value of
97 ``AXES_PASSWORD_FORM_FIELD``) to mask in login attempt logging.
98 Default: ``[]``
9599 * ``AXES_NEVER_LOCKOUT_GET``: If ``True``, Axes will never lock out HTTP GET requests.
96100 Default: ``False``
97101 * ``AXES_NEVER_LOCKOUT_WHITELIST``: If ``True``, users can always login from whitelisted IP addresses.
107111 Default: ``False``
108112 * ``AXES_RESET_ON_SUCCESS``: If ``True``, a successful login will reset the number of failed logins.
109113 Default: ``False``
114 * ``AXES_ALLOWED_CORS_ORIGINS``: Configures lockout response CORS headers for XHR requests.
115 Default: ``*``
110116
111117 The configuration option precedences for the access attempt monitoring are:
112118
153153 authenticate. If you want to re-use the same function for consistency, that's
154154 fine, but Axes does not inject these changes into the authentication flow
155155 for you.
156
157
158 Customizing lockout responses
159 -----------------------------
160
161 Axes can be configured with ``AXES_LOCKOUT_CALLABLE`` to return a custom lockout response when using the plugin with e.g. DRF (Django REST Framework) or other third party libraries which require specialized formats such as JSON or XML response formats or customized response status codes.
162
163 An example of usage could be e.g. a custom view for processing lockouts.
164
165 ``example/views.py``::
166
167 from django.http import JsonResponse
168
169 def lockout(request, credentials, *args, **kwargs):
170 return JsonResponse({"status": "Locked out due to too many login failures"}, status=403)
171
172 ``settings.py``::
173
174 AXES_LOCKOUT_CALLABLE = "example.views.lockout"
88
99 In the following table
1010 **Compatible** means that a component should be fully compatible out-of-the-box,
11 **Functional** means that a component should be functional after customization, and
11 **Functional** means that a component should be functional after configuration, and
1212 **Incompatible** means that a component has been reported as non-functional with Axes.
1313
1414 ======================= ============= ============ ============ ==============
1515 Project Version Compatible Functional Incompatible
1616 ======================= ============= ============ ============ ==============
17 Django REST Framework |gte| 3.7.0 |check|
18 Django REST Framework |lt| 3.7.0 |check|
17 Django REST Framework |check|
1918 Django Allauth |check|
2019 Django Simple Captcha |check|
2120 Django OAuth Toolkit |check|
9089
9190 urlpatterns = [
9291 # Override allauth default login view with a patched view
93 url(r'^accounts/login/$', LoginView.as_view(form_class=AxesLoginForm), name='account_login'),
94 url(r'^accounts/', include('allauth.urls')),
92 path('accounts/login/', LoginView.as_view(form_class=AxesLoginForm), name='account_login'),
93 path('accounts/', include('allauth.urls')),
9594 ]
9695
9796
9897 Integration with Django REST Framework
9998 --------------------------------------
10099
101 .. note::
102 Modern versions of Django REST Framework after 3.7.0 work normally with Axes
103 out-of-the-box and require no customization in DRF.
104
105
106 Django REST Framework versions prior to 3.7.0
107 require the request object to be passed for authentication
108 by a customized DRF authentication class::
109
110 from rest_framework.authentication import BasicAuthentication
111
112 class AxesBasicAuthentication(BasicAuthentication):
113 """
114 Extended basic authentication backend class that supplies the
115 request object into the authentication call for Axes compatibility.
116
117 NOTE: This patch is only needed for DRF versions < 3.7.0.
118 """
119
120 def authenticate(self, request):
121 # NOTE: Request is added as an instance attribute in here
122 self._current_request = request
123 return super().authenticate(request)
124
125 def authenticate_credentials(self, userid, password, request=None):
126 credentials = {
127 get_user_model().USERNAME_FIELD: userid,
128 'password': password
129 }
130
131 # NOTE: Request is added as an argument to the authenticate call here
132 user = authenticate(request=request or self._current_request, **credentials)
133
134 if user is None:
135 raise exceptions.AuthenticationFailed(_('Invalid username/password.'))
136
137 if not user.is_active:
138 raise exceptions.AuthenticationFailed(_('User inactive or deleted.'))
139
140 return (user, None)
100 Django Axes requires REST Framework to be connected
101 via lockout signals for correct functionality.
102
103 You can use the following snippet in your project signals such as ``example/signals.py``::
104
105 from django.dispatch import receiver
106
107 from axes.signals import user_locked_out
108 from rest_framework.exceptions import PermissionDenied
109
110
111 @receiver(user_locked_out)
112 def raise_permission_denied(*args, **kwargs):
113 raise PermissionDenied("Too many failed login attempts")
114
115 And then configure your application to load it in ``examples/apps.py``::
116
117 from django import apps
118
119
120 class AppConfig(apps.AppConfig):
121 name = "example"
122
123 def ready(self):
124 from example import signals # noqa
125
126 Please check the Django signals documentation for more information:
127
128 https://docs.djangoproject.com/en/3.1/topics/signals/
129
130 When a user login fails a signal is emitted and PermissionDenied
131 raises a HTTP 403 reply which interrupts the login process.
132
133 This functionality was handled in the middleware for a time,
134 but that resulted in extra database requests being made for
135 each and every web request, and was migrated to signals.
141136
142137
143138 Integration with Django Simple Captcha
160155
161156 ``example/views.py``::
162157
163 from axes.utils import reset
164 from axes.helpers import get_client_ip_address
158 from axes.utils import reset_request
165159 from django.http.response import HttpResponseRedirect
166160 from django.shortcuts import render
167161 from django.urls import reverse_lazy
173167 if request.POST:
174168 form = AxesCaptchaForm(request.POST)
175169 if form.is_valid():
176 ip = get_client_ip_address(request)
177 reset(ip=ip)
170 reset_request(request)
178171 return HttpResponseRedirect(reverse_lazy('auth_login'))
179172 else:
180173 form = AxesCaptchaForm()
3636
3737 $ tox
3838
39 Tox runs the same test set that is run by Travis, and your code should be good to go if it passes.
39 Tox runs the same test set that is run by GitHub Actions, and your code should be good to go if it passes.
4040
4141 If you wish to limit the testing to specific environment(s), you can parametrize the tox run::
4242
43 $ tox -e py37-django21
43 $ tox -e py39-django22
4444
4545 After you have pushed your changes, open a pull request on GitHub for getting your code upstreamed.
55 http://www.sphinx-doc.org/en/master/usage/configuration.html
66 """
77
8 from os import environ
8 import sphinx_rtd_theme
99 from pkg_resources import get_distribution
1010
1111 import django
12 import sphinx_rtd_theme
12 from django.conf import settings
1313
14 environ.setdefault("DJANGO_SETTINGS_MODULE", "axes.tests.settings")
14 settings.configure(INSTALLED_APPS=["django", "django.contrib.auth", "axes"], DEBUG=True)
1515 django.setup()
16
1617
1718 # -- Extra custom configuration ------------------------------------------
1819
33 import sys
44
55 if __name__ == "__main__":
6 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "axes.tests.settings")
6 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
77
88 from django.core.management import execute_from_command_line
99
00 [build-system]
11 requires = ["setuptools>=30.3.0", "wheel", "setuptools_scm"]
2
3 [tool.pytest.ini_options]
4 testpaths = "tests"
5 addopts = "--cov axes --cov-append --cov-branch --cov-report term-missing --cov-report=xml"
6 DJANGO_SETTINGS_MODULE = "tests.settings"
7
8 [tool.tox]
9 legacy_tox_ini = """
10 [tox]
11 envlist =
12 py{36,37,38,39,py3}-dj{22,31,32}
13 py{38,39}-djmain
14 py38-djqa
15
16 [gh-actions]
17 python =
18 3.6: py36
19 3.7: py37
20 3.8: py38
21 3.9: py39
22 pypy3: pypy3
23
24 [gh-actions:env]
25 DJANGO =
26 2.2: dj22
27 3.1: dj31
28 3.2: dj32
29 main: djmain
30 qa: djqa
31
32 # Normal test environment runs pytest which orchestrates other tools
33 [testenv]
34 deps =
35 -r requirements-test.txt
36 dj22: django>=2.2,<2.3
37 dj31: django>=3.1,<3.2
38 dj32: django>=3.2,<3.3
39 djmain: https://github.com/django/django/archive/main.tar.gz
40 usedevelop = true
41 commands = pytest
42 setenv =
43 PYTHONDONTWRITEBYTECODE=1
44
45 # Django development version is allowed to fail the test matrix
46 [testenv:py{38,39,py3}-djmain]
47 ignore_errors = true
48 ignore_outcome = true
49
50 # QA runs type checks, linting, and code formatting checks
51 [testenv:py38-djqa]
52 deps = -r requirements-qa.txt
53 commands =
54 mypy axes
55 prospector
56 black -t py36 --check --diff axes
57 """
+0
-4
pytest.ini less more
0 [pytest]
1 addopts = --cov axes --cov-config .coveragerc --cov-append --cov-report term-missing
2 python_files = tests.py test_*.py tests_*.py *_tests.py *_test.py
3 DJANGO_SETTINGS_MODULE = axes.tests.settings
0 black==21.7b0
1 mypy==0.910
2 prospector==1.3.1
3 types-pkg_resources # Type stub
0 -e .
1 coverage==5.5
2 pytest==6.2.4
3 pytest-cov==2.12.1
4 pytest-django==4.4.0
5 pytest-subtests==0.5.0
00 -e .
1 black==19.3b0
2 coverage==5.2.1
3 mypy==0.761; python_version < '3.8' and python_implementation != 'PyPy'
4 prospector==1.2.0; python_version < '3.8' and python_implementation != 'PyPy'
5 pytest==6.0.1
6 pytest-cov==2.10.0
7 pytest-django==3.9.0
8 sphinx_rtd_theme==0.5.0
9 tox==3.18.1
1 -r requirements-qa.txt
2 -r requirements-test.txt
3 sphinx_rtd_theme==0.5.2
4 tox==3.24.1
0 [egg_info]
1 tag_build =
2 tag_date = 0
3
3535 use_scm_version=True,
3636 setup_requires=["setuptools_scm"],
3737 python_requires="~=3.6",
38 install_requires=["django>=2.0", "django-appconf>=1.0.3", "django-ipware>=3,<4"],
38 install_requires=["django>=2.2", "django-ipware>=3,<4"],
3939 include_package_data=True,
40 packages=find_packages(),
40 packages=find_packages(exclude=["tests"]),
4141 classifiers=[
4242 "Development Status :: 5 - Production/Stable",
4343 "Environment :: Web Environment",
4444 "Environment :: Plugins",
4545 "Framework :: Django",
4646 "Framework :: Django :: 2.2",
47 "Framework :: Django :: 3.0",
4847 "Framework :: Django :: 3.1",
48 "Framework :: Django :: 3.2",
4949 "Intended Audience :: Developers",
5050 "Intended Audience :: System Administrators",
5151 "License :: OSI Approved :: MIT License",
5555 "Programming Language :: Python :: 3.6",
5656 "Programming Language :: Python :: 3.7",
5757 "Programming Language :: Python :: 3.8",
58 "Programming Language :: Python :: 3.9",
5859 "Programming Language :: Python :: Implementation :: CPython",
5960 "Programming Language :: Python :: Implementation :: PyPy",
6061 "Topic :: Internet :: Log Analysis",
(New empty file)
0 from random import choice
1 from string import ascii_letters, digits
2 from time import sleep
3
4 from django.contrib.auth import get_user_model
5 from django.http import HttpRequest
6 from django.test import TestCase
7 from django.urls import reverse
8 from django.utils.timezone import now
9
10 from axes.conf import settings
11 from axes.helpers import (
12 get_cache,
13 get_client_http_accept,
14 get_client_ip_address,
15 get_client_path_info,
16 get_client_user_agent,
17 get_cool_off,
18 get_credentials,
19 get_failure_limit,
20 )
21 from axes.models import AccessAttempt, AccessLog
22 from axes.utils import reset
23
24
25 def custom_failure_limit(request, credentials):
26 return 3
27
28
29 class AxesTestCase(TestCase):
30 """
31 Test case using custom settings for testing.
32 """
33
34 VALID_USERNAME = "axes-valid-username"
35 VALID_PASSWORD = "axes-valid-password"
36 VALID_EMAIL = "axes-valid-email@example.com"
37 VALID_USER_AGENT = "axes-user-agent"
38 VALID_IP_ADDRESS = "127.0.0.1"
39
40 INVALID_USERNAME = "axes-invalid-username"
41 INVALID_PASSWORD = "axes-invalid-password"
42 INVALID_EMAIL = "axes-invalid-email@example.com"
43
44 LOCKED_MESSAGE = "Account locked: too many login attempts."
45 LOGOUT_MESSAGE = "Logged out"
46 LOGIN_FORM_KEY = '<input type="submit" value="Log in" />'
47
48 STATUS_SUCCESS = 200
49 ALLOWED = 302
50 BLOCKED = 403
51
52 def setUp(self):
53 """
54 Create a valid user for login.
55 """
56
57 self.username = self.VALID_USERNAME
58 self.password = self.VALID_PASSWORD
59 self.email = self.VALID_EMAIL
60
61 self.ip_address = self.VALID_IP_ADDRESS
62 self.user_agent = self.VALID_USER_AGENT
63 self.path_info = reverse("admin:login")
64
65 self.user = get_user_model().objects.create_superuser(
66 username=self.username, password=self.password, email=self.email
67 )
68
69 self.request = HttpRequest()
70 self.request.method = "POST"
71 self.request.META["REMOTE_ADDR"] = self.ip_address
72 self.request.META["HTTP_USER_AGENT"] = self.user_agent
73 self.request.META["PATH_INFO"] = self.path_info
74
75 self.request.axes_attempt_time = now()
76 self.request.axes_ip_address = get_client_ip_address(self.request)
77 self.request.axes_user_agent = get_client_user_agent(self.request)
78 self.request.axes_path_info = get_client_path_info(self.request)
79 self.request.axes_http_accept = get_client_http_accept(self.request)
80
81 self.credentials = get_credentials(self.username)
82
83 def tearDown(self):
84 get_cache().clear()
85
86 def get_kwargs_with_defaults(self, **kwargs):
87 defaults = {
88 "user_agent": self.user_agent,
89 "ip_address": self.ip_address,
90 "username": self.username,
91 }
92
93 defaults.update(kwargs)
94 return defaults
95
96 def create_attempt(self, **kwargs):
97 kwargs = self.get_kwargs_with_defaults(**kwargs)
98 kwargs.setdefault("failures_since_start", 1)
99 return AccessAttempt.objects.create(**kwargs)
100
101 def create_log(self, **kwargs):
102 return AccessLog.objects.create(**self.get_kwargs_with_defaults(**kwargs))
103
104 def reset(self, ip=None, username=None):
105 return reset(ip, username)
106
107 def login(
108 self,
109 is_valid_username=False,
110 is_valid_password=False,
111 remote_addr=None,
112 **kwargs
113 ):
114 """
115 Login a user.
116
117 A valid credential is used when is_valid_username is True,
118 otherwise it will use a random string to make a failed login.
119 """
120
121 if is_valid_username:
122 username = self.VALID_USERNAME
123 else:
124 username = "".join(choice(ascii_letters + digits) for _ in range(10))
125
126 if is_valid_password:
127 password = self.VALID_PASSWORD
128 else:
129 password = self.INVALID_PASSWORD
130
131 post_data = {"username": username, "password": password, **kwargs}
132
133 return self.client.post(
134 reverse("admin:login"),
135 post_data,
136 REMOTE_ADDR=remote_addr or self.ip_address,
137 HTTP_USER_AGENT=self.user_agent,
138 )
139
140 def logout(self):
141 return self.client.post(
142 reverse("admin:logout"),
143 REMOTE_ADDR=self.ip_address,
144 HTTP_USER_AGENT=self.user_agent,
145 )
146
147 def check_login(self):
148 response = self.login(is_valid_username=True, is_valid_password=True)
149 self.assertNotContains(
150 response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
151 )
152
153 def almost_lockout(self):
154 for _ in range(1, get_failure_limit(None, None)):
155 response = self.login()
156 self.assertContains(response, self.LOGIN_FORM_KEY, html=True)
157
158 def lockout(self):
159 self.almost_lockout()
160 return self.login()
161
162 def check_lockout(self):
163 response = self.lockout()
164 if settings.AXES_LOCK_OUT_AT_FAILURE == True:
165 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
166 else:
167 self.assertNotContains(
168 response, self.LOCKED_MESSAGE, status_code=self.STATUS_SUCCESS
169 )
170
171 def cool_off(self):
172 sleep(get_cool_off().total_seconds())
173
174 def check_logout(self):
175 response = self.logout()
176 self.assertContains(
177 response, self.LOGOUT_MESSAGE, status_code=self.STATUS_SUCCESS
178 )
179
180 def check_handler(self):
181 """
182 Check a handler and its basic functionality with lockouts, cool offs, login, and logout.
183
184 This is a check that is intended to successfully run for each and every new handler.
185 """
186
187 self.check_lockout()
188 self.cool_off()
189 self.check_login()
190 self.check_logout()
0 DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
1
2 CACHES = {
3 "default": {
4 # This cache backend is OK to use in development and testing
5 # but has the potential to break production setups with more than on process
6 # due to each process having their own local memory based cache
7 "BACKEND": "django.core.cache.backends.locmem.LocMemCache"
8 }
9 }
10
11 SITE_ID = 1
12
13 MIDDLEWARE = [
14 "django.middleware.common.CommonMiddleware",
15 "django.contrib.sessions.middleware.SessionMiddleware",
16 "django.contrib.auth.middleware.AuthenticationMiddleware",
17 "django.contrib.messages.middleware.MessageMiddleware",
18 "axes.middleware.AxesMiddleware",
19 ]
20
21 AUTHENTICATION_BACKENDS = [
22 "axes.backends.AxesBackend",
23 "django.contrib.auth.backends.ModelBackend",
24 ]
25
26 PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
27
28 ROOT_URLCONF = "tests.urls"
29
30 INSTALLED_APPS = [
31 "django.contrib.auth",
32 "django.contrib.contenttypes",
33 "django.contrib.sessions",
34 "django.contrib.sites",
35 "django.contrib.messages",
36 "django.contrib.admin",
37 "axes",
38 ]
39
40 TEMPLATES = [
41 {
42 "BACKEND": "django.template.backends.django.DjangoTemplates",
43 "DIRS": [],
44 "APP_DIRS": True,
45 "OPTIONS": {
46 "context_processors": [
47 "django.template.context_processors.debug",
48 "django.template.context_processors.request",
49 "django.contrib.auth.context_processors.auth",
50 "django.contrib.messages.context_processors.messages",
51 ]
52 },
53 }
54 ]
55
56 LOGGING = {
57 "version": 1,
58 "disable_existing_loggers": False,
59 "handlers": {"console": {"class": "logging.StreamHandler"}},
60 "loggers": {"axes": {"handlers": ["console"], "level": "INFO", "propagate": False}},
61 }
62
63 SECRET_KEY = "too-secret-for-test"
64
65 USE_I18N = False
66
67 USE_L10N = False
68
69 USE_TZ = False
70
71 LOGIN_REDIRECT_URL = "/admin/"
72
73 AXES_FAILURE_LIMIT = 10
74
75 DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
0 from contextlib import suppress
1 from importlib import reload
2
3 from django.contrib import admin
4 from django.test import override_settings
5
6 import axes.admin
7 from axes.models import AccessAttempt, AccessLog
8 from tests.base import AxesTestCase
9
10
11 class AxesEnableAdminFlag(AxesTestCase):
12 def setUp(self):
13 with suppress(admin.sites.NotRegistered):
14 admin.site.unregister(AccessAttempt)
15 with suppress(admin.sites.NotRegistered):
16 admin.site.unregister(AccessLog)
17
18 @override_settings(AXES_ENABLE_ADMIN=False)
19 def test_disable_admin(self):
20 reload(axes.admin)
21 self.assertFalse(admin.site.is_registered(AccessAttempt))
22 self.assertFalse(admin.site.is_registered(AccessLog))
23
24 def test_enable_admin_by_default(self):
25 reload(axes.admin)
26 self.assertTrue(admin.site.is_registered(AccessAttempt))
27 self.assertTrue(admin.site.is_registered(AccessLog))
0 from unittest.mock import patch
1
2 from django.http import HttpRequest
3 from django.test import override_settings
4 from django.utils.timezone import now
5
6 from axes.attempts import get_cool_off_threshold
7 from axes.models import AccessAttempt
8 from axes.utils import reset, reset_request
9 from tests.base import AxesTestCase
10
11
12 class GetCoolOffThresholdTestCase(AxesTestCase):
13 @override_settings(AXES_COOLOFF_TIME=42)
14 def test_get_cool_off_threshold(self):
15 timestamp = now()
16
17 with patch("axes.attempts.now", return_value=timestamp):
18 attempt_time = timestamp
19 threshold_now = get_cool_off_threshold(attempt_time)
20
21 attempt_time = None
22 threshold_none = get_cool_off_threshold(attempt_time)
23
24 self.assertEqual(threshold_now, threshold_none)
25
26 @override_settings(AXES_COOLOFF_TIME=None)
27 def test_get_cool_off_threshold_error(self):
28 with self.assertRaises(TypeError):
29 get_cool_off_threshold()
30
31
32 class ResetTestCase(AxesTestCase):
33 def test_reset(self):
34 self.create_attempt()
35 reset()
36 self.assertFalse(AccessAttempt.objects.count())
37
38 def test_reset_ip(self):
39 self.create_attempt(ip_address=self.ip_address)
40 reset(ip=self.ip_address)
41 self.assertFalse(AccessAttempt.objects.count())
42
43 def test_reset_username(self):
44 self.create_attempt(username=self.username)
45 reset(username=self.username)
46 self.assertFalse(AccessAttempt.objects.count())
47
48
49 class ResetResponseTestCase(AxesTestCase):
50 USERNAME_1 = "foo_username"
51 USERNAME_2 = "bar_username"
52 IP_1 = "127.1.0.1"
53 IP_2 = "127.1.0.2"
54
55 def setUp(self):
56 super().setUp()
57 self.create_attempt()
58 self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_1)
59 self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_2)
60 self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_1)
61 self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_2)
62 self.request = HttpRequest()
63
64 def test_reset(self):
65 reset_request(self.request)
66 self.assertEqual(AccessAttempt.objects.count(), 5)
67
68 def test_reset_ip(self):
69 self.request.META["REMOTE_ADDR"] = self.IP_1
70 reset_request(self.request)
71 self.assertEqual(AccessAttempt.objects.count(), 3)
72
73 def test_reset_username(self):
74 self.request.GET["username"] = self.USERNAME_1
75 reset_request(self.request)
76 self.assertEqual(AccessAttempt.objects.count(), 5)
77
78 def test_reset_ip_username(self):
79 self.request.GET["username"] = self.USERNAME_1
80 self.request.META["REMOTE_ADDR"] = self.IP_1
81 reset_request(self.request)
82 self.assertEqual(AccessAttempt.objects.count(), 3)
83
84 @override_settings(AXES_ONLY_USER_FAILURES=True)
85 def test_reset_user_failures(self):
86 reset_request(self.request)
87 self.assertEqual(AccessAttempt.objects.count(), 5)
88
89 @override_settings(AXES_ONLY_USER_FAILURES=True)
90 def test_reset_ip_user_failures(self):
91 self.request.META["REMOTE_ADDR"] = self.IP_1
92 reset_request(self.request)
93 self.assertEqual(AccessAttempt.objects.count(), 5)
94
95 @override_settings(AXES_ONLY_USER_FAILURES=True)
96 def test_reset_username_user_failures(self):
97 self.request.GET["username"] = self.USERNAME_1
98 reset_request(self.request)
99 self.assertEqual(AccessAttempt.objects.count(), 3)
100
101 @override_settings(AXES_ONLY_USER_FAILURES=True)
102 def test_reset_ip_username_user_failures(self):
103 self.request.GET["username"] = self.USERNAME_1
104 self.request.META["REMOTE_ADDR"] = self.IP_1
105 reset_request(self.request)
106 self.assertEqual(AccessAttempt.objects.count(), 3)
107
108 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
109 def test_reset_user_or_ip(self):
110 reset_request(self.request)
111 self.assertEqual(AccessAttempt.objects.count(), 5)
112
113 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
114 def test_reset_ip_user_or_ip(self):
115 self.request.META["REMOTE_ADDR"] = self.IP_1
116 reset_request(self.request)
117 self.assertEqual(AccessAttempt.objects.count(), 3)
118
119 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
120 def test_reset_username_user_or_ip(self):
121 self.request.GET["username"] = self.USERNAME_1
122 reset_request(self.request)
123 self.assertEqual(AccessAttempt.objects.count(), 3)
124
125 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
126 def test_reset_ip_username_user_or_ip(self):
127 self.request.GET["username"] = self.USERNAME_1
128 self.request.META["REMOTE_ADDR"] = self.IP_1
129 reset_request(self.request)
130 self.assertEqual(AccessAttempt.objects.count(), 2)
131
132 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
133 def test_reset_user_and_ip(self):
134 reset_request(self.request)
135 self.assertEqual(AccessAttempt.objects.count(), 5)
136
137 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
138 def test_reset_ip_user_and_ip(self):
139 self.request.META["REMOTE_ADDR"] = self.IP_1
140 reset_request(self.request)
141 self.assertEqual(AccessAttempt.objects.count(), 3)
142
143 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
144 def test_reset_username_user_and_ip(self):
145 self.request.GET["username"] = self.USERNAME_1
146 reset_request(self.request)
147 self.assertEqual(AccessAttempt.objects.count(), 3)
148
149 @override_settings(AXES_LOCK_OUT_BY_USER_OR_AND=True)
150 def test_reset_ip_username_user_and_ip(self):
151 self.request.GET["username"] = self.USERNAME_1
152 self.request.META["REMOTE_ADDR"] = self.IP_1
153 reset_request(self.request)
154 self.assertEqual(AccessAttempt.objects.count(), 3)
0 from unittest.mock import patch, MagicMock
1
2 from axes.backends import AxesBackend
3 from axes.exceptions import (
4 AxesBackendRequestParameterRequired,
5 AxesBackendPermissionDenied,
6 )
7 from tests.base import AxesTestCase
8
9
10 class BackendTestCase(AxesTestCase):
11 def test_authenticate_raises_on_missing_request(self):
12 request = None
13
14 with self.assertRaises(AxesBackendRequestParameterRequired):
15 AxesBackend().authenticate(request)
16
17 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
18 def test_authenticate_raises_on_locked_request(self, _):
19 request = MagicMock()
20
21 with self.assertRaises(AxesBackendPermissionDenied):
22 AxesBackend().authenticate(request)
0 from django.core.checks import run_checks, Warning # pylint: disable=redefined-builtin
1 from django.test import override_settings, modify_settings
2
3 from axes.backends import AxesBackend
4 from axes.checks import Messages, Hints, Codes
5 from tests.base import AxesTestCase
6
7
8 class CacheCheckTestCase(AxesTestCase):
9 @override_settings(
10 AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
11 CACHES={
12 "default": {
13 "BACKEND": "django.core.cache.backends.db.DatabaseCache",
14 "LOCATION": "axes_cache",
15 }
16 },
17 )
18 def test_cache_check(self):
19 warnings = run_checks()
20 self.assertEqual(warnings, [])
21
22 @override_settings(
23 AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
24 CACHES={
25 "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}
26 },
27 )
28 def test_cache_check_warnings(self):
29 warnings = run_checks()
30 warning = Warning(
31 msg=Messages.CACHE_INVALID, hint=Hints.CACHE_INVALID, id=Codes.CACHE_INVALID
32 )
33
34 self.assertEqual(warnings, [warning])
35
36 @override_settings(
37 AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler",
38 CACHES={
39 "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}
40 },
41 )
42 def test_cache_check_does_not_produce_check_warnings_with_database_handler(self):
43 warnings = run_checks()
44 self.assertEqual(warnings, [])
45
46
47 class MiddlewareCheckTestCase(AxesTestCase):
48 @modify_settings(MIDDLEWARE={"remove": ["axes.middleware.AxesMiddleware"]})
49 def test_cache_check_warnings(self):
50 warnings = run_checks()
51 warning = Warning(
52 msg=Messages.MIDDLEWARE_INVALID,
53 hint=Hints.MIDDLEWARE_INVALID,
54 id=Codes.MIDDLEWARE_INVALID,
55 )
56
57 self.assertEqual(warnings, [warning])
58
59
60 class AxesSpecializedBackend(AxesBackend):
61 pass
62
63
64 class BackendCheckTestCase(AxesTestCase):
65 @modify_settings(AUTHENTICATION_BACKENDS={"remove": ["axes.backends.AxesBackend"]})
66 def test_backend_missing(self):
67 warnings = run_checks()
68 warning = Warning(
69 msg=Messages.BACKEND_INVALID,
70 hint=Hints.BACKEND_INVALID,
71 id=Codes.BACKEND_INVALID,
72 )
73
74 self.assertEqual(warnings, [warning])
75
76 @override_settings(
77 AUTHENTICATION_BACKENDS=["tests.test_checks.AxesSpecializedBackend"]
78 )
79 def test_specialized_backend(self):
80 warnings = run_checks()
81 self.assertEqual(warnings, [])
82
83 @override_settings(
84 AUTHENTICATION_BACKENDS=["tests.test_checks.AxesNotDefinedBackend"]
85 )
86 def test_import_error(self):
87 with self.assertRaises(ImportError):
88 run_checks()
89
90 @override_settings(AUTHENTICATION_BACKENDS=["module.not_defined"])
91 def test_module_not_found_error(self):
92 with self.assertRaises(ModuleNotFoundError):
93 run_checks()
94
95
96 class DeprecatedSettingsTestCase(AxesTestCase):
97 def setUp(self):
98 self.disable_success_access_log_warning = Warning(
99 msg=Messages.SETTING_DEPRECATED.format(
100 deprecated_setting="AXES_DISABLE_SUCCESS_ACCESS_LOG"
101 ),
102 hint=Hints.SETTING_DEPRECATED,
103 id=Codes.SETTING_DEPRECATED,
104 )
105
106 @override_settings(AXES_DISABLE_SUCCESS_ACCESS_LOG=True)
107 def test_deprecated_success_access_log_flag(self):
108 warnings = run_checks()
109 self.assertEqual(warnings, [self.disable_success_access_log_warning])
0 from unittest.mock import MagicMock, patch
1
2 from django.http import HttpResponse
3
4 from axes.decorators import axes_dispatch, axes_form_invalid
5 from tests.base import AxesTestCase
6
7
8 class DecoratorTestCase(AxesTestCase):
9 SUCCESS_RESPONSE = HttpResponse(status=200, content="Dispatched")
10 LOCKOUT_RESPONSE = HttpResponse(status=403, content="Locked out")
11
12 def setUp(self):
13 self.request = MagicMock()
14 self.cls = MagicMock(return_value=self.request)
15 self.func = MagicMock(return_value=self.SUCCESS_RESPONSE)
16
17 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
18 @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
19 def test_axes_dispatch_locks_out(self, _, __):
20 response = axes_dispatch(self.func)(self.request)
21 self.assertEqual(response.content, self.LOCKOUT_RESPONSE.content)
22
23 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=True)
24 @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
25 def test_axes_dispatch_dispatches(self, _, __):
26 response = axes_dispatch(self.func)(self.request)
27 self.assertEqual(response.content, self.SUCCESS_RESPONSE.content)
28
29 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=False)
30 @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
31 def test_axes_form_invalid_locks_out(self, _, __):
32 response = axes_form_invalid(self.func)(self.cls)
33 self.assertEqual(response.content, self.LOCKOUT_RESPONSE.content)
34
35 @patch("axes.handlers.proxy.AxesProxyHandler.is_allowed", return_value=True)
36 @patch("axes.decorators.get_lockout_response", return_value=LOCKOUT_RESPONSE)
37 def test_axes_form_invalid_dispatches(self, _, __):
38 response = axes_form_invalid(self.func)(self.cls)
39 self.assertEqual(response.content, self.SUCCESS_RESPONSE.content)
0 from platform import python_implementation
1 from unittest.mock import MagicMock, patch
2
3 from pytest import mark
4
5 from django.core.cache import cache
6 from django.test import override_settings
7 from django.urls import reverse
8 from django.utils import timezone
9 from django.utils.timezone import timedelta
10
11 from axes.conf import settings
12 from axes.handlers.proxy import AxesProxyHandler
13 from axes.helpers import get_client_str
14 from axes.models import AccessAttempt, AccessLog
15 from tests.base import AxesTestCase
16
17
18 @override_settings(AXES_HANDLER="axes.handlers.base.AxesHandler")
19 class AxesHandlerTestCase(AxesTestCase):
20 @override_settings(AXES_IP_BLACKLIST=["127.0.0.1"])
21 def test_is_allowed_with_blacklisted_ip_address(self):
22 self.assertFalse(AxesProxyHandler.is_allowed(self.request))
23
24 @override_settings(
25 AXES_NEVER_LOCKOUT_WHITELIST=True, AXES_IP_WHITELIST=["127.0.0.1"]
26 )
27 def test_is_allowed_with_whitelisted_ip_address(self):
28 self.assertTrue(AxesProxyHandler.is_allowed(self.request))
29
30 @override_settings(AXES_NEVER_LOCKOUT_GET=True)
31 def test_is_allowed_with_whitelisted_method(self):
32 self.request.method = "GET"
33 self.assertTrue(AxesProxyHandler.is_allowed(self.request))
34
35 @override_settings(AXES_LOCK_OUT_AT_FAILURE=False)
36 def test_is_allowed_no_lock_out(self):
37 self.assertTrue(AxesProxyHandler.is_allowed(self.request))
38
39 @override_settings(AXES_ONLY_ADMIN_SITE=True)
40 def test_only_admin_site(self):
41 request = MagicMock()
42 request.path = "/test/"
43 self.assertTrue(AxesProxyHandler.is_allowed(self.request))
44
45 def test_is_admin_site(self):
46 request = MagicMock()
47 tests = ( # (AXES_ONLY_ADMIN_SITE, URL, Expected)
48 (True, "/test/", True),
49 (True, reverse("admin:index"), False),
50 (False, "/test/", False),
51 (False, reverse("admin:index"), False),
52 )
53
54 for setting_value, url, expected in tests:
55 with override_settings(AXES_ONLY_ADMIN_SITE=setting_value):
56 request.path = url
57 self.assertEqual(AxesProxyHandler().is_admin_site(request), expected)
58
59 @override_settings(ROOT_URLCONF="tests.urls_empty")
60 @override_settings(AXES_ONLY_ADMIN_SITE=True)
61 def test_is_admin_site_no_admin_site(self):
62 request = MagicMock()
63 request.path = "/admin/"
64 self.assertTrue(AxesProxyHandler().is_admin_site(self.request))
65
66
67 class AxesProxyHandlerTestCase(AxesTestCase):
68 def setUp(self):
69 self.sender = MagicMock()
70 self.credentials = MagicMock()
71 self.request = MagicMock()
72 self.user = MagicMock()
73 self.instance = MagicMock()
74
75 @patch("axes.handlers.proxy.AxesProxyHandler.implementation", None)
76 def test_setting_changed_signal_triggers_handler_reimport(self):
77 self.assertIsNone(AxesProxyHandler.implementation)
78
79 with self.settings(AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler"):
80 self.assertIsNotNone(AxesProxyHandler.implementation)
81
82 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
83 def test_user_login_failed(self, handler):
84 self.assertFalse(handler.user_login_failed.called)
85 AxesProxyHandler.user_login_failed(self.sender, self.credentials, self.request)
86 self.assertTrue(handler.user_login_failed.called)
87
88 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
89 def test_user_logged_in(self, handler):
90 self.assertFalse(handler.user_logged_in.called)
91 AxesProxyHandler.user_logged_in(self.sender, self.request, self.user)
92 self.assertTrue(handler.user_logged_in.called)
93
94 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
95 def test_user_logged_out(self, handler):
96 self.assertFalse(handler.user_logged_out.called)
97 AxesProxyHandler.user_logged_out(self.sender, self.request, self.user)
98 self.assertTrue(handler.user_logged_out.called)
99
100 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
101 def test_post_save_access_attempt(self, handler):
102 self.assertFalse(handler.post_save_access_attempt.called)
103 AxesProxyHandler.post_save_access_attempt(self.instance)
104 self.assertTrue(handler.post_save_access_attempt.called)
105
106 @patch("axes.handlers.proxy.AxesProxyHandler.implementation")
107 def test_post_delete_access_attempt(self, handler):
108 self.assertFalse(handler.post_delete_access_attempt.called)
109 AxesProxyHandler.post_delete_access_attempt(self.instance)
110 self.assertTrue(handler.post_delete_access_attempt.called)
111
112
113 class AxesHandlerBaseTestCase(AxesTestCase):
114 def check_whitelist(self, log):
115 with override_settings(
116 AXES_NEVER_LOCKOUT_WHITELIST=True, AXES_IP_WHITELIST=[self.ip_address]
117 ):
118 AxesProxyHandler.user_login_failed(
119 sender=None, request=self.request, credentials=self.credentials
120 )
121 client_str = get_client_str(
122 self.username, self.ip_address, self.user_agent, self.path_info
123 )
124 log.info.assert_called_with(
125 "AXES: Login failed from whitelisted client %s.", client_str
126 )
127
128 def check_empty_request(self, log, handler):
129 AxesProxyHandler.user_login_failed(sender=None, credentials={}, request=None)
130 log.error.assert_called_with(
131 f"AXES: {handler}.user_login_failed does not function without a request."
132 )
133
134
135 @override_settings(AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler")
136 class ResetAttemptsTestCase(AxesHandlerBaseTestCase):
137 """ Resetting attempts is currently implemented only for database handler """
138
139 USERNAME_1 = "foo_username"
140 USERNAME_2 = "bar_username"
141 IP_1 = "127.1.0.1"
142 IP_2 = "127.1.0.2"
143
144 def setUp(self):
145 super().setUp()
146 self.create_attempt()
147 self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_1)
148 self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_2)
149 self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_1)
150 self.create_attempt(username=self.USERNAME_2, ip_address=self.IP_2)
151
152 def test_handler_reset_attempts(self):
153 self.assertEqual(5, AxesProxyHandler.reset_attempts())
154 self.assertFalse(AccessAttempt.objects.count())
155
156 def test_handler_reset_attempts_username(self):
157 self.assertEqual(2, AxesProxyHandler.reset_attempts(username=self.USERNAME_1))
158 self.assertEqual(AccessAttempt.objects.count(), 3)
159 self.assertEqual(
160 AccessAttempt.objects.filter(ip_address=self.USERNAME_1).count(), 0
161 )
162
163 def test_handler_reset_attempts_ip(self):
164 self.assertEqual(2, AxesProxyHandler.reset_attempts(ip_address=self.IP_1))
165 self.assertEqual(AccessAttempt.objects.count(), 3)
166 self.assertEqual(AccessAttempt.objects.filter(ip_address=self.IP_1).count(), 0)
167
168 def test_handler_reset_attempts_ip_and_username(self):
169 self.assertEqual(
170 1,
171 AxesProxyHandler.reset_attempts(
172 ip_address=self.IP_1, username=self.USERNAME_1
173 ),
174 )
175 self.assertEqual(AccessAttempt.objects.count(), 4)
176 self.assertEqual(AccessAttempt.objects.filter(ip_address=self.IP_1).count(), 1)
177
178 self.create_attempt(username=self.USERNAME_1, ip_address=self.IP_1)
179 self.assertEqual(
180 1,
181 AxesProxyHandler.reset_attempts(
182 ip_address=self.IP_1, username=self.USERNAME_2
183 ),
184 )
185 self.assertEqual(
186 1,
187 AxesProxyHandler.reset_attempts(
188 ip_address=self.IP_2, username=self.USERNAME_1
189 ),
190 )
191
192 def test_handler_reset_attempts_ip_or_username(self):
193 self.assertEqual(
194 3,
195 AxesProxyHandler.reset_attempts(
196 ip_address=self.IP_1, username=self.USERNAME_1, ip_or_username=True
197 ),
198 )
199 self.assertEqual(AccessAttempt.objects.count(), 2)
200 self.assertEqual(AccessAttempt.objects.filter(ip_address=self.IP_1).count(), 0)
201 self.assertEqual(
202 AccessAttempt.objects.filter(ip_address=self.USERNAME_1).count(), 0
203 )
204
205
206 @override_settings(
207 AXES_HANDLER="axes.handlers.database.AxesDatabaseHandler",
208 AXES_COOLOFF_TIME=timedelta(seconds=2),
209 AXES_RESET_ON_SUCCESS=True,
210 )
211 @mark.xfail(
212 python_implementation() == "PyPy",
213 reason="PyPy implementation is flaky for this test",
214 strict=False,
215 )
216 class AxesDatabaseHandlerTestCase(AxesHandlerBaseTestCase):
217 def test_handler_reset_attempts(self):
218 self.create_attempt()
219 self.assertEqual(1, AxesProxyHandler.reset_attempts())
220 self.assertFalse(AccessAttempt.objects.count())
221
222 def test_handler_reset_logs(self):
223 self.create_log()
224 self.assertEqual(1, AxesProxyHandler.reset_logs())
225 self.assertFalse(AccessLog.objects.count())
226
227 def test_handler_reset_logs_older_than_42_days(self):
228 self.create_log()
229
230 then = timezone.now() - timezone.timedelta(days=90)
231 with patch("django.utils.timezone.now", return_value=then):
232 self.create_log()
233
234 self.assertEqual(AccessLog.objects.count(), 2)
235 self.assertEqual(1, AxesProxyHandler.reset_logs(age_days=42))
236 self.assertEqual(AccessLog.objects.count(), 1)
237
238 @override_settings(AXES_RESET_ON_SUCCESS=True)
239 def test_handler(self):
240 self.check_handler()
241
242 @override_settings(AXES_RESET_ON_SUCCESS=False)
243 def test_handler_without_reset(self):
244 self.check_handler()
245
246 @override_settings(AXES_FAILURE_LIMIT=lambda *args: 3)
247 def test_handler_callable_failure_limit(self):
248 self.check_handler()
249
250 @override_settings(AXES_FAILURE_LIMIT="tests.base.custom_failure_limit")
251 def test_handler_str_failure_limit(self):
252 self.check_handler()
253
254 @override_settings(AXES_FAILURE_LIMIT=None)
255 def test_handler_invalid_failure_limit(self):
256 with self.assertRaises(TypeError):
257 self.check_handler()
258
259 @override_settings(AXES_LOCK_OUT_AT_FAILURE=False)
260 def test_handler_without_lockout(self):
261 self.check_handler()
262
263 @patch("axes.handlers.database.log")
264 def test_empty_request(self, log):
265 self.check_empty_request(log, "AxesDatabaseHandler")
266
267 @patch("axes.handlers.database.log")
268 def test_whitelist(self, log):
269 self.check_whitelist(log)
270
271 @override_settings(AXES_ONLY_USER_FAILURES=True)
272 @patch("axes.handlers.database.log")
273 def test_user_login_failed_only_user_failures_with_none_username(self, log):
274 credentials = {"username": None, "password": "test"}
275 sender = MagicMock()
276 AxesProxyHandler.user_login_failed(sender, credentials, self.request)
277 attempt = AccessAttempt.objects.all()
278 self.assertEqual(0, AccessAttempt.objects.count())
279 log.warning.assert_called_with(
280 "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
281 )
282
283 def test_user_login_failed_with_none_username(self):
284 credentials = {"username": None, "password": "test"}
285 sender = MagicMock()
286 AxesProxyHandler.user_login_failed(sender, credentials, self.request)
287 attempt = AccessAttempt.objects.all()
288 self.assertEqual(1, AccessAttempt.objects.filter(username__isnull=True).count())
289
290 def test_user_login_failed_multiple_username(self):
291 configurations = (
292 (2, 1, {}, ["admin", "admin1"]),
293 (2, 1, {"AXES_USE_USER_AGENT": True}, ["admin", "admin1"]),
294 (2, 1, {"AXES_ONLY_USER_FAILURES": True}, ["admin", "admin1"]),
295 (
296 2,
297 1,
298 {"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
299 ["admin", "admin1"],
300 ),
301 (
302 1,
303 2,
304 {"AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP": True},
305 ["admin", "admin"],
306 ),
307 (1, 2, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin"]),
308 (2, 1, {"AXES_LOCK_OUT_BY_USER_OR_IP": True}, ["admin", "admin1"]),
309 )
310
311 for (
312 total_attempts_count,
313 failures_since_start,
314 overrides,
315 usernames,
316 ) in configurations:
317 with self.settings(**overrides):
318 with self.subTest(
319 total_attempts_count=total_attempts_count,
320 failures_since_start=failures_since_start,
321 settings=overrides,
322 ):
323 self.login(username=usernames[0])
324 attempt = AccessAttempt.objects.get(username=usernames[0])
325 self.assertEqual(1, attempt.failures_since_start)
326
327 # check the number of failures associated to the attempt
328 self.login(username=usernames[1])
329 attempt = AccessAttempt.objects.get(username=usernames[1])
330 self.assertEqual(failures_since_start, attempt.failures_since_start)
331
332 # check the number of distinct attempts
333 self.assertEqual(
334 total_attempts_count, AccessAttempt.objects.count()
335 )
336
337 AccessAttempt.objects.all().delete()
338
339
340 @override_settings(AXES_HANDLER="axes.handlers.cache.AxesCacheHandler")
341 class ResetAttemptsCacheHandlerTestCase(AxesHandlerBaseTestCase):
342 """ Test reset attempts for the cache handler """
343
344 USERNAME_1 = "foo_username"
345 USERNAME_2 = "bar_username"
346 IP_1 = "127.1.0.1"
347 IP_2 = "127.1.0.2"
348
349 def set_up_login_attemtps(self):
350 """Set up the login attempts."""
351 self.login(username=self.USERNAME_1, remote_addr=self.IP_1)
352 self.login(username=self.USERNAME_1, remote_addr=self.IP_2)
353 self.login(username=self.USERNAME_2, remote_addr=self.IP_1)
354 self.login(username=self.USERNAME_2, remote_addr=self.IP_2)
355
356 def check_failures(self, failures, username=None, ip_address=None):
357 if ip_address is None and username is None:
358 raise NotImplementedError("Must supply ip_address or username")
359 try:
360 prev_ip = self.request.META["REMOTE_ADDR"]
361 credentials = {"username": username} if username else {}
362 if ip_address is not None:
363 self.request.META["REMOTE_ADDR"] = ip_address
364 self.assertEqual(
365 failures,
366 AxesProxyHandler.get_failures(self.request, credentials=credentials),
367 )
368 finally:
369 self.request.META["REMOTE_ADDR"] = prev_ip
370
371 def test_handler_reset_attempts(self):
372 with self.assertRaises(NotImplementedError):
373 AxesProxyHandler.reset_attempts()
374
375 @override_settings(AXES_ONLY_USER_FAILURES=True)
376 def test_handler_reset_attempts_username(self):
377 self.set_up_login_attemtps()
378 self.assertEqual(
379 2,
380 AxesProxyHandler.get_failures(
381 self.request, credentials={"username": self.USERNAME_1}
382 ),
383 )
384 self.assertEqual(
385 2,
386 AxesProxyHandler.get_failures(
387 self.request, credentials={"username": self.USERNAME_2}
388 ),
389 )
390 self.assertEqual(1, AxesProxyHandler.reset_attempts(username=self.USERNAME_1))
391 self.assertEqual(
392 0,
393 AxesProxyHandler.get_failures(
394 self.request, credentials={"username": self.USERNAME_1}
395 ),
396 )
397 self.assertEqual(
398 2,
399 AxesProxyHandler.get_failures(
400 self.request, credentials={"username": self.USERNAME_2}
401 ),
402 )
403
404 def test_handler_reset_attempts_ip(self):
405 self.set_up_login_attemtps()
406 self.check_failures(2, ip_address=self.IP_1)
407 self.assertEqual(1, AxesProxyHandler.reset_attempts(ip_address=self.IP_1))
408 self.check_failures(0, ip_address=self.IP_1)
409 self.check_failures(2, ip_address=self.IP_2)
410
411 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
412 def test_handler_reset_attempts_ip_and_username(self):
413 self.set_up_login_attemtps()
414 self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_1)
415 self.check_failures(1, username=self.USERNAME_2, ip_address=self.IP_1)
416 self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_2)
417 self.assertEqual(
418 1,
419 AxesProxyHandler.reset_attempts(
420 ip_address=self.IP_1, username=self.USERNAME_1
421 ),
422 )
423 self.check_failures(0, username=self.USERNAME_1, ip_address=self.IP_1)
424 self.check_failures(1, username=self.USERNAME_2, ip_address=self.IP_1)
425 self.check_failures(1, username=self.USERNAME_1, ip_address=self.IP_2)
426
427 def test_handler_reset_attempts_ip_or_username(self):
428 with self.assertRaises(NotImplementedError):
429 AxesProxyHandler.reset_attempts()
430
431
432 @override_settings(
433 AXES_HANDLER="axes.handlers.cache.AxesCacheHandler",
434 AXES_COOLOFF_TIME=timedelta(seconds=1),
435 )
436 class AxesCacheHandlerTestCase(AxesHandlerBaseTestCase):
437 @override_settings(AXES_RESET_ON_SUCCESS=True)
438 def test_handler(self):
439 self.check_handler()
440
441 @override_settings(AXES_RESET_ON_SUCCESS=False)
442 def test_handler_without_reset(self):
443 self.check_handler()
444
445 @override_settings(AXES_LOCK_OUT_AT_FAILURE=False)
446 def test_handler_without_lockout(self):
447 self.check_handler()
448
449 @patch("axes.handlers.cache.log")
450 def test_empty_request(self, log):
451 self.check_empty_request(log, "AxesCacheHandler")
452
453 @patch("axes.handlers.cache.log")
454 def test_whitelist(self, log):
455 self.check_whitelist(log)
456
457 @override_settings(AXES_ONLY_USER_FAILURES=True)
458 @patch.object(cache, "set")
459 @patch("axes.handlers.cache.log")
460 def test_user_login_failed_only_user_failures_with_none_username(
461 self, log, cache_set
462 ):
463 credentials = {"username": None, "password": "test"}
464 sender = MagicMock()
465 AxesProxyHandler.user_login_failed(sender, credentials, self.request)
466 self.assertFalse(cache_set.called)
467 log.warning.assert_called_with(
468 "AXES: Username is None and AXES_ONLY_USER_FAILURES is enabled, new record will NOT be created."
469 )
470
471 @patch.object(cache, "set")
472 def test_user_login_failed_with_none_username(self, cache_set):
473 credentials = {"username": None, "password": "test"}
474 sender = MagicMock()
475 AxesProxyHandler.user_login_failed(sender, credentials, self.request)
476 self.assertTrue(cache_set.called)
477
478
479 @override_settings(AXES_HANDLER="axes.handlers.dummy.AxesDummyHandler")
480 class AxesDummyHandlerTestCase(AxesHandlerBaseTestCase):
481 def test_handler(self):
482 for _ in range(settings.AXES_FAILURE_LIMIT):
483 self.login()
484
485 self.check_login()
486
487 def test_handler_is_allowed(self):
488 self.assertEqual(True, AxesProxyHandler.is_allowed(self.request, {}))
489
490 def test_handler_get_failures(self):
491 self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
492
493
494 @override_settings(AXES_HANDLER="axes.handlers.test.AxesTestHandler")
495 class AxesTestHandlerTestCase(AxesHandlerBaseTestCase):
496 def test_handler_reset_attempts(self):
497 self.assertEqual(0, AxesProxyHandler.reset_attempts())
498
499 def test_handler_reset_logs(self):
500 self.assertEqual(0, AxesProxyHandler.reset_logs())
501
502 def test_handler_is_allowed(self):
503 self.assertEqual(True, AxesProxyHandler.is_allowed(self.request, {}))
504
505 def test_handler_get_failures(self):
506 self.assertEqual(0, AxesProxyHandler.get_failures(self.request, {}))
0 from datetime import timedelta
1 from hashlib import md5
2 from unittest.mock import patch
3
4 from django.contrib.auth import get_user_model
5 from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest
6 from django.test import override_settings, RequestFactory
7
8 from axes.apps import AppConfig
9 from axes.helpers import (
10 get_cache_timeout,
11 get_client_str,
12 get_client_username,
13 get_client_cache_key,
14 get_client_parameters,
15 get_cool_off,
16 get_cool_off_iso8601,
17 get_lockout_response,
18 is_client_ip_address_blacklisted,
19 is_client_ip_address_whitelisted,
20 is_client_method_whitelisted,
21 is_ip_address_in_blacklist,
22 is_ip_address_in_whitelist,
23 is_user_attempt_whitelisted,
24 toggleable,
25 cleanse_parameters,
26 )
27 from axes.models import AccessAttempt
28 from tests.base import AxesTestCase
29
30
31 @override_settings(AXES_ENABLED=False)
32 class AxesDisabledTestCase(AxesTestCase):
33 def test_initialize(self):
34 AppConfig.logging_initialized = False
35 AppConfig.initialize()
36 self.assertFalse(AppConfig.logging_initialized)
37
38 def test_toggleable(self):
39 def is_true():
40 return True
41
42 self.assertTrue(is_true())
43 self.assertIsNone(toggleable(is_true)())
44
45
46 class CacheTestCase(AxesTestCase):
47 @override_settings(AXES_COOLOFF_TIME=3) # hours
48 def test_get_cache_timeout_integer(self):
49 timeout_seconds = float(60 * 60 * 3)
50 self.assertEqual(get_cache_timeout(), timeout_seconds)
51
52 @override_settings(AXES_COOLOFF_TIME=timedelta(seconds=420))
53 def test_get_cache_timeout_timedelta(self):
54 self.assertEqual(get_cache_timeout(), 420)
55
56 @override_settings(AXES_COOLOFF_TIME=None)
57 def test_get_cache_timeout_none(self):
58 self.assertEqual(get_cache_timeout(), None)
59
60
61 class TimestampTestCase(AxesTestCase):
62 def test_iso8601(self):
63 """
64 Test get_cool_off_iso8601 correctly translates datetime.timedelta to ISO 8601 formatted duration.
65 """
66
67 expected = {
68 timedelta(days=1, hours=25, minutes=42, seconds=8): "P2DT1H42M8S",
69 timedelta(days=7, seconds=342): "P7DT5M42S",
70 timedelta(days=0, hours=2, minutes=42): "PT2H42M",
71 timedelta(hours=20, seconds=42): "PT20H42S",
72 timedelta(seconds=300): "PT5M",
73 timedelta(seconds=9005): "PT2H30M5S",
74 timedelta(minutes=9005): "P6DT6H5M",
75 timedelta(days=15): "P15D",
76 }
77
78 for delta, iso_duration in expected.items():
79 with self.subTest(iso_duration):
80 self.assertEqual(get_cool_off_iso8601(delta), iso_duration)
81
82
83 class ClientStringTestCase(AxesTestCase):
84 @staticmethod
85 def get_expected_client_str(*args, **kwargs):
86 client_str_template = '{{username: "{0}", ip_address: "{1}", user_agent: "{2}", path_info: "{3}"}}'
87 return client_str_template.format(*args, **kwargs)
88
89 @override_settings(AXES_VERBOSE=True)
90 def test_verbose_ip_only_client_details(self):
91 username = "test@example.com"
92 ip_address = "127.0.0.1"
93 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
94 path_info = "/admin/"
95
96 expected = self.get_expected_client_str(
97 username, ip_address, user_agent, path_info
98 )
99 actual = get_client_str(username, ip_address, user_agent, path_info)
100
101 self.assertEqual(expected, actual)
102
103 @override_settings(AXES_VERBOSE=True)
104 def test_imbalanced_quotes(self):
105 username = "butterfly.. },,,"
106 ip_address = "127.0.0.1"
107 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
108 path_info = "/admin/"
109
110 expected = self.get_expected_client_str(
111 username, ip_address, user_agent, path_info
112 )
113 actual = get_client_str(username, ip_address, user_agent, path_info)
114
115 self.assertEqual(expected, actual)
116
117 @override_settings(AXES_VERBOSE=True)
118 def test_verbose_ip_only_client_details_tuple(self):
119 username = "test@example.com"
120 ip_address = "127.0.0.1"
121 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
122 path_info = ("admin", "login")
123
124 expected = self.get_expected_client_str(
125 username, ip_address, user_agent, path_info[0]
126 )
127 actual = get_client_str(username, ip_address, user_agent, path_info)
128
129 self.assertEqual(expected, actual)
130
131 @override_settings(AXES_VERBOSE=False)
132 def test_non_verbose_ip_only_client_details(self):
133 username = "test@example.com"
134 ip_address = "127.0.0.1"
135 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
136 path_info = "/admin/"
137
138 expected = '{ip_address: "127.0.0.1", path_info: "/admin/"}'
139 actual = get_client_str(username, ip_address, user_agent, path_info)
140
141 self.assertEqual(expected, actual)
142
143 @override_settings(AXES_ONLY_USER_FAILURES=True)
144 @override_settings(AXES_VERBOSE=True)
145 def test_verbose_user_only_client_details(self):
146 username = "test@example.com"
147 ip_address = "127.0.0.1"
148 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
149 path_info = "/admin/"
150
151 expected = self.get_expected_client_str(
152 username, ip_address, user_agent, path_info
153 )
154 actual = get_client_str(username, ip_address, user_agent, path_info)
155
156 self.assertEqual(expected, actual)
157
158 @override_settings(AXES_ONLY_USER_FAILURES=True)
159 @override_settings(AXES_VERBOSE=False)
160 def test_non_verbose_user_only_client_details(self):
161 username = "test@example.com"
162 ip_address = "127.0.0.1"
163 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
164 path_info = "/admin/"
165
166 expected = '{username: "test@example.com", path_info: "/admin/"}'
167 actual = get_client_str(username, ip_address, user_agent, path_info)
168
169 self.assertEqual(expected, actual)
170
171 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
172 @override_settings(AXES_VERBOSE=True)
173 def test_verbose_user_ip_combo_client_details(self):
174 username = "test@example.com"
175 ip_address = "127.0.0.1"
176 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
177 path_info = "/admin/"
178
179 expected = self.get_expected_client_str(
180 username, ip_address, user_agent, path_info
181 )
182 actual = get_client_str(username, ip_address, user_agent, path_info)
183
184 self.assertEqual(expected, actual)
185
186 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
187 @override_settings(AXES_VERBOSE=False)
188 def test_non_verbose_user_ip_combo_client_details(self):
189 username = "test@example.com"
190 ip_address = "127.0.0.1"
191 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
192 path_info = "/admin/"
193
194 expected = '{username: "test@example.com", ip_address: "127.0.0.1", path_info: "/admin/"}'
195 actual = get_client_str(username, ip_address, user_agent, path_info)
196
197 self.assertEqual(expected, actual)
198
199 @override_settings(AXES_USE_USER_AGENT=True)
200 @override_settings(AXES_VERBOSE=True)
201 def test_verbose_user_agent_client_details(self):
202 username = "test@example.com"
203 ip_address = "127.0.0.1"
204 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
205 path_info = "/admin/"
206
207 expected = self.get_expected_client_str(
208 username, ip_address, user_agent, path_info
209 )
210 actual = get_client_str(username, ip_address, user_agent, path_info)
211
212 self.assertEqual(expected, actual)
213
214 @override_settings(AXES_USE_USER_AGENT=True)
215 @override_settings(AXES_VERBOSE=False)
216 def test_non_verbose_user_agent_client_details(self):
217 username = "test@example.com"
218 ip_address = "127.0.0.1"
219 user_agent = "Googlebot/2.1 (+http://www.googlebot.com/bot.html)"
220 path_info = "/admin/"
221
222 expected = '{ip_address: "127.0.0.1", user_agent: "Googlebot/2.1 (+http://www.googlebot.com/bot.html)", path_info: "/admin/"}'
223 actual = get_client_str(username, ip_address, user_agent, path_info)
224
225 self.assertEqual(expected, actual)
226
227 @override_settings(AXES_CLIENT_STR_CALLABLE="tests.test_helpers.get_dummy_client_str")
228 def test_get_client_str_callable(self):
229 self.assertEqual(
230 get_client_str("username", "ip_address", "user_agent", "path_info"),
231 "client string"
232 )
233
234 def get_dummy_client_str(username, ip_address, user_agent, path_info):
235 return "client string"
236
237
238 class ClientParametersTestCase(AxesTestCase):
239 @override_settings(AXES_ONLY_USER_FAILURES=True)
240 def test_get_filter_kwargs_user(self):
241 self.assertEqual(
242 get_client_parameters(self.username, self.ip_address, self.user_agent),
243 [{"username": self.username}],
244 )
245
246 @override_settings(
247 AXES_ONLY_USER_FAILURES=False,
248 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
249 AXES_USE_USER_AGENT=False,
250 )
251 def test_get_filter_kwargs_ip(self):
252 self.assertEqual(
253 get_client_parameters(self.username, self.ip_address, self.user_agent),
254 [{"ip_address": self.ip_address}],
255 )
256
257 @override_settings(
258 AXES_ONLY_USER_FAILURES=False,
259 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
260 AXES_USE_USER_AGENT=False,
261 )
262 def test_get_filter_kwargs_user_and_ip(self):
263 self.assertEqual(
264 get_client_parameters(self.username, self.ip_address, self.user_agent),
265 [{"username": self.username, "ip_address": self.ip_address}],
266 )
267
268 @override_settings(
269 AXES_ONLY_USER_FAILURES=False,
270 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
271 AXES_LOCK_OUT_BY_USER_OR_IP=True,
272 AXES_USE_USER_AGENT=False,
273 )
274 def test_get_filter_kwargs_user_or_ip(self):
275 self.assertEqual(
276 get_client_parameters(self.username, self.ip_address, self.user_agent),
277 [{"username": self.username}, {"ip_address": self.ip_address}],
278 )
279
280 @override_settings(
281 AXES_ONLY_USER_FAILURES=False,
282 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=False,
283 AXES_USE_USER_AGENT=True,
284 )
285 def test_get_filter_kwargs_ip_and_agent(self):
286 self.assertEqual(
287 get_client_parameters(self.username, self.ip_address, self.user_agent),
288 [{"ip_address": self.ip_address}, {"user_agent": self.user_agent}],
289 )
290
291 @override_settings(
292 AXES_ONLY_USER_FAILURES=False,
293 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True,
294 AXES_USE_USER_AGENT=True,
295 )
296 def test_get_filter_kwargs_user_ip_agent(self):
297 self.assertEqual(
298 get_client_parameters(self.username, self.ip_address, self.user_agent),
299 [
300 {"username": self.username, "ip_address": self.ip_address},
301 {"user_agent": self.user_agent},
302 ],
303 )
304
305
306 class ClientCacheKeyTestCase(AxesTestCase):
307 def test_get_cache_key(self):
308 """
309 Test the cache key format.
310 """
311
312 cache_hash_digest = md5(self.ip_address.encode()).hexdigest()
313 cache_hash_key = f"axes-{cache_hash_digest}"
314
315 # Getting cache key from request
316 request_factory = RequestFactory()
317 request = request_factory.post(
318 "/admin/login/", data={"username": self.username, "password": "test"}
319 )
320
321 self.assertEqual([cache_hash_key], get_client_cache_key(request))
322
323 # Getting cache key from AccessAttempt Object
324 attempt = AccessAttempt(
325 user_agent="<unknown>",
326 ip_address=self.ip_address,
327 username=self.username,
328 get_data="",
329 post_data="",
330 http_accept=request.META.get("HTTP_ACCEPT", "<unknown>"),
331 path_info=request.META.get("PATH_INFO", "<unknown>"),
332 failures_since_start=0,
333 )
334
335 self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
336
337 def test_get_cache_key_empty_ip_address(self):
338 """
339 Simulate an empty IP address in the request.
340 """
341
342 empty_ip_address = ""
343
344 cache_hash_digest = md5(empty_ip_address.encode()).hexdigest()
345 cache_hash_key = f"axes-{cache_hash_digest}"
346
347 # Getting cache key from request
348 request_factory = RequestFactory()
349 request = request_factory.post(
350 "/admin/login/",
351 data={"username": self.username, "password": "test"},
352 REMOTE_ADDR=empty_ip_address,
353 )
354
355 self.assertEqual([cache_hash_key], get_client_cache_key(request))
356
357 # Getting cache key from AccessAttempt Object
358 attempt = AccessAttempt(
359 user_agent="<unknown>",
360 ip_address=empty_ip_address,
361 username=self.username,
362 get_data="",
363 post_data="",
364 http_accept=request.META.get("HTTP_ACCEPT", "<unknown>"),
365 path_info=request.META.get("PATH_INFO", "<unknown>"),
366 failures_since_start=0,
367 )
368
369 self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
370
371 def test_get_cache_key_credentials(self):
372 """
373 Test the cache key format.
374 """
375
376 ip_address = self.ip_address
377 cache_hash_digest = md5(ip_address.encode()).hexdigest()
378 cache_hash_key = f"axes-{cache_hash_digest}"
379
380 # Getting cache key from request
381 request_factory = RequestFactory()
382 request = request_factory.post(
383 "/admin/login/", data={"username": self.username, "password": "test"}
384 )
385
386 # Difference between the upper test: new call signature with credentials
387 credentials = {"username": self.username}
388
389 self.assertEqual([cache_hash_key], get_client_cache_key(request, credentials))
390
391 # Getting cache key from AccessAttempt Object
392 attempt = AccessAttempt(
393 user_agent="<unknown>",
394 ip_address=ip_address,
395 username=self.username,
396 get_data="",
397 post_data="",
398 http_accept=request.META.get("HTTP_ACCEPT", "<unknown>"),
399 path_info=request.META.get("PATH_INFO", "<unknown>"),
400 failures_since_start=0,
401 )
402 self.assertEqual([cache_hash_key], get_client_cache_key(attempt))
403
404
405 class UsernameTestCase(AxesTestCase):
406 @override_settings(AXES_USERNAME_FORM_FIELD="username")
407 def test_default_get_client_username(self):
408 expected = "test-username"
409
410 request = HttpRequest()
411 request.POST["username"] = expected
412
413 actual = get_client_username(request)
414
415 self.assertEqual(expected, actual)
416
417 def test_default_get_client_username_drf(self):
418 class DRFRequest:
419 def __init__(self):
420 self.data = {}
421 self.POST = {}
422
423 expected = "test-username"
424
425 request = DRFRequest()
426 request.data["username"] = expected
427
428 actual = get_client_username(request)
429
430 self.assertEqual(expected, actual)
431
432 @override_settings(AXES_USERNAME_FORM_FIELD="username")
433 def test_default_get_client_username_credentials(self):
434 expected = "test-username"
435 expected_in_credentials = "test-credentials-username"
436
437 request = HttpRequest()
438 request.POST["username"] = expected
439 credentials = {"username": expected_in_credentials}
440
441 actual = get_client_username(request, credentials)
442
443 self.assertEqual(expected_in_credentials, actual)
444
445 def sample_customize_username(request, credentials):
446 return "prefixed-" + request.POST.get("username")
447
448 @override_settings(AXES_USERNAME_FORM_FIELD="username")
449 @override_settings(AXES_USERNAME_CALLABLE=sample_customize_username)
450 def test_custom_get_client_username_from_request(self):
451 provided = "test-username"
452 expected = "prefixed-" + provided
453 provided_in_credentials = "test-credentials-username"
454
455 request = HttpRequest()
456 request.POST["username"] = provided
457 credentials = {"username": provided_in_credentials}
458
459 actual = get_client_username(request, credentials)
460
461 self.assertEqual(expected, actual)
462
463 def sample_customize_username_credentials(request, credentials):
464 return "prefixed-" + credentials.get("username")
465
466 @override_settings(AXES_USERNAME_FORM_FIELD="username")
467 @override_settings(AXES_USERNAME_CALLABLE=sample_customize_username_credentials)
468 def test_custom_get_client_username_from_credentials(self):
469 provided = "test-username"
470 provided_in_credentials = "test-credentials-username"
471 expected_in_credentials = "prefixed-" + provided_in_credentials
472
473 request = HttpRequest()
474 request.POST["username"] = provided
475 credentials = {"username": provided_in_credentials}
476
477 actual = get_client_username(request, credentials)
478
479 self.assertEqual(expected_in_credentials, actual)
480
481 @override_settings(
482 AXES_USERNAME_CALLABLE=lambda request, credentials: "example"
483 ) # pragma: no cover
484 def test_get_client_username(self):
485 self.assertEqual(get_client_username(HttpRequest(), {}), "example")
486
487 @override_settings(AXES_USERNAME_CALLABLE=lambda request: None) # pragma: no cover
488 def test_get_client_username_invalid_callable_too_few_arguments(self):
489 with self.assertRaises(TypeError):
490 get_client_username(HttpRequest(), {})
491
492 @override_settings(
493 AXES_USERNAME_CALLABLE=lambda request, credentials, extra: None
494 ) # pragma: no cover
495 def test_get_client_username_invalid_callable_too_many_arguments(self):
496 with self.assertRaises(TypeError):
497 get_client_username(HttpRequest(), {})
498
499 @override_settings(AXES_USERNAME_CALLABLE=True)
500 def test_get_client_username_not_callable(self):
501 with self.assertRaises(TypeError):
502 get_client_username(HttpRequest(), {})
503
504 @override_settings(AXES_USERNAME_CALLABLE="tests.test_helpers.get_username")
505 def test_get_client_username_str(self):
506 self.assertEqual(get_client_username(HttpRequest(), {}), "username")
507
508
509 def get_username(request, credentials: dict) -> str:
510 return "username"
511
512
513 class IPWhitelistTestCase(AxesTestCase):
514 def setUp(self):
515 self.request = HttpRequest()
516 self.request.method = "POST"
517 self.request.META["REMOTE_ADDR"] = "127.0.0.1"
518 self.request.axes_ip_address = "127.0.0.1"
519
520 @override_settings(AXES_IP_WHITELIST=None)
521 def test_ip_in_whitelist_none(self):
522 self.assertFalse(is_ip_address_in_whitelist("127.0.0.2"))
523
524 @override_settings(AXES_IP_WHITELIST=["127.0.0.1"])
525 def test_ip_in_whitelist(self):
526 self.assertTrue(is_ip_address_in_whitelist("127.0.0.1"))
527 self.assertFalse(is_ip_address_in_whitelist("127.0.0.2"))
528
529 @override_settings(AXES_IP_BLACKLIST=None)
530 def test_ip_in_blacklist_none(self):
531 self.assertFalse(is_ip_address_in_blacklist("127.0.0.2"))
532
533 @override_settings(AXES_IP_BLACKLIST=["127.0.0.1"])
534 def test_ip_in_blacklist(self):
535 self.assertTrue(is_ip_address_in_blacklist("127.0.0.1"))
536 self.assertFalse(is_ip_address_in_blacklist("127.0.0.2"))
537
538 @override_settings(AXES_IP_BLACKLIST=["127.0.0.1"])
539 def test_is_client_ip_address_blacklisted_ip_in_blacklist(self):
540 self.assertTrue(is_client_ip_address_blacklisted(self.request))
541
542 @override_settings(AXES_IP_BLACKLIST=["127.0.0.2"])
543 def test_is_is_client_ip_address_blacklisted_ip_not_in_blacklist(self):
544 self.assertFalse(is_client_ip_address_blacklisted(self.request))
545
546 @override_settings(AXES_NEVER_LOCKOUT_WHITELIST=True)
547 @override_settings(AXES_IP_WHITELIST=["127.0.0.1"])
548 def test_is_client_ip_address_blacklisted_ip_in_whitelist(self):
549 self.assertFalse(is_client_ip_address_blacklisted(self.request))
550
551 @override_settings(AXES_ONLY_WHITELIST=True)
552 @override_settings(AXES_IP_WHITELIST=["127.0.0.2"])
553 def test_is_already_locked_ip_not_in_whitelist(self):
554 self.assertTrue(is_client_ip_address_blacklisted(self.request))
555
556 @override_settings(AXES_NEVER_LOCKOUT_WHITELIST=True)
557 @override_settings(AXES_IP_WHITELIST=["127.0.0.1"])
558 def test_is_client_ip_address_whitelisted_never_lockout(self):
559 self.assertTrue(is_client_ip_address_whitelisted(self.request))
560
561 @override_settings(AXES_ONLY_WHITELIST=True)
562 @override_settings(AXES_IP_WHITELIST=["127.0.0.1"])
563 def test_is_client_ip_address_whitelisted_only_allow(self):
564 self.assertTrue(is_client_ip_address_whitelisted(self.request))
565
566 @override_settings(AXES_ONLY_WHITELIST=True)
567 @override_settings(AXES_IP_WHITELIST=["127.0.0.2"])
568 def test_is_client_ip_address_whitelisted_not(self):
569 self.assertFalse(is_client_ip_address_whitelisted(self.request))
570
571
572 class MethodWhitelistTestCase(AxesTestCase):
573 def setUp(self):
574 self.request = HttpRequest()
575 self.request.method = "GET"
576
577 @override_settings(AXES_NEVER_LOCKOUT_GET=True)
578 def test_is_client_method_whitelisted(self):
579 self.assertTrue(is_client_method_whitelisted(self.request))
580
581 @override_settings(AXES_NEVER_LOCKOUT_GET=False)
582 def test_is_client_method_whitelisted_not(self):
583 self.assertFalse(is_client_method_whitelisted(self.request))
584
585
586 class LockoutResponseTestCase(AxesTestCase):
587 def setUp(self):
588 self.request = HttpRequest()
589
590 @override_settings(AXES_COOLOFF_TIME=42)
591 def test_get_lockout_response_cool_off(self):
592 get_lockout_response(request=self.request)
593
594 @override_settings(AXES_LOCKOUT_TEMPLATE="example.html")
595 @patch("axes.helpers.render")
596 def test_get_lockout_response_lockout_template(self, render):
597 self.assertFalse(render.called)
598 get_lockout_response(request=self.request)
599 self.assertTrue(render.called)
600
601 @override_settings(AXES_LOCKOUT_URL="https://example.com")
602 def test_get_lockout_response_lockout_url(self):
603 response = get_lockout_response(request=self.request)
604 self.assertEqual(type(response), HttpResponseRedirect)
605
606 def test_get_lockout_response_lockout_json(self):
607 self.request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest"
608 response = get_lockout_response(request=self.request)
609 self.assertEqual(type(response), JsonResponse)
610
611 def test_get_lockout_response_lockout_response(self):
612 response = get_lockout_response(request=self.request)
613 self.assertEqual(type(response), HttpResponse)
614
615
616 def mock_get_cool_off_str():
617 return timedelta(seconds=30)
618
619
620 class AxesCoolOffTestCase(AxesTestCase):
621 @override_settings(AXES_COOLOFF_TIME=None)
622 def test_get_cool_off_none(self):
623 self.assertIsNone(get_cool_off())
624
625 @override_settings(AXES_COOLOFF_TIME=2)
626 def test_get_cool_off_int(self):
627 self.assertEqual(get_cool_off(), timedelta(hours=2))
628
629 @override_settings(AXES_COOLOFF_TIME=lambda: timedelta(seconds=30))
630 def test_get_cool_off_callable(self):
631 self.assertEqual(get_cool_off(), timedelta(seconds=30))
632
633 @override_settings(AXES_COOLOFF_TIME="tests.test_helpers.mock_get_cool_off_str")
634 def test_get_cool_off_path(self):
635 self.assertEqual(get_cool_off(), timedelta(seconds=30))
636
637
638 def mock_is_whitelisted(request, credentials):
639 return True
640
641
642 class AxesWhitelistTestCase(AxesTestCase):
643 def setUp(self):
644 self.user_model = get_user_model()
645 self.user = self.user_model.objects.create(username="jane.doe")
646 self.request = HttpRequest()
647 self.credentials = dict()
648
649 def test_is_whitelisted(self):
650 self.assertFalse(is_user_attempt_whitelisted(self.request, self.credentials))
651
652 @override_settings(AXES_WHITELIST_CALLABLE=mock_is_whitelisted)
653 def test_is_whitelisted_override_callable(self):
654 self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials))
655
656 @override_settings(AXES_WHITELIST_CALLABLE="tests.test_helpers.mock_is_whitelisted")
657 def test_is_whitelisted_override_path(self):
658 self.assertTrue(is_user_attempt_whitelisted(self.request, self.credentials))
659
660 @override_settings(AXES_WHITELIST_CALLABLE=42)
661 def test_is_whitelisted_override_invalid(self):
662 with self.assertRaises(TypeError):
663 is_user_attempt_whitelisted(self.request, self.credentials)
664
665
666 def mock_get_lockout_response(request, credentials):
667 return HttpResponse(status=400)
668
669
670 class AxesLockoutTestCase(AxesTestCase):
671 def setUp(self):
672 self.request = HttpRequest()
673 self.credentials = dict()
674
675 def test_get_lockout_response(self):
676 response = get_lockout_response(self.request, self.credentials)
677 self.assertEqual(403, response.status_code)
678
679 @override_settings(AXES_LOCKOUT_CALLABLE=mock_get_lockout_response)
680 def test_get_lockout_response_override_callable(self):
681 response = get_lockout_response(self.request, self.credentials)
682 self.assertEqual(400, response.status_code)
683
684 @override_settings(
685 AXES_LOCKOUT_CALLABLE="tests.test_helpers.mock_get_lockout_response"
686 )
687 def test_get_lockout_response_override_path(self):
688 response = get_lockout_response(self.request, self.credentials)
689 self.assertEqual(400, response.status_code)
690
691 @override_settings(AXES_LOCKOUT_CALLABLE=42)
692 def test_get_lockout_response_override_invalid(self):
693 with self.assertRaises(TypeError):
694 get_lockout_response(self.request, self.credentials)
695
696
697 class AxesCleanseParamsTestCase(AxesTestCase):
698 def setUp(self):
699 self.parameters = {
700 "username": "test_user",
701 "password": "test_password",
702 "other_sensitive_data": "sensitive",
703 }
704
705 def test_cleanse_parameters(self):
706 cleansed = cleanse_parameters(self.parameters)
707 self.assertEqual("test_user", cleansed["username"])
708 self.assertEqual("********************", cleansed["password"])
709 self.assertEqual("sensitive", cleansed["other_sensitive_data"])
710
711 @override_settings(AXES_SENSITIVE_PARAMETERS=["other_sensitive_data"])
712 def test_cleanse_parameters_override_sensitive(self):
713 cleansed = cleanse_parameters(self.parameters)
714 self.assertEqual("test_user", cleansed["username"])
715 self.assertEqual("********************", cleansed["password"])
716 self.assertEqual("********************", cleansed["other_sensitive_data"])
717
718 @override_settings(AXES_SENSITIVE_PARAMETERS=["other_sensitive_data"])
719 @override_settings(AXES_PASSWORD_FORM_FIELD="username")
720 def test_cleanse_parameters_override_both(self):
721 cleansed = cleanse_parameters(self.parameters)
722 self.assertEqual("********************", cleansed["username"])
723 self.assertEqual("********************", cleansed["password"])
724 self.assertEqual("********************", cleansed["other_sensitive_data"])
725
726 @override_settings(AXES_PASSWORD_FORM_FIELD=None)
727 def test_cleanse_parameters_override_empty(self):
728 cleansed = cleanse_parameters(self.parameters)
729 self.assertEqual("test_user", cleansed["username"])
730 self.assertEqual("********************", cleansed["password"])
731 self.assertEqual("sensitive", cleansed["other_sensitive_data"])
0 from unittest.mock import patch
1
2 from django.test import override_settings
3 from django.urls import reverse
4
5 from axes.apps import AppConfig
6 from axes.models import AccessAttempt, AccessLog
7 from tests.base import AxesTestCase
8
9
10 @patch("axes.apps.AppConfig.initialized", False)
11 @patch("axes.apps.log")
12 class AppsTestCase(AxesTestCase):
13 def test_axes_config_log_re_entrant(self, log):
14 """
15 Test that initialize call count does not increase on repeat calls.
16 """
17
18 AppConfig.initialize()
19 calls = log.info.call_count
20
21 AppConfig.initialize()
22 self.assertTrue(
23 calls == log.info.call_count and calls > 0,
24 "AxesConfig.initialize needs to be re-entrant",
25 )
26
27 @override_settings(AXES_VERBOSE=False)
28 def test_axes_config_log_not_verbose(self, log):
29 AppConfig.initialize()
30 self.assertFalse(log.info.called)
31
32 @override_settings(AXES_ONLY_USER_FAILURES=True)
33 def test_axes_config_log_user_only(self, log):
34 AppConfig.initialize()
35 log.info.assert_called_with("AXES: blocking by username only.")
36
37 @override_settings(AXES_ONLY_USER_FAILURES=False)
38 def test_axes_config_log_ip_only(self, log):
39 AppConfig.initialize()
40 log.info.assert_called_with("AXES: blocking by IP only.")
41
42 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
43 def test_axes_config_log_user_ip(self, log):
44 AppConfig.initialize()
45 log.info.assert_called_with("AXES: blocking by combination of username and IP.")
46
47 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
48 def test_axes_config_log_user_or_ip(self, log):
49 AppConfig.initialize()
50 log.info.assert_called_with("AXES: blocking by username or IP.")
51
52
53 class AccessLogTestCase(AxesTestCase):
54 def test_access_log_on_logout(self):
55 """
56 Test a valid logout and make sure the logout_time is updated.
57 """
58
59 self.login(is_valid_username=True, is_valid_password=True)
60 self.assertIsNone(AccessLog.objects.latest("id").logout_time)
61
62 response = self.client.get(reverse("admin:logout"))
63 self.assertContains(response, "Logged out")
64
65 self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
66
67 def test_log_data_truncated(self):
68 """
69 Test that get_query_str properly truncates data to the max_length (default 1024).
70 """
71
72 # An impossibly large post dict
73 extra_data = {"a" * x: x for x in range(1024)}
74 self.login(**extra_data)
75 self.assertEqual(len(AccessAttempt.objects.latest("id").post_data), 1024)
76
77 @override_settings(AXES_DISABLE_ACCESS_LOG=True)
78 def test_valid_logout_without_success_log(self):
79 AccessLog.objects.all().delete()
80
81 response = self.login(is_valid_username=True, is_valid_password=True)
82 response = self.client.get(reverse("admin:logout"))
83
84 self.assertEqual(AccessLog.objects.all().count(), 0)
85 self.assertContains(response, "Logged out", html=True)
86
87 @override_settings(AXES_DISABLE_ACCESS_LOG=True)
88 def test_valid_login_without_success_log(self):
89 """
90 Test that a valid login does not generate an AccessLog when DISABLE_SUCCESS_ACCESS_LOG is True.
91 """
92
93 AccessLog.objects.all().delete()
94
95 response = self.login(is_valid_username=True, is_valid_password=True)
96
97 self.assertEqual(response.status_code, 302)
98 self.assertEqual(AccessLog.objects.all().count(), 0)
99
100 @override_settings(AXES_DISABLE_ACCESS_LOG=True)
101 def test_valid_logout_without_log(self):
102 AccessLog.objects.all().delete()
103
104 response = self.login(is_valid_username=True, is_valid_password=True)
105 response = self.client.get(reverse("admin:logout"))
106
107 self.assertEqual(AccessLog.objects.count(), 0)
108 self.assertContains(response, "Logged out", html=True)
109
110 @override_settings(AXES_DISABLE_ACCESS_LOG=True)
111 def test_non_valid_login_without_log(self):
112 """
113 Test that a non-valid login does generate an AccessLog when DISABLE_ACCESS_LOG is True.
114 """
115 AccessLog.objects.all().delete()
116
117 response = self.login(is_valid_username=True, is_valid_password=False)
118 self.assertEqual(response.status_code, 200)
119
120 self.assertEqual(AccessLog.objects.all().count(), 0)
0 """
1 Integration tests for the login handling.
2
3 TODO: Clean up the tests in this module.
4 """
5
6 from importlib import import_module
7
8 from django.contrib.auth import get_user_model, login, logout
9 from django.http import HttpRequest
10 from django.test import override_settings, TestCase
11 from django.urls import reverse
12
13 from axes.conf import settings
14 from axes.helpers import get_cache, make_cache_key_list
15 from axes.models import AccessAttempt
16 from tests.base import AxesTestCase
17
18
19 class DjangoLoginTestCase(TestCase):
20 def setUp(self):
21 engine = import_module(settings.SESSION_ENGINE)
22
23 self.request = HttpRequest()
24 self.request.session = engine.SessionStore()
25
26 self.username = "john.doe"
27 self.password = "hunter2"
28
29 self.user = get_user_model().objects.create(username=self.username)
30 self.user.set_password(self.password)
31 self.user.save()
32 self.user.backend = "django.contrib.auth.backends.ModelBackend"
33
34
35 class DjangoContribAuthLoginTestCase(DjangoLoginTestCase):
36 def test_login(self):
37 login(self.request, self.user)
38
39 def test_logout(self):
40 login(self.request, self.user)
41 logout(self.request)
42
43
44 @override_settings(AXES_ENABLED=False)
45 class DjangoTestClientLoginTestCase(DjangoLoginTestCase):
46 def test_client_login(self):
47 self.client.login(username=self.username, password=self.password)
48
49 def test_client_logout(self):
50 self.client.login(username=self.username, password=self.password)
51 self.client.logout()
52
53 def test_client_force_login(self):
54 self.client.force_login(self.user)
55
56
57 class DatabaseLoginTestCase(AxesTestCase):
58 """
59 Test for lockouts under different configurations and circumstances to prevent false positives and false negatives.
60
61 Always block attempted logins for the same user from the same IP.
62 Always allow attempted logins for a different user from a different IP.
63 """
64
65 IP_1 = "10.1.1.1"
66 IP_2 = "10.2.2.2"
67 IP_3 = "10.2.2.3"
68 USER_1 = "valid-user-1"
69 USER_2 = "valid-user-2"
70 USER_3 = "valid-user-3"
71 EMAIL_1 = "valid-email-1@example.com"
72 EMAIL_2 = "valid-email-2@example.com"
73
74 VALID_USERNAME = USER_1
75 VALID_EMAIL = EMAIL_1
76 VALID_PASSWORD = "valid-password"
77
78 VALID_IP_ADDRESS = IP_1
79
80 WRONG_PASSWORD = "wrong-password"
81 LOCKED_MESSAGE = "Account locked: too many login attempts."
82 LOGIN_FORM_KEY = '<input type="submit" value="Log in" />'
83 ATTEMPT_NOT_BLOCKED = 200
84 ALLOWED = 302
85 BLOCKED = 403
86
87 def _login(self, username, password, ip_addr="127.0.0.1", **kwargs):
88 """
89 Login a user and get the response.
90
91 IP address can be configured to test IP blocking functionality.
92 """
93
94 post_data = {"username": username, "password": password}
95
96 post_data.update(kwargs)
97
98 return self.client.post(
99 reverse("admin:login"),
100 post_data,
101 REMOTE_ADDR=ip_addr,
102 HTTP_USER_AGENT="test-browser",
103 )
104
105 def _lockout_user_from_ip(self, username, ip_addr):
106 for _ in range(settings.AXES_FAILURE_LIMIT):
107 response = self._login(
108 username=username, password=self.WRONG_PASSWORD, ip_addr=ip_addr
109 )
110 return response
111
112 def _lockout_user1_from_ip1(self):
113 return self._lockout_user_from_ip(username=self.USER_1, ip_addr=self.IP_1)
114
115 def setUp(self):
116 """
117 Create two valid users for authentication.
118 """
119
120 super().setUp()
121
122 self.user2 = get_user_model().objects.create_superuser(
123 username=self.USER_2,
124 email=self.EMAIL_2,
125 password=self.VALID_PASSWORD,
126 is_staff=True,
127 is_superuser=True,
128 )
129
130 def test_login(self):
131 """
132 Test a valid login for a real username.
133 """
134
135 response = self._login(self.username, self.password)
136 self.assertNotContains(
137 response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
138 )
139
140 def test_lockout_limit_once(self):
141 """
142 Test the login lock trying to login one more time than failure limit.
143 """
144
145 response = self.lockout()
146 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
147
148 def test_lockout_limit_many(self):
149 """
150 Test the login lock trying to login a lot of times more than failure limit.
151 """
152
153 self.lockout()
154
155 for _ in range(settings.AXES_FAILURE_LIMIT):
156 response = self.login()
157 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
158
159 def attempt_count(self):
160 return AccessAttempt.objects.count()
161
162 @override_settings(AXES_RESET_ON_SUCCESS=False)
163 def test_reset_on_success_false(self):
164 self.almost_lockout()
165 self.login(is_valid_username=True, is_valid_password=True)
166
167 response = self.login()
168 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
169 self.assertTrue(self.attempt_count())
170
171 @override_settings(AXES_RESET_ON_SUCCESS=True)
172 def test_reset_on_success_true(self):
173 self.almost_lockout()
174 self.assertTrue(self.attempt_count())
175
176 self.login(is_valid_username=True, is_valid_password=True)
177 self.assertFalse(self.attempt_count())
178
179 response = self.lockout()
180 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
181 self.assertTrue(self.attempt_count())
182
183 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
184 def test_lockout_by_combination_user_and_ip(self):
185 """
186 Test login failure when AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP is True.
187 """
188
189 # test until one try before the limit
190 for _ in range(1, settings.AXES_FAILURE_LIMIT):
191 response = self.login(is_valid_username=True, is_valid_password=False)
192 # Check if we are in the same login page
193 self.assertContains(response, self.LOGIN_FORM_KEY, html=True)
194
195 # So, we shouldn't have gotten a lock-out yet.
196 # But we should get one now
197 response = self.login(is_valid_username=True, is_valid_password=False)
198 self.assertContains(response, self.LOCKED_MESSAGE, status_code=403)
199
200 @override_settings(AXES_ONLY_USER_FAILURES=True)
201 def test_lockout_by_only_user_failures(self):
202 """
203 Test login failure when AXES_ONLY_USER_FAILURES is True.
204 """
205
206 # test until one try before the limit
207 for _ in range(1, settings.AXES_FAILURE_LIMIT):
208 response = self._login(self.username, self.WRONG_PASSWORD)
209
210 # Check if we are in the same login page
211 self.assertContains(response, self.LOGIN_FORM_KEY, html=True)
212
213 # So, we shouldn't have gotten a lock-out yet.
214 # But we should get one now
215 response = self._login(self.username, self.WRONG_PASSWORD)
216 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
217
218 # reset the username only and make sure we can log in now even though our IP has failed each time
219 self.reset(username=self.username)
220
221 response = self._login(self.username, self.password)
222
223 # Check if we are still in the login page
224 self.assertNotContains(
225 response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
226 )
227
228 # now create failure_limit + 1 failed logins and then we should still
229 # be able to login with valid_username
230 for _ in range(settings.AXES_FAILURE_LIMIT):
231 response = self._login(self.username, self.password)
232
233 # Check if we can still log in with valid user
234 response = self._login(self.username, self.password)
235 self.assertNotContains(
236 response, self.LOGIN_FORM_KEY, status_code=self.ALLOWED, html=True
237 )
238
239 # Test for true and false positives when blocking by IP *OR* user (default)
240 # Cache disabled. Default settings.
241 def test_lockout_by_ip_blocks_when_same_user_same_ip_without_cache(self):
242 # User 1 is locked out from IP 1.
243 self._lockout_user1_from_ip1()
244
245 # User 1 is still blocked from IP 1.
246 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
247 self.assertEqual(response.status_code, self.BLOCKED)
248
249 def test_lockout_by_ip_allows_when_same_user_diff_ip_without_cache(self):
250 # User 1 is locked out from IP 1.
251 self._lockout_user1_from_ip1()
252
253 # User 1 can still login from IP 2.
254 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
255 self.assertEqual(response.status_code, self.ALLOWED)
256
257 def test_lockout_by_ip_blocks_when_diff_user_same_ip_without_cache(self):
258 # User 1 is locked out from IP 1.
259 self._lockout_user1_from_ip1()
260
261 # User 2 is also locked out from IP 1.
262 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
263 self.assertEqual(response.status_code, self.BLOCKED)
264
265 def test_lockout_by_ip_allows_when_diff_user_diff_ip_without_cache(self):
266 # User 1 is locked out from IP 1.
267 self._lockout_user1_from_ip1()
268
269 # User 2 can still login from IP 2.
270 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
271 self.assertEqual(response.status_code, self.ALLOWED)
272
273 # Test for true and false positives when blocking by user only.
274 # Cache disabled. When AXES_ONLY_USER_FAILURES = True
275 @override_settings(AXES_ONLY_USER_FAILURES=True)
276 def test_lockout_by_user_blocks_when_same_user_same_ip_without_cache(self):
277 # User 1 is locked out from IP 1.
278 self._lockout_user1_from_ip1()
279
280 # User 1 is still blocked from IP 1.
281 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
282 self.assertEqual(response.status_code, self.BLOCKED)
283
284 @override_settings(AXES_ONLY_USER_FAILURES=True)
285 def test_lockout_by_user_blocks_when_same_user_diff_ip_without_cache(self):
286 # User 1 is locked out from IP 1.
287 self._lockout_user1_from_ip1()
288
289 # User 1 is also locked out from IP 2.
290 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
291 self.assertEqual(response.status_code, self.BLOCKED)
292
293 @override_settings(AXES_ONLY_USER_FAILURES=True)
294 def test_lockout_by_user_allows_when_diff_user_same_ip_without_cache(self):
295 # User 1 is locked out from IP 1.
296 self._lockout_user1_from_ip1()
297
298 # User 2 can still login from IP 1.
299 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
300 self.assertEqual(response.status_code, self.ALLOWED)
301
302 @override_settings(AXES_ONLY_USER_FAILURES=True)
303 def test_lockout_by_user_allows_when_diff_user_diff_ip_without_cache(self):
304 # User 1 is locked out from IP 1.
305 self._lockout_user1_from_ip1()
306
307 # User 2 can still login from IP 2.
308 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
309 self.assertEqual(response.status_code, self.ALLOWED)
310
311 @override_settings(AXES_ONLY_USER_FAILURES=True)
312 def test_lockout_by_user_with_empty_username_allows_other_users_without_cache(self):
313 # User with empty username is locked out from IP 1.
314 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
315
316 # Still possible to access the login page
317 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
318 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
319
320 # Test for true and false positives when blocking by user and IP together.
321 # Cache disabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
322 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
323 def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_without_cache(self):
324 # User 1 is locked out from IP 1.
325 self._lockout_user1_from_ip1()
326
327 # User 1 is still blocked from IP 1.
328 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
329 self.assertEqual(response.status_code, self.BLOCKED)
330
331 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
332 def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_without_cache(self):
333 # User 1 is locked out from IP 1.
334 self._lockout_user1_from_ip1()
335
336 # User 1 can still login from IP 2.
337 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
338 self.assertEqual(response.status_code, self.ALLOWED)
339
340 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
341 def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_without_cache(self):
342 # User 1 is locked out from IP 1.
343 self._lockout_user1_from_ip1()
344
345 # User 2 can still login from IP 1.
346 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
347 self.assertEqual(response.status_code, self.ALLOWED)
348
349 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
350 def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_without_cache(self):
351 # User 1 is locked out from IP 1.
352 self._lockout_user1_from_ip1()
353
354 # User 2 can still login from IP 2.
355 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
356 self.assertEqual(response.status_code, self.ALLOWED)
357
358 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
359 def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_without_cache(
360 self,
361 ):
362 # User with empty username is locked out from IP 1.
363 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
364
365 # Still possible to access the login page
366 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
367 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
368
369 # Test for true and false positives when blocking by IP *OR* user (default)
370 # With cache enabled. Default criteria.
371 def test_lockout_by_ip_blocks_when_same_user_same_ip_using_cache(self):
372 # User 1 is locked out from IP 1.
373 self._lockout_user1_from_ip1()
374
375 # User 1 is still blocked from IP 1.
376 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
377 self.assertEqual(response.status_code, self.BLOCKED)
378
379 def test_lockout_by_ip_allows_when_same_user_diff_ip_using_cache(self):
380 # User 1 is locked out from IP 1.
381 self._lockout_user1_from_ip1()
382
383 # User 1 can still login from IP 2.
384 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
385 self.assertEqual(response.status_code, self.ALLOWED)
386
387 def test_lockout_by_ip_blocks_when_diff_user_same_ip_using_cache(self):
388 # User 1 is locked out from IP 1.
389 self._lockout_user1_from_ip1()
390
391 # User 2 is also locked out from IP 1.
392 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
393 self.assertEqual(response.status_code, self.BLOCKED)
394
395 def test_lockout_by_ip_allows_when_diff_user_diff_ip_using_cache(self):
396 # User 1 is locked out from IP 1.
397 self._lockout_user1_from_ip1()
398
399 # User 2 can still login from IP 2.
400 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
401 self.assertEqual(response.status_code, self.ALLOWED)
402
403 @override_settings(AXES_ONLY_USER_FAILURES=True)
404 def test_lockout_by_user_with_empty_username_allows_other_users_using_cache(self):
405 # User with empty username is locked out from IP 1.
406 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
407
408 # Still possible to access the login page
409 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
410 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
411
412 # Test for true and false positives when blocking by user only.
413 # With cache enabled. When AXES_ONLY_USER_FAILURES = True
414 @override_settings(AXES_ONLY_USER_FAILURES=True)
415 def test_lockout_by_user_blocks_when_same_user_same_ip_using_cache(self):
416 # User 1 is locked out from IP 1.
417 self._lockout_user1_from_ip1()
418
419 # User 1 is still blocked from IP 1.
420 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
421 self.assertEqual(response.status_code, self.BLOCKED)
422
423 @override_settings(AXES_ONLY_USER_FAILURES=True)
424 def test_lockout_by_user_blocks_when_same_user_diff_ip_using_cache(self):
425 # User 1 is locked out from IP 1.
426 self._lockout_user1_from_ip1()
427
428 # User 1 is also locked out from IP 2.
429 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
430 self.assertEqual(response.status_code, self.BLOCKED)
431
432 @override_settings(AXES_ONLY_USER_FAILURES=True)
433 def test_lockout_by_user_allows_when_diff_user_same_ip_using_cache(self):
434 # User 1 is locked out from IP 1.
435 self._lockout_user1_from_ip1()
436
437 # User 2 can still login from IP 1.
438 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
439 self.assertEqual(response.status_code, self.ALLOWED)
440
441 @override_settings(AXES_ONLY_USER_FAILURES=True)
442 def test_lockout_by_user_allows_when_diff_user_diff_ip_using_cache(self):
443 # User 1 is locked out from IP 1.
444 self._lockout_user1_from_ip1()
445
446 # User 2 can still login from IP 2.
447 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
448 self.assertEqual(response.status_code, self.ALLOWED)
449
450 # Test for true and false positives when blocking by user and IP together.
451 # With cache enabled. When LOCK_OUT_BY_COMBINATION_USER_AND_IP = True
452 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
453 def test_lockout_by_user_and_ip_blocks_when_same_user_same_ip_using_cache(self):
454 # User 1 is locked out from IP 1.
455 self._lockout_user1_from_ip1()
456
457 # User 1 is still blocked from IP 1.
458 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
459 self.assertEqual(response.status_code, self.BLOCKED)
460
461 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
462 def test_lockout_by_user_and_ip_allows_when_same_user_diff_ip_using_cache(self):
463 # User 1 is locked out from IP 1.
464 self._lockout_user1_from_ip1()
465
466 # User 1 can still login from IP 2.
467 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
468 self.assertEqual(response.status_code, self.ALLOWED)
469
470 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
471 def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache(self):
472 # User 1 is locked out from IP 1.
473 self._lockout_user1_from_ip1()
474
475 # User 2 can still login from IP 1.
476 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
477 self.assertEqual(response.status_code, self.ALLOWED)
478
479 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
480 def test_lockout_by_user_and_ip_allows_when_diff_user_diff_ip_using_cache(self):
481 # User 1 is locked out from IP 1.
482 self._lockout_user1_from_ip1()
483
484 # User 2 can still login from IP 2.
485 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
486 self.assertEqual(response.status_code, self.ALLOWED)
487
488 @override_settings(
489 AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True, AXES_FAILURE_LIMIT=2
490 )
491 def test_lockout_by_user_and_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts(
492 self,
493 ):
494 # User 1 is locked out from IP 1.
495 response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_1)
496 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
497
498 # Second attempt from different IP
499 response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_2)
500 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
501
502 # Second attempt from same IP, different username
503 response = self._login(self.USER_2, self.WRONG_PASSWORD, self.IP_1)
504 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
505
506 # User 1 is blocked from IP 1
507 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_1)
508 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
509
510 # User 1 is blocked from IP 2
511 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_2)
512 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
513
514 # User 2 can still login from IP 2, only he has 1 attempt left
515 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
516 self.assertEqual(response.status_code, self.ALLOWED)
517
518 @override_settings(AXES_LOCK_OUT_BY_COMBINATION_USER_AND_IP=True)
519 def test_lockout_by_user_and_ip_with_empty_username_allows_other_users_using_cache(
520 self,
521 ):
522 # User with empty username is locked out from IP 1.
523 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
524
525 # Still possible to access the login page
526 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
527 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
528
529 # Test for true and false positives when blocking by user or IP together.
530 # With cache enabled. When AXES_LOCK_OUT_BY_USER_OR_IP = True
531 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
532 def test_lockout_by_user_or_ip_blocks_when_same_user_same_ip_using_cache(self):
533 # User 1 is locked out from IP 1.
534 self._lockout_user1_from_ip1()
535
536 # User 1 is still blocked from IP 1.
537 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_1)
538 self.assertEqual(response.status_code, self.BLOCKED)
539
540 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
541 def test_lockout_by_user_or_ip_allows_when_same_user_diff_ip_using_cache(self):
542 # User 1 is locked out from IP 1.
543 self._lockout_user1_from_ip1()
544
545 # User 1 is blocked out from IP 1
546 response = self._login(self.USER_1, self.VALID_PASSWORD, ip_addr=self.IP_2)
547 self.assertEqual(response.status_code, self.BLOCKED)
548
549 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
550 def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache(self):
551 # User 1 is locked out from IP 1.
552 self._lockout_user1_from_ip1()
553
554 # User 2 can still login from IP 1.
555 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_1)
556 self.assertEqual(response.status_code, self.BLOCKED)
557
558 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3)
559 def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_attempts(
560 self,
561 ):
562 # User 1 is locked out from IP 1.
563 response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_1)
564 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
565
566 # Second attempt from different IP
567 response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_2)
568 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
569
570 # User 1 is blocked on all IPs, he reached 2 attempts
571 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_2)
572 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
573 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_3)
574 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
575
576 # IP 1 has still one attempt left
577 response = self._login(self.USER_2, self.WRONG_PASSWORD, self.IP_1)
578 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
579
580 # But now IP 1 is blocked for all attempts
581 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_1)
582 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
583 response = self._login(self.USER_2, self.WRONG_PASSWORD, ip_addr=self.IP_1)
584 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
585 response = self._login(self.USER_3, self.WRONG_PASSWORD, ip_addr=self.IP_1)
586 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
587
588 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True, AXES_FAILURE_LIMIT=3)
589 def test_lockout_by_user_or_ip_allows_when_diff_user_same_ip_using_cache_multiple_failed_attempts(
590 self,
591 ):
592 """ Test, if the failed attempts make also impact on the attempt count """
593 # User 1 is locked out from IP 1.
594 response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_1)
595 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
596
597 # Second attempt from different IP
598 response = self._login(self.USER_1, self.WRONG_PASSWORD, self.IP_2)
599 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
600
601 # Second attempt from same IP, different username
602 response = self._login(self.USER_2, self.WRONG_PASSWORD, self.IP_1)
603 self.assertEqual(response.status_code, self.ATTEMPT_NOT_BLOCKED)
604
605 # User 1 is blocked from IP 2
606 response = self._login(self.USER_1, self.WRONG_PASSWORD, ip_addr=self.IP_2)
607 self.assertContains(response, self.LOCKED_MESSAGE, status_code=self.BLOCKED)
608
609 # On IP 2 it is only 2. attempt, for user 2 it is also 2. attempt -> allow log in
610 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
611 self.assertEqual(response.status_code, self.ALLOWED)
612
613 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
614 def test_lockout_by_user_or_ip_allows_when_diff_user_diff_ip_using_cache(self):
615 # User 1 is locked out from IP 1.
616 self._lockout_user1_from_ip1()
617
618 # User 2 can still login from IP 2.
619 response = self._login(self.USER_2, self.VALID_PASSWORD, ip_addr=self.IP_2)
620 self.assertEqual(response.status_code, self.ALLOWED)
621
622 @override_settings(AXES_LOCK_OUT_BY_USER_OR_IP=True)
623 def test_lockout_by_user_or_ip_with_empty_username_allows_other_users_using_cache(
624 self,
625 ):
626 # User with empty username is locked out from IP 1.
627 self._lockout_user_from_ip(username="", ip_addr=self.IP_1)
628
629 # Still possible to access the login page
630 response = self.client.get(reverse("admin:login"), REMOTE_ADDR=self.IP_1)
631 self.assertContains(response, self.LOGIN_FORM_KEY, status_code=200, html=True)
632
633
634 # Test the same logic with cache handler
635 @override_settings(AXES_HANDLER="axes.handlers.cache.AxesCacheHandler")
636 class CacheLoginTestCase(DatabaseLoginTestCase):
637 def attempt_count(self):
638 cache = get_cache()
639 keys = cache._cache
640 return len(keys)
641
642 def reset(self, **kwargs):
643 get_cache().delete(make_cache_key_list([kwargs])[0])
0 from io import StringIO
1 from unittest.mock import patch, Mock
2
3 from django.core.management import call_command
4 from django.utils import timezone
5
6 from axes.models import AccessAttempt, AccessLog
7 from tests.base import AxesTestCase
8
9
10 class ResetAccessLogsManagementCommandTestCase(AxesTestCase):
11 def setUp(self):
12 self.msg_not_found = "No logs found.\n"
13 self.msg_num_found = "{} logs removed.\n"
14
15 days_3 = timezone.now() - timezone.timedelta(days=3)
16 with patch("django.utils.timezone.now", Mock(return_value=days_3)):
17 AccessLog.objects.create()
18
19 days_13 = timezone.now() - timezone.timedelta(days=9)
20 with patch("django.utils.timezone.now", Mock(return_value=days_13)):
21 AccessLog.objects.create()
22
23 days_30 = timezone.now() - timezone.timedelta(days=27)
24 with patch("django.utils.timezone.now", Mock(return_value=days_30)):
25 AccessLog.objects.create()
26
27 def test_axes_delete_access_logs_default(self):
28 out = StringIO()
29 call_command("axes_reset_logs", stdout=out)
30 self.assertEqual(self.msg_not_found, out.getvalue())
31
32 def test_axes_delete_access_logs_older_than_2_days(self):
33 out = StringIO()
34 call_command("axes_reset_logs", age=2, stdout=out)
35 self.assertEqual(self.msg_num_found.format(3), out.getvalue())
36
37 def test_axes_delete_access_logs_older_than_4_days(self):
38 out = StringIO()
39 call_command("axes_reset_logs", age=4, stdout=out)
40 self.assertEqual(self.msg_num_found.format(2), out.getvalue())
41
42 def test_axes_delete_access_logs_older_than_16_days(self):
43 out = StringIO()
44 call_command("axes_reset_logs", age=16, stdout=out)
45 self.assertEqual(self.msg_num_found.format(1), out.getvalue())
46
47
48 class ManagementCommandTestCase(AxesTestCase):
49 def setUp(self):
50 AccessAttempt.objects.create(
51 username="jane.doe", ip_address="10.0.0.1", failures_since_start="4"
52 )
53
54 AccessAttempt.objects.create(
55 username="john.doe", ip_address="10.0.0.2", failures_since_start="15"
56 )
57
58 def test_axes_list_attempts(self):
59 out = StringIO()
60 call_command("axes_list_attempts", stdout=out)
61
62 expected = "10.0.0.1\tjane.doe\t4\n10.0.0.2\tjohn.doe\t15\n"
63 self.assertEqual(expected, out.getvalue())
64
65 def test_axes_reset(self):
66 out = StringIO()
67 call_command("axes_reset", stdout=out)
68
69 expected = "2 attempts removed.\n"
70 self.assertEqual(expected, out.getvalue())
71
72 def test_axes_reset_not_found(self):
73 out = StringIO()
74 call_command("axes_reset", stdout=out)
75
76 out = StringIO()
77 call_command("axes_reset", stdout=out)
78
79 expected = "No attempts found.\n"
80 self.assertEqual(expected, out.getvalue())
81
82 def test_axes_reset_ip(self):
83 out = StringIO()
84 call_command("axes_reset_ip", "10.0.0.1", stdout=out)
85
86 expected = "1 attempts removed.\n"
87 self.assertEqual(expected, out.getvalue())
88
89 def test_axes_reset_ip_not_found(self):
90 out = StringIO()
91 call_command("axes_reset_ip", "10.0.0.3", stdout=out)
92
93 expected = "No attempts found.\n"
94 self.assertEqual(expected, out.getvalue())
95
96 def test_axes_reset_username(self):
97 out = StringIO()
98 call_command("axes_reset_username", "john.doe", stdout=out)
99
100 expected = "1 attempts removed.\n"
101 self.assertEqual(expected, out.getvalue())
102
103 def test_axes_reset_username_not_found(self):
104 out = StringIO()
105 call_command("axes_reset_username", "ivan.renko", stdout=out)
106
107 expected = "No attempts found.\n"
108 self.assertEqual(expected, out.getvalue())
0 from django.http import HttpResponse, HttpRequest
1 from django.test import override_settings
2
3 from axes.middleware import AxesMiddleware
4 from tests.base import AxesTestCase
5
6
7 class MiddlewareTestCase(AxesTestCase):
8 STATUS_SUCCESS = 200
9 STATUS_LOCKOUT = 403
10
11 def setUp(self):
12 self.request = HttpRequest()
13
14 def test_success_response(self):
15 def get_response(request):
16 request.axes_locked_out = False
17 return HttpResponse()
18
19 response = AxesMiddleware(get_response)(self.request)
20 self.assertEqual(response.status_code, self.STATUS_SUCCESS)
21
22 def test_lockout_response(self):
23 def get_response(request):
24 request.axes_locked_out = True
25 return HttpResponse()
26
27 response = AxesMiddleware(get_response)(self.request)
28 self.assertEqual(response.status_code, self.STATUS_LOCKOUT)
29
30 @override_settings(AXES_ENABLED=False)
31 def test_respects_enabled_switch(self):
32 def get_response(request):
33 request.axes_locked_out = True
34 return HttpResponse()
35
36 response = AxesMiddleware(get_response)(self.request)
37 self.assertEqual(response.status_code, self.STATUS_SUCCESS)
0 from django.apps.registry import apps
1 from django.db import connection
2 from django.db.migrations.autodetector import MigrationAutodetector
3 from django.db.migrations.executor import MigrationExecutor
4 from django.db.migrations.state import ProjectState
5
6 from axes.models import AccessAttempt, AccessLog
7 from tests.base import AxesTestCase
8
9
10 class ModelsTestCase(AxesTestCase):
11 def setUp(self):
12 self.failures_since_start = 42
13
14 self.access_attempt = AccessAttempt(
15 failures_since_start=self.failures_since_start
16 )
17 self.access_log = AccessLog()
18
19 def test_access_attempt_str(self):
20 self.assertIn("Access", str(self.access_attempt))
21
22 def test_access_log_str(self):
23 self.assertIn("Access", str(self.access_log))
24
25
26 class MigrationsTestCase(AxesTestCase):
27 def test_missing_migrations(self):
28 executor = MigrationExecutor(connection)
29 autodetector = MigrationAutodetector(
30 executor.loader.project_state(), ProjectState.from_apps(apps)
31 )
32
33 changes = autodetector.changes(graph=executor.loader.graph)
34
35 self.assertEqual({}, changes)
0 from unittest.mock import MagicMock
1
2 from axes.signals import user_locked_out
3 from tests.base import AxesTestCase
4
5
6 class SignalTestCase(AxesTestCase):
7 def test_send_lockout_signal(self):
8 """
9 Test if the lockout signal is correctly emitted when user is locked out.
10 """
11
12 handler = MagicMock()
13 user_locked_out.connect(handler)
14
15 self.assertEqual(0, handler.call_count)
16 self.lockout()
17 self.assertEqual(1, handler.call_count)
0 from django.contrib import admin
1 from django.urls import path
2
3
4 urlpatterns = [path("admin/", admin.site.urls)]
0 urlpatterns: list = []
+0
-37
tox.ini less more
0 [tox]
1 envlist =
2 qa
3 py{36,37,38,py3}-django{22,30,31,master}
4
5 [travis]
6 python =
7 3.6: py36
8 3.7: py37
9 3.8: py38
10 pypy3: pypy3
11
12 [travis:env]
13 DJANGO =
14 2.2: django22
15 3.0: django30
16 3.1: django31
17 master: djangomaster
18
19 [testenv]
20 deps =
21 -r requirements.txt
22 django22: django>=2.2,<2.3
23 django30: django>=3.0,<3.1
24 django31: django>=3.1,<3.2
25 djangomaster: https://github.com/django/django/archive/master.tar.gz
26 usedevelop = True
27 commands =
28 pytest
29 setenv =
30 PYTHONDONTWRITEBYTECODE=1
31
32 [testenv:qa]
33 commands =
34 mypy axes
35 prospector
36 black -t py36 --check --diff axes