django-pfx 1.4.dev28__tar.gz → 1.4.dev36__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 (144) hide show
  1. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/.gitlab-ci.yml +20 -7
  2. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/PKG-INFO +1 -1
  3. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/django_pfx.egg-info/PKG-INFO +1 -1
  4. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/django_pfx.egg-info/SOURCES.txt +7 -2
  5. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/django_pfx.egg-info/top_level.txt +1 -0
  6. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/api.views.rst +18 -1
  7. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/authentication.md +42 -14
  8. django-pfx-1.4.dev36/make_messages +3 -0
  9. django-pfx-1.4.dev36/manage.py +37 -0
  10. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.po +28 -27
  11. django-pfx-1.4.dev36/pfx/pfxcore/migrations/0001_initial.py +59 -0
  12. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/models/__init__.py +1 -0
  13. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/models/abstract_pfx_base_user.py +4 -1
  14. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/models/otp_user_mixin.py +39 -0
  15. django-pfx-1.4.dev36/pfx/pfxcore/models/pfx_user.py +11 -0
  16. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/authentication_views.py +2 -2
  17. django-pfx-1.4.dev36/pfx/settings/dev.py +8 -0
  18. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/setup.cfg +1 -0
  19. django-pfx-1.4.dev36/tests/__init__.py +0 -0
  20. django-pfx-1.4.dev36/tests/settings/__init__.py +0 -0
  21. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_auth_api.py +2 -2
  22. django-pfx-1.4.dev28/django-admin-test +0 -10
  23. django-pfx-1.4.dev28/runtest.py +0 -17
  24. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/.gitignore +0 -0
  25. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/.pre-commit-config.yaml +0 -0
  26. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/LICENSE +0 -0
  27. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/MANIFEST.in +0 -0
  28. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/README.md +0 -0
  29. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/django_pfx.egg-info/dependency_links.txt +0 -0
  30. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/django_pfx.egg-info/requires.txt +0 -0
  31. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/Makefile +0 -0
  32. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/conf.py +0 -0
  33. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/index.rst +0 -0
  34. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/decorator.md +0 -0
  35. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/generate_openapi.md +0 -0
  36. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/getting_started.md +0 -0
  37. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/internationalisation.md +0 -0
  38. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/model.md +0 -0
  39. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/pfx_views.md +0 -0
  40. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/profiling.md +0 -0
  41. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/settings.md +0 -0
  42. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/doc/source/testing.md +0 -0
  43. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/img/pfx.png +0 -0
  44. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/img/pfx.svg +0 -0
  45. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/__init__.py +0 -0
  46. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/__init__.py +0 -0
  47. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/apidoc/__init__.py +0 -0
  48. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/apidoc/parameters.py +0 -0
  49. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/apidoc/schema.py +0 -0
  50. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/apidoc/tags.py +0 -0
  51. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/apps.py +0 -0
  52. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/decorator/__init__.py +0 -0
  53. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/decorator/rest.py +0 -0
  54. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/default_settings.py +0 -0
  55. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/exceptions.py +0 -0
  56. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/fields.py +0 -0
  57. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/http/__init__.py +0 -0
  58. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/http/json_response.py +0 -0
  59. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/locale/fr/LC_MESSAGES/django.mo +0 -0
  60. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/management/__init__.py +0 -0
  61. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/management/commands/__init__.py +0 -0
  62. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/management/commands/makeapidoc.py +0 -0
  63. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/management/commands/profile.py +0 -0
  64. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/middleware/__init__.py +0 -0
  65. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/middleware/authentication.py +0 -0
  66. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/middleware/locale.py +0 -0
  67. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/middleware/profiling.py +0 -0
  68. {django-pfx-1.4.dev28/pfx/pfxcore/serializers → django-pfx-1.4.dev36/pfx/pfxcore/migrations}/__init__.py +0 -0
  69. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/models/cache_mixins.py +0 -0
  70. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/models/login_ban.py +0 -0
  71. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/models/not_null_fields.py +0 -0
  72. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/models/pfx_models.py +0 -0
  73. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/models/user_filtered_queryset_mixin.py +0 -0
  74. {django-pfx-1.4.dev28/tests → django-pfx-1.4.dev36/pfx/pfxcore/serializers}/__init__.py +0 -0
  75. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/serializers/json.py +0 -0
  76. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/settings.py +0 -0
  77. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/shortcuts.py +0 -0
  78. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/storage/__init__.py +0 -0
  79. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/storage/s3_storage.py +0 -0
  80. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/templates/registration/otp_code_email.txt +0 -0
  81. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/templates/registration/otp_code_subject.txt +0 -0
  82. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/templates/registration/password_reset_email.txt +0 -0
  83. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/templates/registration/password_reset_subject.txt +0 -0
  84. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/templates/registration/welcome_email.txt +0 -0
  85. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/templates/registration/welcome_subject.txt +0 -0
  86. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/test.py +0 -0
  87. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/urls.py +0 -0
  88. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/__init__.py +0 -0
  89. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/fields.py +0 -0
  90. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/filters_views.py +0 -0
  91. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/locale_views.py +0 -0
  92. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/__init__.py +0 -0
  93. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/date_format.py +0 -0
  94. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/groups.py +0 -0
  95. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/list_count.py +0 -0
  96. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/list_items.py +0 -0
  97. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/list_mode.py +0 -0
  98. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/list_order.py +0 -0
  99. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/list_search.py +0 -0
  100. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/media_redirect.py +0 -0
  101. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/meta_fields.py +0 -0
  102. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/meta_filters.py +0 -0
  103. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/meta_orders.py +0 -0
  104. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/subset.py +0 -0
  105. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/subset_limit.py +0 -0
  106. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/subset_offset.py +0 -0
  107. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/subset_page.py +0 -0
  108. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/subset_page_size.py +0 -0
  109. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/parameters/subset_page_subset.py +0 -0
  110. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pfx/pfxcore/views/rest_views.py +0 -0
  111. {django-pfx-1.4.dev28/tests → django-pfx-1.4.dev36/pfx}/settings/__init__.py +0 -0
  112. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/pyproject.toml +0 -0
  113. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/requirements.txt +0 -0
  114. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/serve-doc +0 -0
  115. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/setup.py +0 -0
  116. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/locale/fr/LC_MESSAGES/django.po +0 -0
  117. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/models.py +0 -0
  118. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/settings/ci.py +0 -0
  119. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/settings/common.py +0 -0
  120. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/settings/dev.py +0 -0
  121. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/settings/dev_custom_example.py +0 -0
  122. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/settings/dev_default.py +0 -0
  123. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/__init__.py +0 -0
  124. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/basic_api_errors.py +0 -0
  125. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/basic_api_test.py +0 -0
  126. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_api_doc.py +0 -0
  127. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_body_mixin.py +0 -0
  128. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_cache.py +0 -0
  129. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_client.py +0 -0
  130. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_fields.py +0 -0
  131. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_filters.py +0 -0
  132. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_locale_api.py +0 -0
  133. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_perm_tests.py +0 -0
  134. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_perms_api.py +0 -0
  135. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_profiling_middleware.py +0 -0
  136. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_settings.py +0 -0
  137. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_shortcuts.py +0 -0
  138. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_timezone_middleware.py +0 -0
  139. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_tools.py +0 -0
  140. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_user_queryset.py +0 -0
  141. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_view_decorators.py +0 -0
  142. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/tests/test_view_fields.py +0 -0
  143. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/urls.py +0 -0
  144. {django-pfx-1.4.dev28 → django-pfx-1.4.dev36}/tests/views.py +0 -0
@@ -23,20 +23,29 @@ stages:
23
23
  - build
24
24
  - package
25
25
 
26
+ quality check:
27
+ variables:
28
+ DJANGO_VERSION: 'django==4.2.*' # LTS
29
+ script:
30
+ - apt-get update
31
+ - apt-get install -y gettext
32
+ - flake8 .
33
+ - isort . -c
34
+ - ./make_messages
35
+ - git diff --exit-code # exit if messages changed
36
+ - python manage.py makemigrations --check --dry-run --setting='pfx.settings.dev'
37
+
26
38
  test:
27
39
  variables:
28
40
  POSTGRES_DB: ci
29
41
  POSTGRES_USER: postgres
30
42
  POSTGRES_PASSWORD: postgres
31
- TEST_SETTINGS_MODULE: 'tests.settings.ci'
43
+ DJANGO_SETTINGS_MODULE: 'tests.settings.ci'
32
44
  script:
33
45
  - apt-get update
34
46
  - apt-get install -y gettext
35
- - flake8 .
36
- - isort . -c
37
- - django-admin compilemessages --ignore venv
38
- - export DJANGO_SETTINGS_MODULE=$TEST_SETTINGS_MODULE
39
- - coverage run --source='.' runtest.py
47
+ - python manage.py compilemessages -i venv
48
+ - coverage run --source='.' manage.py test
40
49
  - coverage report
41
50
  - coverage xml
42
51
  - coverage html
@@ -86,6 +95,10 @@ pages:
86
95
  - master
87
96
 
88
97
  package:
98
+ stage: package
99
+ needs:
100
+ - job: quality check
101
+ - job: test
89
102
  rules:
90
103
  - if: $CI_COMMIT_TAG =~ /^\d+\.\d+$/
91
104
  when: on_success
@@ -97,6 +110,6 @@ package:
97
110
  - git fetch --prune --unshallow
98
111
  - pip install --upgrade twine
99
112
  - pip install -r requirements.txt
100
- - (cd pfx/pfxcore/ && django-admin compilemessages)
113
+ - ./manage.py compilemessages -i venv -i tests
101
114
  - python3 -m build
102
115
  - TWINE_PASSWORD=${PYPI_DEPLOY_TOKEN} TWINE_USERNAME=__token__ python3 -m twine upload dist/*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-pfx
3
- Version: 1.4.dev28
3
+ Version: 1.4.dev36
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.dev28
3
+ Version: 1.4.dev36
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
@@ -4,10 +4,10 @@
4
4
  LICENSE
5
5
  MANIFEST.in
6
6
  README.md
7
- django-admin-test
7
+ make_messages
8
+ manage.py
8
9
  pyproject.toml
9
10
  requirements.txt
10
- runtest.py
11
11
  serve-doc
12
12
  setup.cfg
13
13
  setup.py
@@ -60,6 +60,8 @@ pfx/pfxcore/middleware/__init__.py
60
60
  pfx/pfxcore/middleware/authentication.py
61
61
  pfx/pfxcore/middleware/locale.py
62
62
  pfx/pfxcore/middleware/profiling.py
63
+ pfx/pfxcore/migrations/0001_initial.py
64
+ pfx/pfxcore/migrations/__init__.py
63
65
  pfx/pfxcore/models/__init__.py
64
66
  pfx/pfxcore/models/abstract_pfx_base_user.py
65
67
  pfx/pfxcore/models/cache_mixins.py
@@ -67,6 +69,7 @@ pfx/pfxcore/models/login_ban.py
67
69
  pfx/pfxcore/models/not_null_fields.py
68
70
  pfx/pfxcore/models/otp_user_mixin.py
69
71
  pfx/pfxcore/models/pfx_models.py
72
+ pfx/pfxcore/models/pfx_user.py
70
73
  pfx/pfxcore/models/user_filtered_queryset_mixin.py
71
74
  pfx/pfxcore/serializers/__init__.py
72
75
  pfx/pfxcore/serializers/json.py
@@ -102,6 +105,8 @@ pfx/pfxcore/views/parameters/subset_offset.py
102
105
  pfx/pfxcore/views/parameters/subset_page.py
103
106
  pfx/pfxcore/views/parameters/subset_page_size.py
104
107
  pfx/pfxcore/views/parameters/subset_page_subset.py
108
+ pfx/settings/__init__.py
109
+ pfx/settings/dev.py
105
110
  tests/__init__.py
106
111
  tests/models.py
107
112
  tests/urls.py
@@ -32,6 +32,18 @@ API Reference
32
32
  :undoc-members:
33
33
  :show-inheritance:
34
34
 
35
+ .. autoclass:: pfx.pfxcore.models.AbstractPFXBaseUser
36
+ :members:
37
+ :show-inheritance:
38
+
39
+ .. autoclass:: pfx.pfxcore.models.OtpUserMixin
40
+ :members:
41
+ :show-inheritance:
42
+
43
+ .. autoclass:: pfx.pfxcore.models.PFXUser
44
+ :members:
45
+ :show-inheritance:
46
+
35
47
  ``pfx.pfxcore.views``
36
48
  *********************
37
49
 
@@ -43,12 +55,17 @@ Base services
43
55
  :undoc-members:
44
56
  :show-inheritance:
45
57
 
58
+ .. autoclass:: pfx.pfxcore.views.SignupView
59
+ :members:
60
+ :undoc-members:
61
+ :show-inheritance:
62
+
46
63
  .. autoclass:: pfx.pfxcore.views.ForgottenPasswordView
47
64
  :members:
48
65
  :undoc-members:
49
66
  :show-inheritance:
50
67
 
51
- .. autoclass:: pfx.pfxcore.views.SignupView
68
+ .. autoclass:: pfx.pfxcore.views.OtpEmailView
52
69
  :members:
53
70
  :undoc-members:
54
71
  :show-inheritance:
@@ -1,20 +1,21 @@
1
1
  # Authentication
2
2
 
3
3
  Django PFX offers services and middlewares for managing user authentication in your API.
4
- These services replicate some of the functionalities provided by the `django.contrib.auth`
4
+ These services replicate some of the functionalities provided by the {mod}`django.contrib.auth`
5
5
  package but in the form of RESTful services.
6
6
  They utilize the same user model and authentication backend features,
7
7
  including password validation and hashing.
8
8
 
9
9
  ## User Model
10
10
 
11
- You have the option to use the standard `django.contrib.auth.models.User`,
11
+ You have the option to use the standard Django User with {class}`pfx.pfxcore.models.PFXUser`
12
+ (which is a {class}`django.contrib.auth.models.User` with PFX required mixins),
12
13
  but you may prefer to use your own model. To do this, create your own user class.
13
14
 
14
15
  ```python
15
- from django.contrib.auth.models import AbstractUser
16
+ from pfx.pfxcore.models import AbstractPFXBaseUser
16
17
 
17
- class MyUser(AbstractUser):
18
+ class MyUser(AbstractPFXBaseUser):
18
19
  pass
19
20
  ```
20
21
 
@@ -28,8 +29,8 @@ AUTH_USER_MODEL = "myapp.MyUser"
28
29
 
29
30
  There are two authentication modes available: cookie and bearer token. You can activate either or both by enabling the following middlewares:
30
31
 
31
- * `'pfx.pfxcore.middleware.AuthenticationMiddleware'` (bearer token)
32
- * `'pfx.pfxcore.middleware.CookieAuthenticationMiddleware'` (cookie)
32
+ * {class}`pfx.pfxcore.middleware.AuthenticationMiddleware` (bearer token)
33
+ * {class}`pfx.pfxcore.middleware.CookieAuthenticationMiddleware` (cookie)
33
34
 
34
35
  ### Token Validity
35
36
 
@@ -40,7 +41,7 @@ You can customize token validity by configuring these parameters:
40
41
 
41
42
  ### Cookie Settings
42
43
 
43
- To use the `CookieAuthenticationMiddleware`, you need to configure the following settings:
44
+ To use the {class}`pfx.pfxcore.middleware.CookieAuthenticationMiddleware`, you need to configure the following settings:
44
45
 
45
46
  * `PFX_COOKIE_DOMAIN`: The cookie domain
46
47
  * `PFX_COOKIE_SECURE`: `Secure` attribute of the cookie (`True`/`False`)
@@ -48,28 +49,55 @@ To use the `CookieAuthenticationMiddleware`, you need to configure the following
48
49
 
49
50
  See the [MDN Website](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies) for more details.
50
51
 
52
+ ### Temporary bans
53
+
54
+ Users will be temporarily banned after several unsuccessful login attempts.
55
+
56
+ In the event of a temporary ban, login services will respond with the HTTP code `429` and
57
+ the `Retry-After` header.
58
+
59
+ #### Settings
60
+
61
+ * `PFX_LOGIN_BAN_FAILED_NUMBER`: The number of failed login attempts before banning. To deactivate the ban completely, set `0` (optional, default `5`).
62
+ * `PFX_LOGIN_BAN_SECONDS_START`: The number of seconds for the first ban (optional, default `60`).
63
+ * `PFX_LOGIN_BAN_SECONDS_STEP`: The number of seconds to be added to the previous ban for consecutive bans (optional, default `60`).
64
+
51
65
  ### Multifactor Authentication
52
66
  Multifactor authentication can be enabled in django-pfx Authentication API.
53
67
 
54
68
  PFX currently provides MFA with One Time Password (OTP), compatible with FreeOTP,
55
69
  Google Authenticator and other OTP app.
56
70
 
57
- To enable this feature, install django-pfx with otp
71
+ To enable this feature, install django-pfx with otp.
58
72
 
59
73
  ```bash
60
74
  pip install django-pfx[otp]
61
75
  ```
62
76
 
63
- Then the user class must use the ```OtpUserMixin```
77
+ Then the user class must use the {class}`pfx.pfxcore.models.OtpUserMixin`.
64
78
 
65
79
  ```python
66
- from django.contrib.auth.models import AbstractUser
67
- from pfx.pfxcore.models import OtpUserMixin
80
+ from pfx.pfxcore.models import PFXUser, OtpUserMixin
68
81
 
69
- class MyUser(OtpUserMixin, AbstractUser):
82
+ class MyUser(OtpUserMixin, PFXUser):
70
83
  pass
71
84
  ```
72
85
 
86
+ or
87
+
88
+ ```python
89
+ from pfx.pfxcore.models import AbstractPFXBaseUser, OtpUserMixin
90
+
91
+ class MyUser(OtpUserMixin, AbstractPFXBaseUser):
92
+ pass
93
+ ```
94
+
95
+ #### Settings
96
+
97
+ * `PFX_TOKEN_OTP_VALIDITY`: Validity for OTP tokens (corresponds to the maximum time to enter
98
+ an OTP code after logging in with a password) (optional, default `{'minutes': 15}`)
99
+ * `PFX_HOTP_CODE_VALIDITY`: Validity of HOTP codes in minutes (used to send code by email) (optional, default `15`).
100
+
73
101
  The user can then enable or disable the OTP auth using the [services documented below](#enable-mfa-otp).
74
102
 
75
103
  ## Services
@@ -263,7 +291,7 @@ like so: `https://example.com/reset-password?token={token}&uidb64={uidb64}`.
263
291
  Your reset page should then call the "set password" service with these two parameters.
264
292
 
265
293
  You can override this class if you need to customize the email templates.
266
- Refer to the [API doc](api.views.rst#pfx.pfxcore.views.ForgottenPasswordView) for more details.
294
+ Refer to {class}`pfx.pfxcore.views.ForgottenPasswordView` for more details.
267
295
 
268
296
  **Request :** `POST` `/auth/forgotten-password`
269
297
 
@@ -288,7 +316,7 @@ like so: `https://example.com/reset-password?token={token}&uidb64={uidb64}`.
288
316
  Your reset page should then call the "set password" service with these two parameters.
289
317
 
290
318
  You can override this class if you need to customize the user or email templates.
291
- Refer to the [API doc](api.views.rst#pfx.pfxcore.views.SignupView) for more details.
319
+ Refer to {class}`pfx.pfxcore.views.SignupView` for more details.
292
320
 
293
321
  **Request :** `POST` `/auth/signup`
294
322
 
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ (cd pfx/pfxcore && django-admin makemessages -a)
3
+ (cd tests/tests && django-admin makemessages -a)
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python
2
+ """Django's command-line utility for administrative tasks."""
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+ from django.db.migrations import writer
8
+
9
+ if len(sys.argv) > 1 and sys.argv[1] == 'test':
10
+ logging.disable(logging.WARNING)
11
+
12
+
13
+ writer.MIGRATION_HEADER_TEMPLATE = """\
14
+ # Generated by Django %(version)s on %(timestamp)s
15
+ # flake8: noqa
16
+
17
+ """
18
+
19
+ def main():
20
+ """Run administrative tasks."""
21
+ cmd = len(sys.argv) > 1 and sys.argv[1]
22
+ os.environ.setdefault(
23
+ "DJANGO_SETTINGS_MODULE",
24
+ cmd == 'test' and "tests.settings.dev" or "pfx.settings.dev")
25
+ try:
26
+ from django.core.management import execute_from_command_line
27
+ except ImportError as exc:
28
+ raise ImportError(
29
+ "Couldn't import Django. Are you sure it's installed and "
30
+ "available on your PYTHONPATH environment variable? Did you "
31
+ "forget to activate a virtual environment?"
32
+ ) from exc
33
+ execute_from_command_line(sys.argv)
34
+
35
+
36
+ if __name__ == '__main__':
37
+ main()
@@ -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-04-08 13:51+0200\n"
10
+ "POT-Creation-Date: 2024-04-11 11:40+0200\n"
11
11
  "PO-Revision-Date: 2021-06-22 23:31+0200\n"
12
12
  "Last-Translator: \n"
13
13
  "Language-Team: \n"
@@ -18,7 +18,7 @@ msgstr ""
18
18
  "Plural-Forms: nplurals=2; plural=(n > 1);\n"
19
19
  "X-Generator: Poedit 2.3\n"
20
20
 
21
- #: decorator/rest.py:43
21
+ #: decorator/rest.py:41
22
22
  msgid "An internal server error occured."
23
23
  msgstr "Une erreur interne du serveur est survenue."
24
24
 
@@ -59,39 +59,39 @@ 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/login_ban.py:45
62
+ #: models/login_ban.py:43
63
63
  msgid "Username"
64
64
  msgstr "Nom d’utilisateur"
65
65
 
66
- #: models/login_ban.py:46
66
+ #: models/login_ban.py:44
67
67
  msgid "Failed counter"
68
68
  msgstr "Compteur d’échec"
69
69
 
70
- #: models/login_ban.py:47
70
+ #: models/login_ban.py:45
71
71
  msgid "Last failed"
72
72
  msgstr "Dernier échec"
73
73
 
74
- #: models/login_ban.py:52
74
+ #: models/login_ban.py:50
75
75
  msgid "Login ban"
76
76
  msgstr "Login banni"
77
77
 
78
- #: models/login_ban.py:53
78
+ #: models/login_ban.py:51
79
79
  msgid "Login bans"
80
80
  msgstr "Login bannis"
81
81
 
82
- #: models/otp_user_mixin.py:10
82
+ #: models/otp_user_mixin.py:15
83
83
  msgid "OTP secret token"
84
84
  msgstr "Jeton secret OTP"
85
85
 
86
- #: models/otp_user_mixin.py:13
86
+ #: models/otp_user_mixin.py:19
87
87
  msgid "Temporary OTP secret token"
88
88
  msgstr "Jeton secret OTP temporaire"
89
89
 
90
- #: models/otp_user_mixin.py:14
90
+ #: models/otp_user_mixin.py:21
91
91
  msgid "HOTP count"
92
92
  msgstr "Compte HOTP"
93
93
 
94
- #: models/otp_user_mixin.py:15
94
+ #: models/otp_user_mixin.py:23
95
95
  msgid "HOTP expiry"
96
96
  msgstr "Expiration HOTP"
97
97
 
@@ -100,22 +100,22 @@ msgstr "Expiration HOTP"
100
100
  msgid "%(model_name)s with this %(field_labels)s already exists."
101
101
  msgstr "%(model_name)s avec ce/cette %(field_labels)s éxiste déjà."
102
102
 
103
- #: shortcuts.py:52
103
+ #: shortcuts.py:50
104
104
  #, python-brace-format
105
105
  msgid "{key} must be an integer number."
106
106
  msgstr "{key} doit être un nombre entier."
107
107
 
108
- #: shortcuts.py:68
108
+ #: shortcuts.py:66
109
109
  #, python-brace-format
110
110
  msgid "{key} must be a number."
111
111
  msgstr "{key} doit être un nombre."
112
112
 
113
- #: shortcuts.py:84
113
+ #: shortcuts.py:82
114
114
  #, python-brace-format
115
115
  msgid "{key} must be a date."
116
116
  msgstr "{key} doit être une date."
117
117
 
118
- #: shortcuts.py:105
118
+ #: shortcuts.py:103
119
119
  #, python-brace-format
120
120
  msgid "{key} must be “true”, “false”, “1”, “0” or empty."
121
121
  msgstr "{key} doit être « true », « false », « 1 », « 0 » ou vide"
@@ -152,6 +152,7 @@ msgid "The %(site_name)s team"
152
152
  msgstr "L'équipe de %(site_name)s"
153
153
 
154
154
  #: templates/registration/otp_code_subject.txt:2
155
+ #, python-format
155
156
  msgid "New authentication code for %(site_name)s"
156
157
  msgstr "Nouveau code d’authentication pour %(site_name)s"
157
158
 
@@ -191,7 +192,7 @@ msgstr "Bienvenue sur %(site_name)s."
191
192
  msgid "Welcome on %(site_name)s"
192
193
  msgstr "Bienvenue sur %(site_name)s"
193
194
 
194
- #: views/authentication_views.py:82
195
+ #: views/authentication_views.py:81
195
196
  #, python-brace-format
196
197
  msgid ""
197
198
  "Your connection is temporarily disabled after several unsuccessful attempts, "
@@ -200,43 +201,43 @@ msgstr ""
200
201
  "Votre connexion est temporairement désactivée après plusieurs tentatives "
201
202
  "infructueuses, veuillez réessayer dans {seconds} secondes."
202
203
 
203
- #: views/authentication_views.py:244 views/authentication_views.py:407
204
+ #: views/authentication_views.py:243 views/authentication_views.py:404
204
205
  msgid "password updated successfully"
205
206
  msgstr "le mot de passe a été mis à jour avec succès"
206
207
 
207
- #: views/authentication_views.py:249
208
+ #: views/authentication_views.py:248
208
209
  msgid "Incorrect password"
209
210
  msgstr "Mot de passe incorrect"
210
211
 
211
- #: views/authentication_views.py:252 views/authentication_views.py:416
212
+ #: views/authentication_views.py:251 views/authentication_views.py:413
212
213
  msgid "Empty password is not allowed"
213
214
  msgstr "Un mot de passe vide n’est pas autorisé"
214
215
 
215
- #: views/authentication_views.py:341
216
+ #: views/authentication_views.py:338
216
217
  msgid "User and token are valid"
217
218
  msgstr "L'utilisateur et le token sont valides"
218
219
 
219
- #: views/authentication_views.py:343
220
+ #: views/authentication_views.py:340
220
221
  msgid "User or token is invalid"
221
222
  msgstr "L'utilisateur ou le token est invalide"
222
223
 
223
- #: views/authentication_views.py:449
224
+ #: views/authentication_views.py:446
224
225
  msgid "OTP is already activated"
225
226
  msgstr "L'OTP est déjà activé"
226
227
 
227
- #: views/authentication_views.py:487
228
+ #: views/authentication_views.py:484
228
229
  msgid "OTP is activated"
229
230
  msgstr "L'OTP est activé"
230
231
 
231
- #: views/authentication_views.py:488 views/authentication_views.py:522
232
+ #: views/authentication_views.py:485 views/authentication_views.py:519
232
233
  msgid "Invalid OTP code"
233
234
  msgstr "Code OTP invalide"
234
235
 
235
- #: views/authentication_views.py:521
236
+ #: views/authentication_views.py:518
236
237
  msgid "OTP is disabled"
237
238
  msgstr "L'OTP est désactivé"
238
239
 
239
- #: views/authentication_views.py:777
240
+ #: views/authentication_views.py:778
240
241
  msgid ""
241
242
  "If the email address you entered is correct, you will receive an email from "
242
243
  "us with instructions to reset your password."
@@ -245,7 +246,7 @@ msgstr ""
245
246
  "un courrier électronique de notre part contenant des instructions pour "
246
247
  "réinitialiser votre mot de passe."
247
248
 
248
- #: views/authentication_views.py:845
249
+ #: views/authentication_views.py:852
249
250
  msgid "A new authentication code has been sent by email."
250
251
  msgstr "Un nouveau code d'authentification a été envoyé par e-mail."
251
252
 
@@ -0,0 +1,59 @@
1
+ # Generated by Django 4.2.11 on 2024-04-11 04:19
2
+ # flake8: noqa
3
+
4
+ import django.contrib.auth.models
5
+ import django.contrib.auth.validators
6
+ import django.utils.timezone
7
+ from django.db import migrations, models
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ('auth', '0012_alter_user_first_name_max_length'),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.CreateModel(
20
+ name='LoginBan',
21
+ fields=[
22
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23
+ ('username', models.CharField(max_length=150, unique=True, verbose_name='Username')),
24
+ ('failed_counter', models.IntegerField(verbose_name='Failed counter')),
25
+ ('last_failed', models.DateTimeField(auto_now=True, verbose_name='Last failed')),
26
+ ],
27
+ options={
28
+ 'verbose_name': 'Login ban',
29
+ 'verbose_name_plural': 'Login bans',
30
+ },
31
+ ),
32
+ migrations.CreateModel(
33
+ name='PFXUser',
34
+ fields=[
35
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
36
+ ('password', models.CharField(max_length=128, verbose_name='password')),
37
+ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
38
+ ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
39
+ ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
40
+ ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
41
+ ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
42
+ ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
43
+ ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
44
+ ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
45
+ ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
46
+ ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
47
+ ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
48
+ ],
49
+ options={
50
+ 'verbose_name': 'user',
51
+ 'verbose_name_plural': 'users',
52
+ 'abstract': False,
53
+ 'swappable': 'AUTH_USER_MODEL',
54
+ },
55
+ managers=[
56
+ ('objects', django.contrib.auth.models.UserManager()),
57
+ ],
58
+ ),
59
+ ]
@@ -13,4 +13,5 @@ from .pfx_models import (
13
13
  PFXModelMixin,
14
14
  UniqueConstraint,
15
15
  )
16
+ from .pfx_user import PFXUser
16
17
  from .user_filtered_queryset_mixin import UserFilteredQuerySetMixin
@@ -2,6 +2,8 @@ from django.contrib.auth.models import AbstractBaseUser
2
2
 
3
3
 
4
4
  class AbstractPFXBaseUser(AbstractBaseUser):
5
+ """The base abstract user for PFX."""
6
+
5
7
  class Meta:
6
8
  abstract = True
7
9
 
@@ -10,6 +12,7 @@ class AbstractPFXBaseUser(AbstractBaseUser):
10
12
  Return a user secret to sign JWT token.
11
13
 
12
14
  If not empty, the JWT token validity depends on all values
13
- user to build the return string.
15
+ user to build the return string. So, each time the returned value
16
+ changes, the previously issued tokens will no longer be valid.
14
17
  """
15
18
  return self.password
@@ -8,23 +8,42 @@ from pfx.pfxcore.settings import settings
8
8
 
9
9
 
10
10
  class OtpUserMixin(models.Model):
11
+ """A mixin to enable OTP MFA on a user class."""
12
+
13
+ #: OTP secret token.
11
14
  otp_secret_token = models.CharField(
12
15
  _("OTP secret token"), max_length=32, null=True,
13
16
  blank=True, unique=True)
17
+ #: Temporary OTP secret token (needs confirmation).
14
18
  otp_secret_token_tmp = models.CharField(
15
19
  _("Temporary OTP secret token"), max_length=32, null=True, blank=True)
20
+ #: HOTP count.
16
21
  hotp_count = models.IntegerField(_("HOTP count"), default=0)
22
+ #: HOTP expiry.
17
23
  hotp_expiry = models.DateTimeField(_("HOTP expiry"), default=timezone.now)
18
24
 
19
25
  class Meta:
20
26
  abstract = True
21
27
 
22
28
  def enable_otp(self):
29
+ """Activate OTP for this user.
30
+
31
+ Generates a new temporary OTP secret token. To complete activation,
32
+ call `confirm_otp` with a valid code.
33
+ """
23
34
  import pyotp
24
35
  self.otp_secret_token_tmp = pyotp.random_base32()
25
36
  self.save(update_fields=['otp_secret_token_tmp'])
26
37
 
27
38
  def confirm_otp(self, otp_code):
39
+ """Confirm OTP activation for this user.
40
+
41
+ Set the OTP secret token from the temporary one if the provided
42
+ code is valid.
43
+
44
+ :param otp_code: A valid OTP code for the temporary OTP secret key.
45
+ :returns: `True` if success, `False` otherwise.
46
+ """
28
47
  if self.is_otp_valid(otp_code, tmp=True):
29
48
  self.otp_secret_token = self.otp_secret_token_tmp
30
49
  self.otp_secret_token_tmp = None
@@ -34,10 +53,16 @@ class OtpUserMixin(models.Model):
34
53
  return False
35
54
 
36
55
  def disable_otp(self):
56
+ """Disable OTP for this user.
57
+
58
+ Remove the OTP secret token.
59
+ """
37
60
  self.otp_secret_token = None
38
61
  self.save(update_fields=['otp_secret_token'])
39
62
 
40
63
  def get_otp_setup_uri(self, tmp=False):
64
+ """Return the setup URL for OTP activation.
65
+ """
41
66
  import pyotp
42
67
  return pyotp.totp.TOTP(
43
68
  tmp and self.otp_secret_token_tmp or
@@ -45,6 +70,13 @@ class OtpUserMixin(models.Model):
45
70
  name=self.email, issuer_name=settings.PFX_SITE_NAME)
46
71
 
47
72
  def is_otp_valid(self, otp_code, tmp=False):
73
+ """Verify an OTP code.
74
+
75
+ :param otp_code: A valid OTP code for the OTP secret key.
76
+ :param tmp: If `True`, verify the code with the temporary
77
+ OTP secret key.
78
+ :returns: `True` if the code is valid, `False` otherwise.
79
+ """
48
80
  import pyotp
49
81
  totp = pyotp.parse_uri(self.get_otp_setup_uri(tmp=tmp))
50
82
  valid = totp.verify(otp_code)
@@ -56,10 +88,17 @@ class OtpUserMixin(models.Model):
56
88
  return valid
57
89
 
58
90
  def get_user_jwt_signature_key(self):
91
+ """Return a user secret to sign JWT token.
92
+
93
+ If the user inherit :class:`pfx.pfxcore.models.AbstractPFXBaseUser`,
94
+ add the OTP secret token to the user signature."""
59
95
  return super().get_user_jwt_signature_key() + (
60
96
  self.otp_secret_token or "")
61
97
 
62
98
  def get_hotp_code(self):
99
+ """Return a new valid HOTP code.
100
+
101
+ Increment the HOTP counter and reset the expiry."""
63
102
  import pyotp
64
103
  if not self.otp_secret_token:
65
104
  raise Exception("OTP disabled")