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.
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/PKG-INFO +1 -1
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/PKG-INFO +1 -1
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/SOURCES.txt +2 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/conf.py +2 -1
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/authentication.md +154 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/default_settings.py +4 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/authentication.py +13 -9
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/__init__.py +2 -0
- django-pfx-1.4.dev22/pfx/pfxcore/models/abstract_pfx_base_user.py +15 -0
- django-pfx-1.4.dev22/pfx/pfxcore/models/login_ban.py +60 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/otp_user_mixin.py +4 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/authentication_views.py +45 -7
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/requirements.txt +1 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/models.py +3 -2
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_auth_api.py +216 -46
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/.gitignore +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/.gitlab-ci.yml +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/.pre-commit-config.yaml +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/LICENSE +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/MANIFEST.in +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/README.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django-admin-test +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/dependency_links.txt +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/requires.txt +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/django_pfx.egg-info/top_level.txt +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/Makefile +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/index.rst +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/api.views.rst +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/decorator.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/generate_openapi.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/getting_started.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/internationalisation.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/model.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/pfx_views.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/profiling.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/settings.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/doc/source/testing.md +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/img/pfx.png +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/img/pfx.svg +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/parameters.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/schema.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apidoc/tags.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/apps.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/decorator/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/decorator/rest.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/exceptions.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/fields.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/http/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/http/json_response.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.mo +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.po +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/management/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/makeapidoc.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/management/commands/profile.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/locale.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/middleware/profiling.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/cache_mixins.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/not_null_fields.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/pfx_models.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/models/user_filtered_queryset_mixin.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/serializers/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/serializers/json.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/settings.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/shortcuts.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/storage/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/storage/s3_storage.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/password_reset_email.txt +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/password_reset_subject.txt +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/welcome_email.txt +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/templates/registration/welcome_subject.txt +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/test.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/urls.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/fields.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/filters_views.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/locale_views.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/date_format.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/groups.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_count.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_items.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_mode.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_order.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/list_search.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/media_redirect.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_fields.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_filters.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/meta_orders.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_limit.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_offset.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page_size.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/parameters/subset_page_subset.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pfx/pfxcore/views/rest_views.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/pyproject.toml +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/runtest.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/serve-doc +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/setup.cfg +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/setup.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/locale/fr/LC_MESSAGES/django.po +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/ci.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/common.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/dev.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/dev_custom_example.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/settings/dev_default.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/__init__.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/basic_api_errors.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/basic_api_test.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_api_doc.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_body_mixin.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_cache.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_client.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_fields.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_filters.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_locale_api.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_perm_tests.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_perms_api.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_profiling_middleware.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_shortcuts.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_timezone_middleware.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_tools.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_user_queryset.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_view_decorators.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/tests/test_view_fields.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/urls.py +0 -0
- {django-pfx-1.4.dev16 → django-pfx-1.4.dev22}/tests/views.py +0 -0
|
@@ -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,
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
logger.
|
|
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:
|
|
56
|
+
except (DecodeError, Exception) as e:
|
|
57
|
+
# Always logs unexpected exceptions
|
|
54
58
|
logger.exception(e)
|
|
55
59
|
raise
|
|
56
60
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
259
|
+
exp=exp,
|
|
237
260
|
**self.get_extra_payload(user), **extra_payload)
|
|
238
261
|
return jwt.encode(
|
|
239
|
-
payload,
|
|
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
|
-
(
|
|
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
|
|
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,
|
|
52
|
+
class User(CacheableMixin, JSONReprMixin, OtpUserMixin, AbstractPFXBaseUser):
|
|
52
53
|
username = models.CharField(
|
|
53
54
|
'username',
|
|
54
55
|
max_length=150,
|