django-pfx 1.4.dev22__tar.gz → 1.4.dev26__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 (137) hide show
  1. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/PKG-INFO +1 -1
  2. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/django_pfx.egg-info/PKG-INFO +1 -1
  3. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/django_pfx.egg-info/SOURCES.txt +2 -0
  4. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/default_settings.py +1 -0
  5. django-pfx-1.4.dev26/pfx/pfxcore/locale/fr/LC_MESSAGES/django.mo +0 -0
  6. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.po +89 -24
  7. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/middleware/authentication.py +27 -10
  8. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/models/otp_user_mixin.py +27 -2
  9. django-pfx-1.4.dev26/pfx/pfxcore/templates/registration/otp_code_email.txt +12 -0
  10. django-pfx-1.4.dev26/pfx/pfxcore/templates/registration/otp_code_subject.txt +3 -0
  11. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/urls.py +2 -1
  12. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/__init__.py +1 -0
  13. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/authentication_views.py +117 -13
  14. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_auth_api.py +116 -0
  15. django-pfx-1.4.dev22/pfx/pfxcore/locale/fr/LC_MESSAGES/django.mo +0 -0
  16. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/.gitignore +0 -0
  17. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/.gitlab-ci.yml +0 -0
  18. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/.pre-commit-config.yaml +0 -0
  19. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/LICENSE +0 -0
  20. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/MANIFEST.in +0 -0
  21. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/README.md +0 -0
  22. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/django-admin-test +0 -0
  23. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/django_pfx.egg-info/dependency_links.txt +0 -0
  24. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/django_pfx.egg-info/requires.txt +0 -0
  25. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/django_pfx.egg-info/top_level.txt +0 -0
  26. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/Makefile +0 -0
  27. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/conf.py +0 -0
  28. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/index.rst +0 -0
  29. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/api.views.rst +0 -0
  30. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/authentication.md +0 -0
  31. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/decorator.md +0 -0
  32. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/generate_openapi.md +0 -0
  33. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/getting_started.md +0 -0
  34. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/internationalisation.md +0 -0
  35. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/model.md +0 -0
  36. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/pfx_views.md +0 -0
  37. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/profiling.md +0 -0
  38. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/settings.md +0 -0
  39. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/doc/source/testing.md +0 -0
  40. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/img/pfx.png +0 -0
  41. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/img/pfx.svg +0 -0
  42. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/__init__.py +0 -0
  43. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/__init__.py +0 -0
  44. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/apidoc/__init__.py +0 -0
  45. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/apidoc/parameters.py +0 -0
  46. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/apidoc/schema.py +0 -0
  47. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/apidoc/tags.py +0 -0
  48. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/apps.py +0 -0
  49. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/decorator/__init__.py +0 -0
  50. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/decorator/rest.py +0 -0
  51. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/exceptions.py +0 -0
  52. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/fields.py +0 -0
  53. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/http/__init__.py +0 -0
  54. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/http/json_response.py +0 -0
  55. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/management/__init__.py +0 -0
  56. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/management/commands/__init__.py +0 -0
  57. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/management/commands/makeapidoc.py +0 -0
  58. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/management/commands/profile.py +0 -0
  59. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/middleware/__init__.py +0 -0
  60. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/middleware/locale.py +0 -0
  61. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/middleware/profiling.py +0 -0
  62. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/models/__init__.py +0 -0
  63. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/models/abstract_pfx_base_user.py +0 -0
  64. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/models/cache_mixins.py +0 -0
  65. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/models/login_ban.py +0 -0
  66. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/models/not_null_fields.py +0 -0
  67. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/models/pfx_models.py +0 -0
  68. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/models/user_filtered_queryset_mixin.py +0 -0
  69. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/serializers/__init__.py +0 -0
  70. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/serializers/json.py +0 -0
  71. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/settings.py +0 -0
  72. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/shortcuts.py +0 -0
  73. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/storage/__init__.py +0 -0
  74. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/storage/s3_storage.py +0 -0
  75. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/templates/registration/password_reset_email.txt +0 -0
  76. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/templates/registration/password_reset_subject.txt +0 -0
  77. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/templates/registration/welcome_email.txt +0 -0
  78. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/templates/registration/welcome_subject.txt +0 -0
  79. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/test.py +0 -0
  80. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/fields.py +0 -0
  81. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/filters_views.py +0 -0
  82. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/locale_views.py +0 -0
  83. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/__init__.py +0 -0
  84. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/date_format.py +0 -0
  85. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/groups.py +0 -0
  86. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/list_count.py +0 -0
  87. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/list_items.py +0 -0
  88. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/list_mode.py +0 -0
  89. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/list_order.py +0 -0
  90. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/list_search.py +0 -0
  91. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/media_redirect.py +0 -0
  92. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/meta_fields.py +0 -0
  93. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/meta_filters.py +0 -0
  94. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/meta_orders.py +0 -0
  95. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/subset.py +0 -0
  96. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/subset_limit.py +0 -0
  97. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/subset_offset.py +0 -0
  98. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/subset_page.py +0 -0
  99. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/subset_page_size.py +0 -0
  100. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/parameters/subset_page_subset.py +0 -0
  101. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pfx/pfxcore/views/rest_views.py +0 -0
  102. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/pyproject.toml +0 -0
  103. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/requirements.txt +0 -0
  104. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/runtest.py +0 -0
  105. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/serve-doc +0 -0
  106. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/setup.cfg +0 -0
  107. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/setup.py +0 -0
  108. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/__init__.py +0 -0
  109. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/locale/fr/LC_MESSAGES/django.po +0 -0
  110. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/models.py +0 -0
  111. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/settings/__init__.py +0 -0
  112. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/settings/ci.py +0 -0
  113. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/settings/common.py +0 -0
  114. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/settings/dev.py +0 -0
  115. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/settings/dev_custom_example.py +0 -0
  116. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/settings/dev_default.py +0 -0
  117. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/__init__.py +0 -0
  118. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/basic_api_errors.py +0 -0
  119. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/basic_api_test.py +0 -0
  120. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_api_doc.py +0 -0
  121. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_body_mixin.py +0 -0
  122. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_cache.py +0 -0
  123. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_client.py +0 -0
  124. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_fields.py +0 -0
  125. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_filters.py +0 -0
  126. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_locale_api.py +0 -0
  127. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_perm_tests.py +0 -0
  128. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_perms_api.py +0 -0
  129. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_profiling_middleware.py +0 -0
  130. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_shortcuts.py +0 -0
  131. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_timezone_middleware.py +0 -0
  132. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_tools.py +0 -0
  133. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_user_queryset.py +0 -0
  134. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_view_decorators.py +0 -0
  135. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/tests/test_view_fields.py +0 -0
  136. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/urls.py +0 -0
  137. {django-pfx-1.4.dev22 → django-pfx-1.4.dev26}/tests/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-pfx
3
- Version: 1.4.dev22
3
+ Version: 1.4.dev26
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.dev22
3
+ Version: 1.4.dev26
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
@@ -72,6 +72,8 @@ pfx/pfxcore/serializers/__init__.py
72
72
  pfx/pfxcore/serializers/json.py
73
73
  pfx/pfxcore/storage/__init__.py
74
74
  pfx/pfxcore/storage/s3_storage.py
75
+ pfx/pfxcore/templates/registration/otp_code_email.txt
76
+ pfx/pfxcore/templates/registration/otp_code_subject.txt
75
77
  pfx/pfxcore/templates/registration/password_reset_email.txt
76
78
  pfx/pfxcore/templates/registration/password_reset_subject.txt
77
79
  pfx/pfxcore/templates/registration/welcome_email.txt
@@ -1,6 +1,7 @@
1
1
  PFX_TOKEN_SHORT_VALIDITY = {'hours': 12}
2
2
  PFX_TOKEN_LONG_VALIDITY = {'days': 30}
3
3
  PFX_TOKEN_OTP_VALIDITY = {'minutes': 15}
4
+ PFX_HOTP_CODE_VALIDITY = 15
4
5
 
5
6
  PFX_COOKIE_DOMAIN = None
6
7
  PFX_COOKIE_SECURE = True
@@ -7,7 +7,7 @@ msgid ""
7
7
  msgstr ""
8
8
  "Project-Id-Version: \n"
9
9
  "Report-Msgid-Bugs-To: \n"
10
- "POT-Creation-Date: 2024-03-28 11:03+0100\n"
10
+ "POT-Creation-Date: 2024-04-08 13:51+0200\n"
11
11
  "PO-Revision-Date: 2021-06-22 23:31+0200\n"
12
12
  "Last-Translator: \n"
13
13
  "Language-Team: \n"
@@ -59,14 +59,42 @@ msgstr ""
59
59
  "Format non valide, il peut s’agir d’un nombre en heures, « 1:05 », « :05 », "
60
60
  "« 1h 5m », « 1.5h » ou « 30m »."
61
61
 
62
- #: models/otp_user_mixin.py:8
62
+ #: models/login_ban.py:45
63
+ msgid "Username"
64
+ msgstr "Nom d’utilisateur"
65
+
66
+ #: models/login_ban.py:46
67
+ msgid "Failed counter"
68
+ msgstr "Compteur d’échec"
69
+
70
+ #: models/login_ban.py:47
71
+ msgid "Last failed"
72
+ msgstr "Dernier échec"
73
+
74
+ #: models/login_ban.py:52
75
+ msgid "Login ban"
76
+ msgstr "Login banni"
77
+
78
+ #: models/login_ban.py:53
79
+ msgid "Login bans"
80
+ msgstr "Login bannis"
81
+
82
+ #: models/otp_user_mixin.py:10
63
83
  msgid "OTP secret token"
64
84
  msgstr "Jeton secret OTP"
65
85
 
66
- #: models/otp_user_mixin.py:11
86
+ #: models/otp_user_mixin.py:13
67
87
  msgid "Temporary OTP secret token"
68
88
  msgstr "Jeton secret OTP temporaire"
69
89
 
90
+ #: models/otp_user_mixin.py:14
91
+ msgid "HOTP count"
92
+ msgstr "Compte HOTP"
93
+
94
+ #: models/otp_user_mixin.py:15
95
+ msgid "HOTP expiry"
96
+ msgstr "Expiration HOTP"
97
+
70
98
  #: models/pfx_models.py:14
71
99
  #, python-format
72
100
  msgid "%(model_name)s with this %(field_labels)s already exists."
@@ -92,6 +120,41 @@ msgstr "{key} doit être une date."
92
120
  msgid "{key} must be “true”, “false”, “1”, “0” or empty."
93
121
  msgstr "{key} doit être « true », « false », « 1 », « 0 » ou vide"
94
122
 
123
+ #: templates/registration/otp_code_email.txt:2
124
+ #, python-format
125
+ msgid ""
126
+ "You're receiving this email because you requested an authentication code at "
127
+ "%(site_name)s."
128
+ msgstr ""
129
+ "Vous recevez cet e-mail parce que vous avez demandé un code d’authentication "
130
+ "sur %(site_name)s."
131
+
132
+ #: templates/registration/otp_code_email.txt:4
133
+ msgid "Authentication code:"
134
+ msgstr "Code d’authentication :"
135
+
136
+ #: templates/registration/otp_code_email.txt:6
137
+ #, python-format
138
+ msgid "This code is valid for %(otp_validity)s minutes."
139
+ msgstr "Ce code est valable durant %(otp_validity)s minutes."
140
+
141
+ #: templates/registration/otp_code_email.txt:8
142
+ #: templates/registration/password_reset_email.txt:10
143
+ #: templates/registration/welcome_email.txt:10
144
+ msgid "Thanks for using our site!"
145
+ msgstr "Merci d'utiliser notre site !"
146
+
147
+ #: templates/registration/otp_code_email.txt:10
148
+ #: templates/registration/password_reset_email.txt:12
149
+ #: templates/registration/welcome_email.txt:12
150
+ #, python-format
151
+ msgid "The %(site_name)s team"
152
+ msgstr "L'équipe de %(site_name)s"
153
+
154
+ #: templates/registration/otp_code_subject.txt:2
155
+ msgid "New authentication code for %(site_name)s"
156
+ msgstr "Nouveau code d’authentication pour %(site_name)s"
157
+
95
158
  #: templates/registration/password_reset_email.txt:2
96
159
  #, python-format
97
160
  msgid ""
@@ -113,17 +176,6 @@ msgstr ""
113
176
  msgid "Your username, in case you've forgotten:"
114
177
  msgstr "Votre nom d'utilisateur, au cas où vous l'auriez oublié :"
115
178
 
116
- #: templates/registration/password_reset_email.txt:10
117
- #: templates/registration/welcome_email.txt:10
118
- msgid "Thanks for using our site!"
119
- msgstr "Merci d'utiliser notre site !"
120
-
121
- #: templates/registration/password_reset_email.txt:12
122
- #: templates/registration/welcome_email.txt:12
123
- #, python-format
124
- msgid "The %(site_name)s team"
125
- msgstr "L'équipe de %(site_name)s"
126
-
127
179
  #: templates/registration/password_reset_subject.txt:2
128
180
  #, python-format
129
181
  msgid "Password reset on %(site_name)s"
@@ -139,43 +191,52 @@ msgstr "Bienvenue sur %(site_name)s."
139
191
  msgid "Welcome on %(site_name)s"
140
192
  msgstr "Bienvenue sur %(site_name)s"
141
193
 
142
- #: views/authentication_views.py:221 views/authentication_views.py:378
194
+ #: views/authentication_views.py:82
195
+ #, python-brace-format
196
+ msgid ""
197
+ "Your connection is temporarily disabled after several unsuccessful attempts, "
198
+ "please retry in {seconds} seconds."
199
+ msgstr ""
200
+ "Votre connexion est temporairement désactivée après plusieurs tentatives "
201
+ "infructueuses, veuillez réessayer dans {seconds} secondes."
202
+
203
+ #: views/authentication_views.py:244 views/authentication_views.py:407
143
204
  msgid "password updated successfully"
144
205
  msgstr "le mot de passe a été mis à jour avec succès"
145
206
 
146
- #: views/authentication_views.py:226
207
+ #: views/authentication_views.py:249
147
208
  msgid "Incorrect password"
148
209
  msgstr "Mot de passe incorrect"
149
210
 
150
- #: views/authentication_views.py:229 views/authentication_views.py:387
211
+ #: views/authentication_views.py:252 views/authentication_views.py:416
151
212
  msgid "Empty password is not allowed"
152
213
  msgstr "Un mot de passe vide n’est pas autorisé"
153
214
 
154
- #: views/authentication_views.py:313
215
+ #: views/authentication_views.py:341
155
216
  msgid "User and token are valid"
156
217
  msgstr "L'utilisateur et le token sont valides"
157
218
 
158
- #: views/authentication_views.py:315
219
+ #: views/authentication_views.py:343
159
220
  msgid "User or token is invalid"
160
221
  msgstr "L'utilisateur ou le token est invalide"
161
222
 
162
- #: views/authentication_views.py:420
223
+ #: views/authentication_views.py:449
163
224
  msgid "OTP is already activated"
164
225
  msgstr "L'OTP est déjà activé"
165
226
 
166
- #: views/authentication_views.py:458
227
+ #: views/authentication_views.py:487
167
228
  msgid "OTP is activated"
168
229
  msgstr "L'OTP est activé"
169
230
 
170
- #: views/authentication_views.py:459 views/authentication_views.py:493
231
+ #: views/authentication_views.py:488 views/authentication_views.py:522
171
232
  msgid "Invalid OTP code"
172
233
  msgstr "Code OTP invalide"
173
234
 
174
- #: views/authentication_views.py:492
235
+ #: views/authentication_views.py:521
175
236
  msgid "OTP is disabled"
176
237
  msgstr "L'OTP est désactivé"
177
238
 
178
- #: views/authentication_views.py:740
239
+ #: views/authentication_views.py:777
179
240
  msgid ""
180
241
  "If the email address you entered is correct, you will receive an email from "
181
242
  "us with instructions to reset your password."
@@ -184,6 +245,10 @@ msgstr ""
184
245
  "un courrier électronique de notre part contenant des instructions pour "
185
246
  "réinitialiser votre mot de passe."
186
247
 
248
+ #: views/authentication_views.py:845
249
+ msgid "A new authentication code has been sent by email."
250
+ msgstr "Un nouveau code d'authentification a été envoyé par e-mail."
251
+
187
252
  #: views/filters_views.py:79
188
253
  #, python-brace-format
189
254
  msgid "Invalid value for {filter} filter"
@@ -30,13 +30,29 @@ class JWTTokenDecodeMixin:
30
30
  return user
31
31
 
32
32
  @classmethod
33
- def decode_jwt(cls, token, otp_login=False):
33
+ def decode_jwt_header(cls, token):
34
34
  try:
35
35
  headers = jwt.get_unverified_header(token)
36
36
  if 'pfx_user_pk' not in headers:
37
37
  raise jwt.InvalidTokenError(
38
38
  "Missing pfx_user_pk in token headers")
39
- user = cls.get_cached_user(headers['pfx_user_pk'])
39
+ return headers['pfx_user_pk']
40
+ except (jwt.ExpiredSignatureError,
41
+ jwt.InvalidTokenError, jwt.InvalidSignatureError,
42
+ DecodeError) as e:
43
+ # Log these exceptions only in debug mode
44
+ logger.debug(e, exc_info=True)
45
+ raise
46
+ except Exception as e:
47
+ # Always logs unexpected exceptions
48
+ logger.exception(e)
49
+ raise
50
+
51
+ @classmethod
52
+ def decode_jwt(cls, token, otp_login=False):
53
+ user_pk = cls.decode_jwt_header(token)
54
+ try:
55
+ user = cls.get_cached_user(user_pk)
40
56
  decoded = jwt.decode(
41
57
  token,
42
58
  user.get_user_jwt_signature_key() + settings.PFX_SECRET_KEY,
@@ -49,11 +65,12 @@ class JWTTokenDecodeMixin:
49
65
  "This token is reserved for OTP login")
50
66
  return user
51
67
  except (get_user_model().DoesNotExist, jwt.ExpiredSignatureError,
52
- jwt.InvalidTokenError, jwt.InvalidSignatureError) as e:
68
+ jwt.InvalidTokenError, jwt.InvalidSignatureError,
69
+ DecodeError) as e:
53
70
  # Log these exceptions only in debug mode
54
71
  logger.debug(e, exc_info=True)
55
72
  raise
56
- except (DecodeError, Exception) as e:
73
+ except Exception as e:
57
74
  # Always logs unexpected exceptions
58
75
  logger.exception(e)
59
76
  raise
@@ -72,11 +89,11 @@ class AuthenticationMiddleware(JWTTokenDecodeMixin, MiddlewareMixin):
72
89
  authorization = request.headers.get('Authorization')
73
90
  if authorization:
74
91
  try:
75
- _, key = authorization.split("Bearer ")
92
+ _, token = authorization.split("Bearer ")
76
93
  except ValueError:
77
- key = None
94
+ token = ""
78
95
  try:
79
- request.user = self.decode_jwt(key)
96
+ request.user = self.decode_jwt(token)
80
97
  except Exception:
81
98
  request.user = AnonymousUser()
82
99
  else:
@@ -102,10 +119,10 @@ class CookieAuthenticationMiddleware(JWTTokenDecodeMixin, MiddlewareMixin):
102
119
  """
103
120
 
104
121
  def process_request(self, request):
105
- key = request.COOKIES.get('token')
106
- if key:
122
+ token = request.COOKIES.get('token', "")
123
+ if token:
107
124
  try:
108
- request.user = self.decode_jwt(key)
125
+ request.user = self.decode_jwt(token)
109
126
  except Exception:
110
127
  request.user = AnonymousUser()
111
128
  request.delete_cookie = True
@@ -1,7 +1,13 @@
1
- from django.conf import settings
1
+ from datetime import timedelta
2
+
2
3
  from django.db import models
4
+ from django.utils import timezone
3
5
  from django.utils.translation import gettext_lazy as _
4
6
 
7
+ from pfx.pfxcore.settings import PFXSettings
8
+
9
+ settings = PFXSettings()
10
+
5
11
 
6
12
  class OtpUserMixin(models.Model):
7
13
  otp_secret_token = models.CharField(
@@ -9,6 +15,8 @@ class OtpUserMixin(models.Model):
9
15
  blank=True, unique=True)
10
16
  otp_secret_token_tmp = models.CharField(
11
17
  _("Temporary OTP secret token"), max_length=32, null=True, blank=True)
18
+ hotp_count = models.IntegerField(_("HOTP count"), default=0)
19
+ hotp_expiry = models.DateTimeField(_("HOTP expiry"), default=timezone.now)
12
20
 
13
21
  class Meta:
14
22
  abstract = True
@@ -41,8 +49,25 @@ class OtpUserMixin(models.Model):
41
49
  def is_otp_valid(self, otp_code, tmp=False):
42
50
  import pyotp
43
51
  totp = pyotp.parse_uri(self.get_otp_setup_uri(tmp=tmp))
44
- return totp.verify(otp_code)
52
+ valid = totp.verify(otp_code)
53
+ if not valid and timezone.now() <= self.hotp_expiry:
54
+ hotp = pyotp.hotp.HOTP(
55
+ tmp and self.otp_secret_token_tmp or
56
+ self.otp_secret_token)
57
+ return hotp.verify(otp_code, self.hotp_count)
58
+ return valid
45
59
 
46
60
  def get_user_jwt_signature_key(self):
47
61
  return super().get_user_jwt_signature_key() + (
48
62
  self.otp_secret_token or "")
63
+
64
+ def get_hotp_code(self):
65
+ import pyotp
66
+ if not self.otp_secret_token:
67
+ raise Exception("OTP disabled")
68
+ self.hotp_count += 1
69
+ self.hotp_expiry = timezone.now() + timedelta(
70
+ minutes=settings.PFX_HOTP_CODE_VALIDITY)
71
+ self.save(update_fields=[
72
+ 'hotp_count', 'hotp_expiry'])
73
+ return pyotp.hotp.HOTP(self.otp_secret_token).at(self.hotp_count)
@@ -0,0 +1,12 @@
1
+ {% load i18n %}{% autoescape off %}
2
+ {% blocktrans %}You're receiving this email because you requested an authentication code at {{ site_name }}.{% endblocktrans %}
3
+
4
+ {% trans "Authentication code:" %} {{ otp_code }}
5
+
6
+ {% blocktrans %}This code is valid for {{ otp_validity }} minutes.{% endblocktrans %}
7
+
8
+ {% trans "Thanks for using our site!" %}
9
+
10
+ {% blocktrans %}The {{ site_name }} team{% endblocktrans %}
11
+
12
+ {% endautoescape %}
@@ -0,0 +1,3 @@
1
+ {% load i18n %}{% autoescape off %}
2
+ {% blocktrans %}New authentication code for {{ site_name }}{% endblocktrans %}
3
+ {% endautoescape %}
@@ -4,4 +4,5 @@ urlpatterns = register_views(
4
4
  views.LocaleRestView,
5
5
  views.AuthenticationView,
6
6
  views.SignupView,
7
- views.ForgottenPasswordView)
7
+ views.ForgottenPasswordView,
8
+ views.OtpEmailView)
@@ -1,6 +1,7 @@
1
1
  from .authentication_views import (
2
2
  AuthenticationView,
3
3
  ForgottenPasswordView,
4
+ OtpEmailView,
4
5
  SendMessageTokenMixin,
5
6
  SignupView,
6
7
  )
@@ -261,9 +261,7 @@ class AuthenticationView(
261
261
  return jwt.encode(
262
262
  payload,
263
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()),
264
+ headers=dict(pfx_user_pk=user.pk),
267
265
  algorithm="HS256")
268
266
 
269
267
  def get_extra_payload(self, user):
@@ -417,7 +415,7 @@ class AuthenticationView(
417
415
  raise UnauthorizedError()
418
416
 
419
417
  @method_decorator(never_cache)
420
- @rest_api("/otp/activate", public=False, method="put")
418
+ @rest_api("/otp/activate", public=False, method="put", priority_doc=52)
421
419
  def otp_activate(self, *args, **kwargs):
422
420
  """Entrypoint for :code:`PUT /otp/activate` route.
423
421
 
@@ -453,7 +451,7 @@ class AuthenticationView(
453
451
 
454
452
  @method_decorator(never_cache)
455
453
  @rest_api(
456
- "/otp/confirm", public=False, method="put",
454
+ "/otp/confirm", public=False, method="put", priority_doc=53,
457
455
  response_schema='message_schema')
458
456
  def otp_confirm(self, *args, **kwargs):
459
457
  """Entrypoint for :code:`PUT /otp/confirm` route.
@@ -488,7 +486,7 @@ class AuthenticationView(
488
486
  return JsonResponse(dict(message=_("Invalid OTP code")), status=422)
489
487
 
490
488
  @method_decorator(never_cache)
491
- @rest_api("/otp/disable", public=False, method="put")
489
+ @rest_api("/otp/disable", public=False, method="put", priority_doc=54)
492
490
  def otp_disable(self, *args, **kwargs):
493
491
  """Entrypoint for :code:`PUT /otp/disable` route.
494
492
 
@@ -523,7 +521,7 @@ class AuthenticationView(
523
521
 
524
522
  @method_decorator(never_cache)
525
523
  @rest_api(
526
- "/otp/login", public=True, method="post",
524
+ "/otp/login", public=True, method="post", priority_doc=50,
527
525
  response_schema='login_schema')
528
526
  def otp_login(self, *args, **kwargs):
529
527
  """Entrypoint for :code:`PUT /otp/login` route.
@@ -557,12 +555,15 @@ class AuthenticationView(
557
555
  description: If the token is missing, invalid or expired.
558
556
  403:
559
557
  description: If the OTP is disabled for this user.
560
-
561
558
  """
562
559
  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)
560
+ token = data.get('token', "")
561
+ try:
562
+ user_pk = self.decode_jwt_header(token)
563
+ ban_key = f"otp_{user_pk}"
564
+ except Exception:
565
+ raise UnauthorizedError()
566
+ ban_dt = LoginBan.objects.is_ban(ban_key)
566
567
  if ban_dt:
567
568
  return self.login_ban_response(ban_dt)
568
569
  try:
@@ -575,9 +576,9 @@ class AuthenticationView(
575
576
  if not user.otp_secret_token:
576
577
  raise ForbiddenError()
577
578
  if user.is_otp_valid(data.get('otp_code')):
578
- LoginBan.objects.unban(username)
579
+ LoginBan.objects.unban(ban_key)
579
580
  return self._login_success(user, mode, remember_me)
580
- LoginBan.objects.ban(username)
581
+ LoginBan.objects.ban(ban_key)
581
582
  return self.login_failed_response()
582
583
 
583
584
  def get_user(self, uidb64):
@@ -779,3 +780,106 @@ class ForgottenPasswordView(SendMessageTokenMixin, BodyMixin, BaseRestView):
779
780
  'you will receive an email from us with '
780
781
  'instructions to reset your password.')
781
782
  })
783
+
784
+
785
+ @rest_view("/auth/otp")
786
+ class OtpEmailView(BodyMixin, JWTTokenDecodeMixin, BaseRestView):
787
+ """View for forgotten password service."""
788
+ #: The email template.
789
+ email_template_name = 'registration/otp_code_email.txt'
790
+ #: The email subject template.
791
+ subject_template_name = 'registration/otp_code_subject.txt'
792
+ #: The HTML email template.
793
+ html_email_template_name = None
794
+ #: Extra email context.
795
+ extra_email_context = None
796
+ #: The from value of the email.
797
+ from_email = None
798
+ #: The email field of user model.
799
+ email_field = 'email'
800
+ #: The language field of user model
801
+ language_field = 'language'
802
+ #: The ApiDoc tags.
803
+ tags = [AUTHENTICATION_TAG]
804
+
805
+ @method_decorator(never_cache)
806
+ @rest_api(
807
+ "/email", public=True, method="post", priority_doc=51,
808
+ response_schema='message_schema')
809
+ def sent_email(self, *args, **kwargs):
810
+ """Entrypoint for :code:`POST /email` route.
811
+
812
+ Request a new OTP code by email.
813
+
814
+ :returns: The JSON response
815
+ :rtype: :class:`JsonResponse`
816
+ ---
817
+ post:
818
+ summary: Send OTP email
819
+ description: Request a new OTP code by email.
820
+ requestBody:
821
+ content:
822
+ application/json:
823
+ schema:
824
+ properties:
825
+ token:
826
+ type: string
827
+ description: a valid JWT user token. This
828
+ is required only if the user is not
829
+ already connected (with a Bearer token
830
+ or a Cookie).
831
+ responses:
832
+ 401:
833
+ description: If the token is missing, invalid or expired.
834
+ 403:
835
+ description: If the OTP is disabled for this user.
836
+ """
837
+ if self.request.user.is_anonymous:
838
+ data = self.deserialize_body()
839
+ try:
840
+ user, __, __ = self.decode_jwt(
841
+ data.get('token', ""), otp_login=True)
842
+ except Exception:
843
+ raise UnauthorizedError()
844
+ else:
845
+ user = self.request.user
846
+ if not isinstance(user, OtpUserMixin):
847
+ logger.error("User must inherit OtpUserMixin to activate OTP")
848
+ raise NotFoundError()
849
+ if not user.otp_secret_token:
850
+ raise ForbiddenError()
851
+ self.send_otp_message(user)
852
+ return JsonResponse({
853
+ 'message': _('A new authentication code has been sent by email.')})
854
+
855
+ def send_otp_message(self, user):
856
+ """Send an email to a user with an OTP code.
857
+
858
+ :param user: The user
859
+ """
860
+ from django.utils import translation
861
+ lang = str(getattr(user, self.language_field, settings.LANGUAGE_CODE))
862
+
863
+ otp_code = user.get_hotp_code()
864
+ data = {
865
+ 'target_user': user,
866
+ 'otp_code': otp_code,
867
+ 'otp_validity': settings.PFX_HOTP_CODE_VALIDITY,
868
+ 'site_name': settings.PFX_SITE_NAME,
869
+ 'user': user,
870
+ **(self.extra_email_context or {})
871
+ }
872
+ with translation.override(lang):
873
+ subject = loader.render_to_string(self.subject_template_name, data)
874
+ # Email subject *must not* contain newlines
875
+ subject = ''.join(subject.splitlines())
876
+ body = loader.render_to_string(self.email_template_name, data)
877
+ email_message = EmailMultiAlternatives(
878
+ subject, body, self.from_email,
879
+ [getattr(user, self.email_field)])
880
+ if self.html_email_template_name is not None:
881
+ html_email = loader.render_to_string(
882
+ self.html_email_template_name, data)
883
+ email_message.attach_alternative(
884
+ html_email, 'text/html')
885
+ email_message.send()
@@ -944,6 +944,34 @@ class AuthAPITest(TestAssertMixin, TransactionTestCase):
944
944
  self.assertIsNone(self.user1.otp_secret_token)
945
945
  self.assertIsNone(self.user1.otp_secret_token_tmp)
946
946
 
947
+ @override_settings(
948
+ PFX_HOTP_CODE_VALIDITY=15,
949
+ PFX_TOKEN_OTP_VALIDITY={'minutes': 30},)
950
+ def test_otp_disable_by_email(self):
951
+ self.enable_otp(self.user1)
952
+
953
+ self.otp_login(self.user1, 'RIGHT PASSWORD')
954
+
955
+ with freeze_time("2023-05-01 08:00:00"):
956
+ response = self.client.post('/api/auth/otp/email', dict())
957
+ self.assertRC(response, 200)
958
+
959
+ self.assertEqual(
960
+ mail.outbox[0].subject,
961
+ f'New authentication code for {settings.PFX_SITE_NAME}')
962
+ code_match = re.search(
963
+ r'Authentication code: (\d{6})', mail.outbox[0].body)
964
+ self.assertIsNotNone(code_match)
965
+ otp_code = int(code_match.group(1))
966
+
967
+ response = self.client.put('/api/auth/otp/disable', dict(
968
+ otp_code=otp_code))
969
+ self.assertRC(response, 200)
970
+ self.assertJE(response, 'message', "OTP is disabled")
971
+ self.user1.refresh_from_db()
972
+ self.assertIsNone(self.user1.otp_secret_token)
973
+ self.assertIsNone(self.user1.otp_secret_token_tmp)
974
+
947
975
  def test_otp_disable_bad_code(self):
948
976
  self.enable_otp(self.user1)
949
977
 
@@ -1195,3 +1223,91 @@ class AuthAPITest(TestAssertMixin, TransactionTestCase):
1195
1223
  '/api/private/authors',
1196
1224
  HTTP_AUTHORIZATION='Bearer ' + login_token)
1197
1225
  self.assertRC(response, 200)
1226
+
1227
+ @override_settings(PFX_HOTP_CODE_VALIDITY=15)
1228
+ def test_send_otp_email(self):
1229
+ self.enable_otp(self.user1)
1230
+
1231
+ with freeze_time("2023-05-01 08:00:00"):
1232
+ response = self.client.post('/api/auth/login', dict(
1233
+ username='jrr.tolkien',
1234
+ password='RIGHT PASSWORD'))
1235
+ self.assertRC(response, 200)
1236
+ self.assertJE(response, 'need_otp', True)
1237
+ self.assertJEExists(response, 'token')
1238
+ otp_token = self.get_val(response, 'token')
1239
+
1240
+ response = self.client.post('/api/auth/otp/email', dict(
1241
+ token=otp_token))
1242
+ self.assertRC(response, 200)
1243
+
1244
+ self.assertEqual(
1245
+ mail.outbox[0].subject,
1246
+ f'New authentication code for {settings.PFX_SITE_NAME}')
1247
+ code_match = re.search(
1248
+ r'Authentication code: (\d{6})', mail.outbox[0].body)
1249
+ self.assertIsNotNone(code_match)
1250
+ otp_code = int(code_match.group(1))
1251
+
1252
+ response = self.client.post('/api/auth/otp/login', dict(
1253
+ token=otp_token,
1254
+ otp_code=otp_code))
1255
+ self.assertRC(response, 200)
1256
+ self.assertJEExists(response, 'token')
1257
+ login_token = self.get_val(response, 'token')
1258
+
1259
+ # Login token is accepted
1260
+ response = self.client.get(
1261
+ '/api/private/authors',
1262
+ HTTP_AUTHORIZATION='Bearer ' + login_token)
1263
+ self.assertRC(response, 200)
1264
+
1265
+ @override_settings(
1266
+ PFX_HOTP_CODE_VALIDITY=15,
1267
+ PFX_TOKEN_OTP_VALIDITY={'minutes': 30},)
1268
+ def test_send_otp_email_expiry(self):
1269
+ self.enable_otp(self.user1)
1270
+
1271
+ with freeze_time("2023-05-01 08:00:00"):
1272
+ response = self.client.post('/api/auth/login', dict(
1273
+ username='jrr.tolkien',
1274
+ password='RIGHT PASSWORD'))
1275
+ self.assertRC(response, 200)
1276
+ self.assertJE(response, 'need_otp', True)
1277
+ self.assertJEExists(response, 'token')
1278
+ otp_token = self.get_val(response, 'token')
1279
+
1280
+ response = self.client.post('/api/auth/otp/email', dict(
1281
+ token=otp_token))
1282
+ self.assertRC(response, 200)
1283
+
1284
+ self.assertEqual(
1285
+ mail.outbox[0].subject,
1286
+ f'New authentication code for {settings.PFX_SITE_NAME}')
1287
+ code_match = re.search(
1288
+ r'Authentication code: (\d{6})', mail.outbox[0].body)
1289
+ self.assertIsNotNone(code_match)
1290
+ otp_code = code_match.group(1)
1291
+
1292
+ with freeze_time("2023-05-01 08:15:00"):
1293
+ response = self.client.post('/api/auth/otp/login', dict(
1294
+ token=otp_token,
1295
+ otp_code=otp_code))
1296
+ self.assertRC(response, 200)
1297
+ self.assertJEExists(response, 'token')
1298
+
1299
+ with freeze_time("2023-05-01 08:15:01"):
1300
+ response = self.client.post('/api/auth/otp/login', dict(
1301
+ token=otp_token,
1302
+ otp_code=otp_code))
1303
+ self.assertRC(response, 422)
1304
+
1305
+ @override_settings(
1306
+ PFX_HOTP_CODE_VALIDITY=15,
1307
+ PFX_TOKEN_OTP_VALIDITY={'minutes': 30},)
1308
+ def test_send_otp_email_without_token(self):
1309
+ self.enable_otp(self.user1)
1310
+
1311
+ with freeze_time("2023-05-01 08:00:00"):
1312
+ response = self.client.post('/api/auth/otp/email', dict())
1313
+ self.assertRC(response, 401)
File without changes
File without changes
File without changes
File without changes
File without changes