django-pfx 1.4.dev18__tar.gz → 1.4.dev22__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/PKG-INFO +1 -1
  2. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/django_pfx.egg-info/PKG-INFO +1 -1
  3. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/django_pfx.egg-info/SOURCES.txt +2 -0
  4. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/default_settings.py +4 -0
  5. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/authentication.py +13 -9
  6. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/models/__init__.py +2 -0
  7. django-pfx-1.4.dev22/pfx/pfxcore/models/abstract_pfx_base_user.py +15 -0
  8. django-pfx-1.4.dev22/pfx/pfxcore/models/login_ban.py +60 -0
  9. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/models/otp_user_mixin.py +4 -0
  10. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/authentication_views.py +45 -7
  11. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/models.py +3 -2
  12. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_auth_api.py +216 -46
  13. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/.gitignore +0 -0
  14. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/.gitlab-ci.yml +0 -0
  15. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/.pre-commit-config.yaml +0 -0
  16. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/LICENSE +0 -0
  17. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/MANIFEST.in +0 -0
  18. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/README.md +0 -0
  19. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/django-admin-test +0 -0
  20. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/django_pfx.egg-info/dependency_links.txt +0 -0
  21. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/django_pfx.egg-info/requires.txt +0 -0
  22. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/django_pfx.egg-info/top_level.txt +0 -0
  23. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/Makefile +0 -0
  24. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/conf.py +0 -0
  25. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/index.rst +0 -0
  26. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/api.views.rst +0 -0
  27. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/authentication.md +0 -0
  28. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/decorator.md +0 -0
  29. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/generate_openapi.md +0 -0
  30. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/getting_started.md +0 -0
  31. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/internationalisation.md +0 -0
  32. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/model.md +0 -0
  33. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/pfx_views.md +0 -0
  34. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/profiling.md +0 -0
  35. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/settings.md +0 -0
  36. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/doc/source/testing.md +0 -0
  37. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/img/pfx.png +0 -0
  38. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/img/pfx.svg +0 -0
  39. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/__init__.py +0 -0
  40. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/__init__.py +0 -0
  41. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/__init__.py +0 -0
  42. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/parameters.py +0 -0
  43. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/schema.py +0 -0
  44. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/tags.py +0 -0
  45. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/apps.py +0 -0
  46. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/decorator/__init__.py +0 -0
  47. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/decorator/rest.py +0 -0
  48. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/exceptions.py +0 -0
  49. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/fields.py +0 -0
  50. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/http/__init__.py +0 -0
  51. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/http/json_response.py +0 -0
  52. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.mo +0 -0
  53. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.po +0 -0
  54. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/management/__init__.py +0 -0
  55. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/__init__.py +0 -0
  56. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/makeapidoc.py +0 -0
  57. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/profile.py +0 -0
  58. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/__init__.py +0 -0
  59. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/locale.py +0 -0
  60. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/profiling.py +0 -0
  61. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/models/cache_mixins.py +0 -0
  62. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/models/not_null_fields.py +0 -0
  63. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/models/pfx_models.py +0 -0
  64. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/models/user_filtered_queryset_mixin.py +0 -0
  65. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/serializers/__init__.py +0 -0
  66. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/serializers/json.py +0 -0
  67. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/settings.py +0 -0
  68. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/shortcuts.py +0 -0
  69. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/storage/__init__.py +0 -0
  70. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/storage/s3_storage.py +0 -0
  71. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/password_reset_email.txt +0 -0
  72. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/password_reset_subject.txt +0 -0
  73. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/welcome_email.txt +0 -0
  74. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/welcome_subject.txt +0 -0
  75. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/test.py +0 -0
  76. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/urls.py +0 -0
  77. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/__init__.py +0 -0
  78. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/fields.py +0 -0
  79. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/filters_views.py +0 -0
  80. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/locale_views.py +0 -0
  81. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/__init__.py +0 -0
  82. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/date_format.py +0 -0
  83. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/groups.py +0 -0
  84. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_count.py +0 -0
  85. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_items.py +0 -0
  86. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_mode.py +0 -0
  87. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_order.py +0 -0
  88. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_search.py +0 -0
  89. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/media_redirect.py +0 -0
  90. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_fields.py +0 -0
  91. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_filters.py +0 -0
  92. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_orders.py +0 -0
  93. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset.py +0 -0
  94. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_limit.py +0 -0
  95. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_offset.py +0 -0
  96. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page.py +0 -0
  97. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page_size.py +0 -0
  98. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page_subset.py +0 -0
  99. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pfx/pfxcore/views/rest_views.py +0 -0
  100. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/pyproject.toml +0 -0
  101. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/requirements.txt +0 -0
  102. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/runtest.py +0 -0
  103. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/serve-doc +0 -0
  104. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/setup.cfg +0 -0
  105. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/setup.py +0 -0
  106. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/__init__.py +0 -0
  107. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/locale/fr/LC_MESSAGES/django.po +0 -0
  108. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/settings/__init__.py +0 -0
  109. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/settings/ci.py +0 -0
  110. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/settings/common.py +0 -0
  111. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/settings/dev.py +0 -0
  112. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/settings/dev_custom_example.py +0 -0
  113. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/settings/dev_default.py +0 -0
  114. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/__init__.py +0 -0
  115. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/basic_api_errors.py +0 -0
  116. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/basic_api_test.py +0 -0
  117. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_api_doc.py +0 -0
  118. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_body_mixin.py +0 -0
  119. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_cache.py +0 -0
  120. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_client.py +0 -0
  121. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_fields.py +0 -0
  122. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_filters.py +0 -0
  123. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_locale_api.py +0 -0
  124. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_perm_tests.py +0 -0
  125. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_perms_api.py +0 -0
  126. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_profiling_middleware.py +0 -0
  127. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_shortcuts.py +0 -0
  128. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_timezone_middleware.py +0 -0
  129. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_tools.py +0 -0
  130. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_user_queryset.py +0 -0
  131. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_view_decorators.py +0 -0
  132. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/tests/test_view_fields.py +0 -0
  133. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/urls.py +0 -0
  134. {django-pfx-1.4.dev18 → django-pfx-1.4.dev22}/tests/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-pfx
3
- Version: 1.4.dev18
3
+ Version: 1.4.dev22
4
4
  Summary: Django PFX is a toolkit designed to streamline the development of RESTful APIs using the Django framework.
5
5
  Author: Hervé Martinet
6
6
  Author-email: herve.martinet@gmail.com
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-pfx
3
- Version: 1.4.dev18
3
+ Version: 1.4.dev22
4
4
  Summary: Django PFX is a toolkit designed to streamline the development of RESTful APIs using the Django framework.
5
5
  Author: Hervé Martinet
6
6
  Author-email: herve.martinet@gmail.com
@@ -61,7 +61,9 @@ pfx/pfxcore/middleware/authentication.py
61
61
  pfx/pfxcore/middleware/locale.py
62
62
  pfx/pfxcore/middleware/profiling.py
63
63
  pfx/pfxcore/models/__init__.py
64
+ pfx/pfxcore/models/abstract_pfx_base_user.py
64
65
  pfx/pfxcore/models/cache_mixins.py
66
+ pfx/pfxcore/models/login_ban.py
65
67
  pfx/pfxcore/models/not_null_fields.py
66
68
  pfx/pfxcore/models/otp_user_mixin.py
67
69
  pfx/pfxcore/models/pfx_models.py
@@ -6,6 +6,10 @@ PFX_COOKIE_DOMAIN = None
6
6
  PFX_COOKIE_SECURE = True
7
7
  PFX_COOKIE_SAMESITE = 'None'
8
8
 
9
+ PFX_LOGIN_BAN_FAILED_NUMBER = 5
10
+ PFX_LOGIN_BAN_SECONDS_START = 60
11
+ PFX_LOGIN_BAN_SECONDS_STEP = 60
12
+
9
13
  PFX_OPENAPI_PATH = "doc/api/"
10
14
  PFX_OPENAPI_FILENAME = "openapi"
11
15
  PFX_OPENAPI_TEMPLATE = {}
@@ -32,25 +32,29 @@ class JWTTokenDecodeMixin:
32
32
  @classmethod
33
33
  def decode_jwt(cls, token, otp_login=False):
34
34
  try:
35
+ headers = jwt.get_unverified_header(token)
36
+ if 'pfx_user_pk' not in headers:
37
+ raise jwt.InvalidTokenError(
38
+ "Missing pfx_user_pk in token headers")
39
+ user = cls.get_cached_user(headers['pfx_user_pk'])
35
40
  decoded = jwt.decode(
36
- token, settings.PFX_SECRET_KEY,
41
+ token,
42
+ user.get_user_jwt_signature_key() + settings.PFX_SECRET_KEY,
37
43
  options=dict(require=["exp"]),
38
44
  algorithms="HS256")
39
- user = cls.get_cached_user(decoded['pfx_user_pk'])
40
45
  if 'otp_login' in decoded:
41
46
  if otp_login:
42
47
  return user, *decoded['otp_login']
43
48
  raise jwt.InvalidTokenError(
44
49
  "This token is reserved for OTP login")
45
50
  return user
46
- except get_user_model().DoesNotExist:
47
- raise
48
- except DecodeError as e:
49
- logger.exception(e)
50
- raise
51
- except jwt.ExpiredSignatureError:
51
+ except (get_user_model().DoesNotExist, jwt.ExpiredSignatureError,
52
+ jwt.InvalidTokenError, jwt.InvalidSignatureError) as e:
53
+ # Log these exceptions only in debug mode
54
+ logger.debug(e, exc_info=True)
52
55
  raise
53
- except Exception as e: # pragma: no cover
56
+ except (DecodeError, Exception) as e:
57
+ # Always logs unexpected exceptions
54
58
  logger.exception(e)
55
59
  raise
56
60
 
@@ -1,4 +1,6 @@
1
+ from .abstract_pfx_base_user import AbstractPFXBaseUser
1
2
  from .cache_mixins import CacheableMixin, CacheDependsMixin
3
+ from .login_ban import LoginBan
2
4
  from .not_null_fields import (
3
5
  NotNullCharField,
4
6
  NotNullTextField,
@@ -0,0 +1,15 @@
1
+ from django.contrib.auth.models import AbstractBaseUser
2
+
3
+
4
+ class AbstractPFXBaseUser(AbstractBaseUser):
5
+ class Meta:
6
+ abstract = True
7
+
8
+ def get_user_jwt_signature_key(self):
9
+ """
10
+ Return a user secret to sign JWT token.
11
+
12
+ If not empty, the JWT token validity depends on all values
13
+ user to build the return string.
14
+ """
15
+ return self.password
@@ -0,0 +1,60 @@
1
+ from datetime import timedelta
2
+
3
+ from django.db import models
4
+ from django.utils import timezone
5
+ from django.utils.translation import gettext_lazy as _
6
+
7
+ from pfx.pfxcore.settings import PFXSettings
8
+
9
+ settings = PFXSettings()
10
+
11
+
12
+ class LoginBanQuerySet(models.QuerySet):
13
+ def is_ban(self, username):
14
+ if not username or settings.PFX_LOGIN_BAN_FAILED_NUMBER == 0:
15
+ return False
16
+ try:
17
+ ban = self.get(username=username)
18
+ except LoginBan.DoesNotExist:
19
+ return False
20
+ if ban.failed_counter % settings.PFX_LOGIN_BAN_FAILED_NUMBER == 0:
21
+ seconds = settings.PFX_LOGIN_BAN_SECONDS_START + (
22
+ settings.PFX_LOGIN_BAN_SECONDS_STEP * (ban.failed_counter - 1))
23
+ ban_time = ban.last_failed + timedelta(seconds=seconds)
24
+ now = timezone.now()
25
+ if now < ban_time:
26
+ return ban_time - now
27
+ return False
28
+
29
+ def ban(self, username):
30
+ if not username:
31
+ return
32
+ try:
33
+ ban = self.get(username=username)
34
+ ban.save()
35
+ except LoginBan.DoesNotExist:
36
+ LoginBan.objects.create(username=username)
37
+
38
+ def unban(self, username):
39
+ if not username:
40
+ return
41
+ self.filter(username=username).delete()
42
+
43
+
44
+ class LoginBan(models.Model):
45
+ username = models.CharField(_("Username"), max_length=150, unique=True)
46
+ failed_counter = models.IntegerField(_("Failed counter"))
47
+ last_failed = models.DateTimeField(_("Last failed"), auto_now=True)
48
+
49
+ objects = LoginBanQuerySet.as_manager()
50
+
51
+ class Meta:
52
+ verbose_name = _("Login ban")
53
+ verbose_name_plural = _("Login bans")
54
+
55
+ def __str__(self):
56
+ return self.username
57
+
58
+ def save(self, *args, **kwargs):
59
+ self.failed_counter = (self.failed_counter or 0) + 1
60
+ return super().save(*args, **kwargs)
@@ -42,3 +42,7 @@ class OtpUserMixin(models.Model):
42
42
  import pyotp
43
43
  totp = pyotp.parse_uri(self.get_otp_setup_uri(tmp=tmp))
44
44
  return totp.verify(otp_code)
45
+
46
+ def get_user_jwt_signature_key(self):
47
+ return super().get_user_jwt_signature_key() + (
48
+ self.otp_secret_token or "")
@@ -31,6 +31,7 @@ from pfx.pfxcore.exceptions import (
31
31
  from pfx.pfxcore.http import JsonResponse
32
32
  from pfx.pfxcore.middleware.authentication import JWTTokenDecodeMixin
33
33
  from pfx.pfxcore.models import CacheableMixin, OtpUserMixin
34
+ from pfx.pfxcore.models.login_ban import LoginBan
34
35
  from pfx.pfxcore.settings import PFXSettings
35
36
  from pfx.pfxcore.shortcuts import delete_token_cookie
36
37
 
@@ -71,6 +72,19 @@ class AuthenticationView(
71
72
  Can be overridden to customize the error."""
72
73
  raise AuthenticationError()
73
74
 
75
+ def login_ban_response(self, ban_dt):
76
+ """Return the response for login temporary ban.
77
+
78
+ Can be overridden to customize the error."""
79
+ seconds = int(ban_dt.total_seconds())
80
+ response = JsonResponse(dict(
81
+ message=_(
82
+ "Your connection is temporarily disabled after several "
83
+ "unsuccessful attempts, please retry in {seconds} seconds."
84
+ ).format(seconds=seconds)), status=429)
85
+ response['Retry-after'] = seconds
86
+ return response
87
+
74
88
  @method_decorator(sensitive_post_parameters())
75
89
  @method_decorator(never_cache)
76
90
  @rest_api(
@@ -115,14 +129,23 @@ class AuthenticationView(
115
129
  responses:
116
130
  422:
117
131
  description: The credentials are not valid.
132
+ 429:
133
+ description: Temporary banned after several unsuccessful
134
+ attempts.
118
135
  """
119
136
  data = self.deserialize_body()
120
- user = authenticate(self.request, username=data.get('username'),
137
+ username = data.get('username')
138
+ ban_dt = LoginBan.objects.is_ban(username)
139
+ if ban_dt:
140
+ return self.login_ban_response(ban_dt)
141
+ user = authenticate(self.request, username=username,
121
142
  password=data.get('password'))
122
143
  if isinstance(user, CacheableMixin):
123
144
  user.cache_delete()
124
145
  if user is None:
146
+ LoginBan.objects.ban(username)
125
147
  return self.login_failed_response()
148
+ LoginBan.objects.unban(username)
126
149
  mode = self.request.GET.get('mode', 'jwt')
127
150
  remember_me = data.get('remember_me', False)
128
151
  if isinstance(user, OtpUserMixin) and user.otp_secret_token:
@@ -233,17 +256,22 @@ class AuthenticationView(
233
256
  def _prepare_token(self, user, validity, **extra_payload):
234
257
  exp = datetime.now(tz=timezone.utc) + validity
235
258
  payload = dict(
236
- pfx_user_pk=user.pk, exp=exp,
259
+ exp=exp,
237
260
  **self.get_extra_payload(user), **extra_payload)
238
261
  return jwt.encode(
239
- payload, settings.PFX_SECRET_KEY, algorithm="HS256")
262
+ payload,
263
+ user.get_user_jwt_signature_key() + settings.PFX_SECRET_KEY,
264
+ headers=dict(
265
+ pfx_user_pk=user.pk,
266
+ username=user.get_username()),
267
+ algorithm="HS256")
240
268
 
241
269
  def get_extra_payload(self, user):
242
270
  """Get extra payload for user token.
243
271
 
244
272
  By default, there is only one private claim in the JWT token
245
- (the user PK : pfx_user_pk). This method can be overridden
246
- to add claims (key:value attributes) to the JWT token.
273
+ (exp : expiration). This method can be overridden
274
+ to add claims (key: value attributes) to the JWT token.
247
275
 
248
276
  :param user: The user
249
277
  :returns: The extra payload
@@ -371,6 +399,7 @@ class AuthenticationView(
371
399
  user.set_password(data['password'])
372
400
  user.is_active = True
373
401
  user.save()
402
+ user.refresh_from_db()
374
403
  if 'autologin' in data and data['autologin'] in (
375
404
  'jwt', 'cookie'):
376
405
  return self._login_success(user, data['autologin'])
@@ -521,6 +550,9 @@ class AuthenticationView(
521
550
  responses:
522
551
  422:
523
552
  description: If OTP code is missing, invalid or expired.
553
+ 429:
554
+ description: Temporary banned after several unsuccessful
555
+ attempts.
524
556
  401:
525
557
  description: If the token is missing, invalid or expired.
526
558
  403:
@@ -528,9 +560,13 @@ class AuthenticationView(
528
560
 
529
561
  """
530
562
  data = self.deserialize_body()
563
+ token = data.get('token')
564
+ username = jwt.get_unverified_header(token).get('username')
565
+ ban_dt = LoginBan.objects.is_ban(username)
566
+ if ban_dt:
567
+ return self.login_ban_response(ban_dt)
531
568
  try:
532
- user, mode, remember_me = self.decode_jwt(
533
- data.get('token'), otp_login=True)
569
+ user, mode, remember_me = self.decode_jwt(token, otp_login=True)
534
570
  except Exception:
535
571
  raise UnauthorizedError()
536
572
  if not isinstance(user, OtpUserMixin):
@@ -539,7 +575,9 @@ class AuthenticationView(
539
575
  if not user.otp_secret_token:
540
576
  raise ForbiddenError()
541
577
  if user.is_otp_valid(data.get('otp_code')):
578
+ LoginBan.objects.unban(username)
542
579
  return self._login_success(user, mode, remember_me)
580
+ LoginBan.objects.ban(username)
543
581
  return self.login_failed_response()
544
582
 
545
583
  def get_user(self, uidb64):
@@ -1,4 +1,4 @@
1
- from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
1
+ from django.contrib.auth.base_user import BaseUserManager
2
2
  from django.core.mail import send_mail
3
3
  from django.db import models
4
4
  from django.utils.functional import cached_property
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
7
7
  from pfx.pfxcore.decorator import rest_property
8
8
  from pfx.pfxcore.fields import MediaField, MinutesDurationField
9
9
  from pfx.pfxcore.models import (
10
+ AbstractPFXBaseUser,
10
11
  CacheableMixin,
11
12
  CacheDependsMixin,
12
13
  JSONReprMixin,
@@ -48,7 +49,7 @@ class UserManager(BaseUserManager):
48
49
  is_superuser=True)
49
50
 
50
51
 
51
- class User(CacheableMixin, JSONReprMixin, OtpUserMixin, AbstractBaseUser):
52
+ class User(CacheableMixin, JSONReprMixin, OtpUserMixin, AbstractPFXBaseUser):
52
53
  username = models.CharField(
53
54
  'username',
54
55
  max_length=150,