django-pfx 1.4.dev16__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.dev16 → django-pfx-1.4.dev22}/PKG-INFO +1 -1
  2. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/PKG-INFO +1 -1
  3. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/SOURCES.txt +2 -0
  4. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/conf.py +2 -1
  5. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/authentication.md +154 -0
  6. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/default_settings.py +4 -0
  7. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/authentication.py +13 -9
  8. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/__init__.py +2 -0
  9. django-pfx-1.4.dev22/pfx/pfxcore/models/abstract_pfx_base_user.py +15 -0
  10. django-pfx-1.4.dev22/pfx/pfxcore/models/login_ban.py +60 -0
  11. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/otp_user_mixin.py +4 -0
  12. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/authentication_views.py +45 -7
  13. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/requirements.txt +1 -0
  14. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/models.py +3 -2
  15. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_auth_api.py +216 -46
  16. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/.gitignore +0 -0
  17. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/.gitlab-ci.yml +0 -0
  18. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/.pre-commit-config.yaml +0 -0
  19. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/LICENSE +0 -0
  20. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/MANIFEST.in +0 -0
  21. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/README.md +0 -0
  22. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django-admin-test +0 -0
  23. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/dependency_links.txt +0 -0
  24. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/requires.txt +0 -0
  25. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/top_level.txt +0 -0
  26. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/Makefile +0 -0
  27. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/index.rst +0 -0
  28. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/api.views.rst +0 -0
  29. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/decorator.md +0 -0
  30. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/generate_openapi.md +0 -0
  31. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/getting_started.md +0 -0
  32. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/internationalisation.md +0 -0
  33. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/model.md +0 -0
  34. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/pfx_views.md +0 -0
  35. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/profiling.md +0 -0
  36. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/settings.md +0 -0
  37. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/testing.md +0 -0
  38. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/img/pfx.png +0 -0
  39. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/img/pfx.svg +0 -0
  40. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/__init__.py +0 -0
  41. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/__init__.py +0 -0
  42. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/__init__.py +0 -0
  43. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/parameters.py +0 -0
  44. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/schema.py +0 -0
  45. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/tags.py +0 -0
  46. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apps.py +0 -0
  47. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/decorator/__init__.py +0 -0
  48. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/decorator/rest.py +0 -0
  49. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/exceptions.py +0 -0
  50. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/fields.py +0 -0
  51. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/http/__init__.py +0 -0
  52. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/http/json_response.py +0 -0
  53. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.mo +0 -0
  54. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.po +0 -0
  55. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/management/__init__.py +0 -0
  56. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/__init__.py +0 -0
  57. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/makeapidoc.py +0 -0
  58. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/profile.py +0 -0
  59. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/__init__.py +0 -0
  60. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/locale.py +0 -0
  61. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/profiling.py +0 -0
  62. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/cache_mixins.py +0 -0
  63. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/not_null_fields.py +0 -0
  64. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/pfx_models.py +0 -0
  65. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/user_filtered_queryset_mixin.py +0 -0
  66. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/serializers/__init__.py +0 -0
  67. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/serializers/json.py +0 -0
  68. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/settings.py +0 -0
  69. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/shortcuts.py +0 -0
  70. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/storage/__init__.py +0 -0
  71. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/storage/s3_storage.py +0 -0
  72. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/password_reset_email.txt +0 -0
  73. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/password_reset_subject.txt +0 -0
  74. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/welcome_email.txt +0 -0
  75. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/welcome_subject.txt +0 -0
  76. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/test.py +0 -0
  77. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/urls.py +0 -0
  78. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/__init__.py +0 -0
  79. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/fields.py +0 -0
  80. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/filters_views.py +0 -0
  81. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/locale_views.py +0 -0
  82. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/__init__.py +0 -0
  83. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/date_format.py +0 -0
  84. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/groups.py +0 -0
  85. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_count.py +0 -0
  86. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_items.py +0 -0
  87. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_mode.py +0 -0
  88. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_order.py +0 -0
  89. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_search.py +0 -0
  90. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/media_redirect.py +0 -0
  91. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_fields.py +0 -0
  92. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_filters.py +0 -0
  93. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_orders.py +0 -0
  94. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset.py +0 -0
  95. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_limit.py +0 -0
  96. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_offset.py +0 -0
  97. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page.py +0 -0
  98. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page_size.py +0 -0
  99. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page_subset.py +0 -0
  100. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/rest_views.py +0 -0
  101. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pyproject.toml +0 -0
  102. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/runtest.py +0 -0
  103. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/serve-doc +0 -0
  104. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/setup.cfg +0 -0
  105. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/setup.py +0 -0
  106. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/__init__.py +0 -0
  107. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/locale/fr/LC_MESSAGES/django.po +0 -0
  108. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/__init__.py +0 -0
  109. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/ci.py +0 -0
  110. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/common.py +0 -0
  111. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/dev.py +0 -0
  112. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/dev_custom_example.py +0 -0
  113. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/dev_default.py +0 -0
  114. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/__init__.py +0 -0
  115. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/basic_api_errors.py +0 -0
  116. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/basic_api_test.py +0 -0
  117. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_api_doc.py +0 -0
  118. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_body_mixin.py +0 -0
  119. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_cache.py +0 -0
  120. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_client.py +0 -0
  121. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_fields.py +0 -0
  122. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_filters.py +0 -0
  123. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_locale_api.py +0 -0
  124. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_perm_tests.py +0 -0
  125. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_perms_api.py +0 -0
  126. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_profiling_middleware.py +0 -0
  127. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_shortcuts.py +0 -0
  128. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_timezone_middleware.py +0 -0
  129. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_tools.py +0 -0
  130. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_user_queryset.py +0 -0
  131. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_view_decorators.py +0 -0
  132. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_view_fields.py +0 -0
  133. {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/urls.py +0 -0
  134. {django-pfx-1.4.dev16 → 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.dev16
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.dev16
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
@@ -53,7 +53,8 @@ release = '1.0'
53
53
  # ones.
54
54
  extensions = ['myst_parser',
55
55
  'sphinx.ext.autodoc',
56
- 'sphinx.ext.autosummary']
56
+ 'sphinx.ext.autosummary',
57
+ 'sphinxcontrib.mermaid']
57
58
  autodoc_member_order = 'bysource'
58
59
 
59
60
  # Add any paths that contain templates here, relative to this directory.
@@ -48,12 +48,50 @@ To use the `CookieAuthenticationMiddleware`, you need to configure the following
48
48
 
49
49
  See the [MDN Website](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) for more details.
50
50
 
51
+ ### Multifactor Authentication
52
+ Multifactor authentication can be enabled in django-pfx Authentication API.
53
+
54
+ PFX currently provides MFA with One Time Password (OTP), compatible with FreeOTP,
55
+ Google Authenticator and other OTP app.
56
+
57
+ To enable this feature, install django-pfx with otp
58
+
59
+ ```bash
60
+ pip install django-pfx[otp]
61
+ ```
62
+
63
+ Then the user class must use the ```OtpUserMixin```
64
+
65
+ ```python
66
+ from django.contrib.auth.models import AbstractUser
67
+ from pfx.pfxcore.models import OtpUserMixin
68
+
69
+ class MyUser(OtpUserMixin, AbstractUser):
70
+ pass
71
+ ```
72
+
73
+ The user can then enable or disable the OTP auth using the [services documented below](#enable-mfa-otp).
74
+
51
75
  ## Services
52
76
 
53
77
  ### Login
54
78
  A login rest services with a `mode` parameter to choose between JWT bearer token or cookie authentication.
55
79
  In cookie mode, the JWT token is saved in an HTTP-only cookie.
56
80
 
81
+ ```{mermaid}
82
+
83
+ sequenceDiagram
84
+ participant App
85
+ participant API
86
+ App->>API: POST /auth/login
87
+ alt Authentication success
88
+ API->>App: 200 OK + cookie
89
+ else Authentication failed
90
+ API->>App: 401 Unautorized
91
+ end
92
+
93
+ ```
94
+
57
95
  **Request :** `POST` `/auth/login?mode=<mode>`
58
96
 
59
97
  **Request body:**
@@ -75,6 +113,122 @@ In cookie mode, the JWT token is saved in an HTTP-only cookie.
75
113
  | user | the user object |
76
114
 
77
115
 
116
+ ### Login + TOTP
117
+ If the user has enabled the TOTP login, the process is the same as above for the first step,
118
+ except that the login service returns a temporary JWT token valid only for the otp services.
119
+
120
+ ```{mermaid}
121
+
122
+ sequenceDiagram
123
+ participant App
124
+ participant API
125
+ App->>API: POST /auth/login
126
+ alt Login success
127
+ API->>App: 200 OK
128
+ note left of API: temporary jwt token
129
+ App->>API: POST /auth/otp/login
130
+ note right of App: temporary jwt token + OTP token in body.
131
+ alt OTP success
132
+ API->>App: 200 OK
133
+ note left of API: JWT token in cookie or in body <br/> + user in body
134
+ else OTP failed
135
+ API->>App: 401 Unautorized
136
+ end
137
+ else Login failed
138
+ API->>App: 401 Unautorized
139
+ end
140
+
141
+ ```
142
+
143
+ **Request :** `POST` `/auth/login?mode=<mode>`
144
+
145
+ **Request body:**
146
+
147
+ | Field | Description |
148
+ |-------------|-------------------------------------|
149
+ | username | the username |
150
+ | password | the password |
151
+ | remember_me | If true, use a long validity token. |
152
+
153
+ **Responses :**
154
+
155
+ * `HTTP 401` if the credentials are incorrect
156
+ * `HTTP 200` with the following body
157
+
158
+ | Field | Description |
159
+ |-------|-----------------------|
160
+ | token | a temporary jwt token |
161
+
162
+
163
+ **Request :** `POST` `/auth/otp/login?mode=<mode>`
164
+
165
+ **Request body:**
166
+
167
+ | Field | Description |
168
+ |-------------|-------------------------------------|
169
+ | token | the temporary jwt token |
170
+ | otp | the one time password (TOTP) |
171
+
172
+ **Responses :**
173
+
174
+ * `HTTP 401` if the temporary jwt token is incorrect
175
+ * `HTTP 403` if the otp is incorrect
176
+ * `HTTP 200` with the following body
177
+
178
+ | Field | Description |
179
+ |-------|----------------------------------------|
180
+ | token | the jwt token. (only if mode is 'jwt') |
181
+ | user | the user object |
182
+
183
+ ### Enable MFA OTP
184
+ Services to enable the MFA with OTP.
185
+ You have to call first the `activate` service to get the URI,
186
+ encode it as a QR code and present it in the UI of your software.
187
+ Then user then scans this QR code to add the OTP secret to his OTP App.
188
+ Finally, the `confirm` service must be called with an OTP code retrieved
189
+ in the OTP App to confirm the activation.
190
+
191
+ **Request :** `PUT` `/auth/otp/activate`
192
+
193
+ **Responses :**
194
+
195
+ * `HTTP 400` if the otp is already enabled
196
+ * `HTTP 200` with the following body
197
+
198
+ | Field | Description |
199
+ |-----------|---------------------------------------|
200
+ | setup_uri | the uri to enable the OTP application |
201
+
202
+ **Request :** `PUT` `/auth/otp/confirm`
203
+
204
+ **Request body:**
205
+
206
+ | Field | Description |
207
+ |-------------|---------------------------------------|
208
+ | otp_code | the otp code retrieved in the OTP app |
209
+
210
+ **Responses :**
211
+
212
+ * `HTTP 422` if the otp code is invalid
213
+ * `HTTP 200` if the otp code is valid
214
+
215
+ ### Disable MFA OTP
216
+ A service to disable the MFA with OTP.
217
+
218
+ **Request :** `PUT` `/auth/otp/disable`
219
+
220
+ **Request body:**
221
+
222
+ | Field | Description |
223
+ |-------------|---------------------------------------|
224
+ | otp_code | the otp code retrieved in the OTP app |
225
+
226
+ **Responses :**
227
+
228
+ * `HTTP 422` if the otp code is invalid
229
+ * `HTTP 200` if the otp code is valid
230
+
231
+
78
232
  ### Logout
79
233
  A service that deletes the authentication cookie if it exists.
80
234
 
@@ -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):
@@ -14,6 +14,7 @@ apispec
14
14
  sphinx
15
15
  myst-parser
16
16
  sphinx-rtd-theme
17
+ sphinxcontrib-mermaid
17
18
  dill
18
19
  apispec
19
20
  pyyaml
@@ -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,