├── tests ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── templates │ └── secure.html ├── urls_admin.py ├── urls_otp_admin.py ├── views.py ├── test_forms.py ├── test_checks.py ├── test_validators.py ├── test_views_backuptokens.py ├── test_registry.py ├── test_views_profile.py ├── test_views_disable.py ├── models.py ├── urls.py ├── utils.py ├── test_totpdeviceform.py ├── settings.py ├── test_commands.py └── test_views_qrcode.py ├── example ├── __init__.py ├── templates │ ├── two_factor │ │ ├── _base.html │ │ ├── _wizard_forms.html │ │ └── _base_focus.html │ ├── _messages.html │ ├── registration │ │ └── logged_out.html │ ├── user_sessions │ │ └── _base.html │ ├── registration.html │ ├── secret.html │ ├── registration_complete.html │ ├── home.html │ └── _base.html ├── locale │ ├── ar │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── as │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── az │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── cs │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── fa │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── fi │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ha │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── it │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ja │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── lt │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── nb │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── nl │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── pl │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ro │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ru │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── sr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── sv │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── tr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── vi │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ca_ES │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── da_DK │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── en_GB │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── he_IL │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── hi_IN │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── hu_HU │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── zh_CN │ │ └── LC_MESSAGES │ │ │ └── django.mo │ └── sr@latin │ │ └── LC_MESSAGES │ │ └── django.mo ├── settings_webauthn.py ├── settings_private.py.dist ├── manage.py ├── gateways.py ├── views.py ├── urls.py └── settings.py ├── docs ├── extensions │ ├── __init__.py │ └── settings.py ├── management-commands.rst ├── Makefile ├── requirements.rst ├── index.rst └── class-reference.rst ├── two_factor ├── __init__.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── two_factor_disable.py │ │ └── two_factor_status.py ├── middleware │ ├── __init__.py │ └── threadlocals.py ├── migrations │ ├── __init__.py │ ├── 0008_delete_phonedevice.py │ ├── 0004_auto_20160205_1827.py │ ├── 0002_auto_20150110_0810.py │ ├── 0006_phonedevice_key_default.py │ ├── 0007_auto_20201201_1019.py │ ├── 0001_squashed_0008_delete_phonedevice.py │ ├── 0003_auto_20150817_1733.py │ ├── 0005_auto_20160224_0450.py │ └── 0001_initial.py ├── plugins │ ├── __init__.py │ ├── webauthn │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_forms.py │ │ │ ├── test_views_setup.py │ │ │ └── test_utils.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0002_alter_webauthndevice_public_key.py │ │ │ └── 0001_initial.py │ │ ├── admin.py │ │ ├── urls.py │ │ ├── static │ │ │ └── two_factor │ │ │ │ └── js │ │ │ │ └── webauthn_utils.js │ │ ├── models.py │ │ ├── views.py │ │ ├── templates │ │ │ └── two_factor_webauthn │ │ │ │ ├── create_credential.js │ │ │ │ └── get_credential.js │ │ ├── apps.py │ │ └── method.py │ ├── yubikey │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── forms.py │ │ └── method.py │ ├── phonenumber │ │ ├── tests │ │ │ ├── __init__.py │ │ │ └── test_method.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── templatetags │ │ │ ├── __init__.py │ │ │ └── phonenumber.py │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── admin.py │ │ ├── validators.py │ │ ├── apps.py │ │ ├── forms.py │ │ ├── utils.py │ │ ├── method.py │ │ ├── models.py │ │ └── views.py │ ├── email │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── utils.py │ │ ├── forms.py │ │ └── method.py │ └── registry.py ├── gateways │ ├── twilio │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── views.py │ │ └── gateway.py │ ├── __init__.py │ └── fake.py ├── templatetags │ ├── __init__.py │ └── two_factor_tags.py ├── locale │ ├── ar │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── as │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── az │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── cs │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── de │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── en │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── es │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── fa │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── fi │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ha │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── it │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ja │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── lt │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── nb │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── nl │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── pl │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ro │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ru │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── sr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── sv │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── tr │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── vi │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── ca_ES │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── da_DK │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── en_GB │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── he_IL │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── hi_IN │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── hu_HU │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ └── django.mo │ ├── zh_CN │ │ └── LC_MESSAGES │ │ │ └── django.mo │ └── sr@latin │ │ └── LC_MESSAGES │ │ └── django.mo ├── templates │ └── two_factor │ │ ├── _wizard_forms.html │ │ ├── twilio │ │ ├── sms_message.html │ │ ├── press_a_key.xml │ │ └── token.xml │ │ ├── _base_focus.html │ │ ├── _wizard_actions.html │ │ ├── profile │ │ ├── disable.html │ │ └── profile.html │ │ ├── core │ │ ├── otp_required.html │ │ ├── phone_register.html │ │ ├── setup_complete.html │ │ ├── backup_tokens.html │ │ ├── login.html │ │ └── setup.html │ │ └── _base.html ├── signals.py ├── apps.py ├── views │ ├── __init__.py │ ├── profile.py │ └── mixins.py ├── checks.py ├── utils.py ├── urls.py └── admin.py ├── requirements_e2e.txt ├── .gitignore ├── MANIFEST.in ├── .codecov.yml ├── .readthedocs.yml ├── .pre-commit-config.yaml ├── requirements_dev.txt ├── .tx └── config ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── release.yml │ └── build.yml └── PULL_REQUEST_TEMPLATE.md ├── LICENSE ├── Makefile ├── tox.ini ├── CONTRIBUTING.rst ├── README.rst └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/templates/secure.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /two_factor/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/gateways/twilio/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/plugins/yubikey/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements_e2e.txt: -------------------------------------------------------------------------------- 1 | # test with selenium 2 | selenium~=4.30.0 3 | -------------------------------------------------------------------------------- /example/templates/two_factor/_base.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% block nav_profile %}active{% endblock %} 3 | -------------------------------------------------------------------------------- /example/locale/ar/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/ar/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/as/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/as/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/az/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/az/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/fa/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/fa/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/fi/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/fi/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ha/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/ha/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ja/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/ja/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/lt/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/lt/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/nb/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/nb/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ro/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/ro/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/sr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/sr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/sv/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/sv/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/tr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/tr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/vi/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/vi/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/settings_webauthn.py: -------------------------------------------------------------------------------- 1 | from .settings import * # noqa: F403 2 | 3 | INSTALLED_APPS.extend(['two_factor.plugins.webauthn']) # noqa: F405 4 | -------------------------------------------------------------------------------- /example/locale/ca_ES/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/ca_ES/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/da_DK/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/da_DK/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/en_GB/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/en_GB/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/he_IL/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/he_IL/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/hi_IN/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/hi_IN/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/hu_HU/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/hu_HU/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/zh_CN/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/example/locale/zh_CN/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/ar/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/ar/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/as/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/as/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/az/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/az/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/fa/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/fa/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/fi/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/fi/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/ha/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/ha/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/ja/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/ja/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/lt/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/lt/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/nb/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/nb/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/ro/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/ro/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/sr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/sr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/sv/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/sv/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/tr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/tr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/vi/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/master/two_factor/locale/vi/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/templates/two_factor/_wizard_forms.html: -------------------------------------------------------------------------------- 1 |
{% trans "See you around!" %}
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: false 5 | tests: 6 | paths: tests 7 | informational: true 8 | two_factor: 9 | paths: two_factor 10 | informational: true 11 | patch: off 12 | -------------------------------------------------------------------------------- /docs/management-commands.rst: -------------------------------------------------------------------------------- 1 | Management Commands 2 | =================== 3 | 4 | Status 5 | ------ 6 | .. autoclass:: two_factor.management.commands.two_factor_status.Command 7 | 8 | Disable 9 | ------- 10 | .. autoclass:: two_factor.management.commands.two_factor_disable.Command 11 | -------------------------------------------------------------------------------- /tests/urls_otp_admin.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from two_factor.admin import AdminSiteOTPRequired 4 | 5 | from .urls import urlpatterns 6 | 7 | otp_admin_site = AdminSiteOTPRequired() 8 | 9 | urlpatterns += [ 10 | path('otp_admin/', otp_admin_site.urls), 11 | ] 12 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | build: 2 | os: ubuntu-lts-latest 3 | tools: 4 | python: "3.12" 5 | 6 | sphinx: 7 | configuration: docs/conf.py 8 | 9 | python: 10 | install: 11 | - method: pip 12 | path: . 13 | extra_requirements: 14 | - docs 15 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/isort 3 | rev: 7.0.0 4 | hooks: 5 | - id: isort 6 | args: ['--check-only', '--diff'] 7 | 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.14.3 10 | hooks: 11 | - id: ruff 12 | -------------------------------------------------------------------------------- /two_factor/gateways/twilio/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import TwilioCallApp 4 | 5 | urlpatterns = ([ 6 | path( 7 | 'twilio/inbound/two_factor/The session list is
6 | powered by django-user-sessions to list the location,
7 | browser and IP-address.
{% blocktrans trimmed %}Congratulations, you've made it. You have successfully 10 | enabled two-factor authentication and logged in with your token.{% endblocktrans %}
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/twilio/press_a_key.xml: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |{% blocktrans trimmed %}Congratulations, you've successfully registered an 7 | account.{% endblocktrans %}
8 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /two_factor/templatetags/two_factor_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from two_factor.plugins.registry import registry 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.filter 9 | def as_action(device): 10 | method = registry.method_from_device(device) 11 | return method.get_action(device) 12 | 13 | 14 | @register.filter 15 | def as_verbose_action(device): 16 | method = registry.method_from_device(device) 17 | return method.get_verbose_action(device) 18 | -------------------------------------------------------------------------------- /two_factor/migrations/0008_delete_phonedevice.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | dependencies = [ 7 | ('two_factor', '0007_auto_20201201_1019'), 8 | ] 9 | 10 | operations = [ 11 | migrations.SeparateDatabaseAndState( 12 | state_operations=[ 13 | migrations.DeleteModel( 14 | name='PhoneDevice', 15 | ), 16 | ], 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import CreateCredentialJS, GetCredentialJS 4 | 5 | app_name = 'webauthn' 6 | 7 | urlpatterns = [ 8 | path( 9 | 'create_credential.js', 10 | CreateCredentialJS.as_view(content_type='text/javascript'), 11 | name='create_credential', 12 | ), 13 | path( 14 | 'get_credential.js', 15 | GetCredentialJS.as_view(content_type='text/javascript'), 16 | name='get_credential', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /two_factor/middleware/threadlocals.py: -------------------------------------------------------------------------------- 1 | from threading import local 2 | 3 | _thread_locals = local() 4 | 5 | 6 | def get_current_request(): 7 | return getattr(_thread_locals, 'request', None) 8 | 9 | 10 | class ThreadLocals: 11 | """ 12 | Middleware that stores the request object in thread local storage. 13 | """ 14 | def __init__(self, get_response): 15 | self.get_response = get_response 16 | 17 | def __call__(self, request): 18 | _thread_locals.request = request 19 | return self.get_response(request) 20 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/twilio/token.xml: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |{% blocktrans trimmed %}You are about to disable two-factor authentication. This 7 | weakens your account security, are you sure?{% endblocktrans %}
8 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /example/templates/two_factor/_base_focus.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_wrapper %} 5 | 10 | 11 |{% blocktrans trimmed %}The page you requested, enforces users to verify using 8 | two-factor authentication for security reasons. You need to enable these 9 | security features in order to access this page.{% endblocktrans %}
10 | 11 |{% blocktrans trimmed %}Two-factor authentication is not enabled for your 12 | account. Enable two-factor authentication for enhanced account 13 | security.{% endblocktrans %}
14 |15 | {% trans "Go back" %} 17 | 18 | {% trans "Enable Two-Factor Authentication" %} 19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /example/gateways.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.utils.translation import gettext as _ 3 | 4 | from two_factor.middleware.threadlocals import get_current_request 5 | from two_factor.plugins.phonenumber.utils import mask_phone_number 6 | 7 | 8 | class Messages: 9 | @classmethod 10 | def make_call(cls, device, token): 11 | cls._add_message(_('Fake call to %(number)s: "Your token is: %(token)s"'), 12 | device, token) 13 | 14 | @classmethod 15 | def send_sms(cls, device, token): 16 | cls._add_message(_('Fake SMS to %(number)s: "Your token is: %(token)s"'), 17 | device, token) 18 | 19 | @classmethod 20 | def _add_message(cls, message, device, token): 21 | message = message % {'number': mask_phone_number(device.number), 22 | 'token': token} 23 | messages.add_message(get_current_request(), messages.INFO, message) 24 | -------------------------------------------------------------------------------- /two_factor/plugins/email/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from two_factor.forms import ( 5 | AuthenticationTokenForm as BaseAuthenticationTokenForm, 6 | DeviceValidationForm as BaseValidationForm, 7 | ) 8 | 9 | 10 | class EmailForm(forms.Form): 11 | email = forms.EmailField(label=_("Email address")) 12 | 13 | def __init__(self, **kwargs): 14 | kwargs.pop('device', None) 15 | super().__init__(**kwargs) 16 | 17 | 18 | class DeviceValidationForm(BaseValidationForm): 19 | token = forms.CharField(label=_("Token")) 20 | token.widget.attrs.update({'autofocus': 'autofocus', 21 | 'autocomplete': 'one-time-code'}) 22 | idempotent = False # Once validated, the token is cleared. 23 | 24 | 25 | class AuthenticationTokenForm(BaseAuthenticationTokenForm): 26 | def _chosen_device(self, user): 27 | return self.initial_device 28 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |Provide a template named
13 | two_factor/_base.html to style this page and remove this message.
{% blocktrans trimmed %}You'll be adding a backup phone number to your 9 | account. This number will be used if your primary method of 10 | registration is not available.{% endblocktrans %}
11 | {% elif wizard.steps.current == 'validation' %} 12 |{% blocktrans trimmed %}We've sent a token to your phone number. Please 13 | enter the token you've received.{% endblocktrans %}
14 | {% endif %} 15 | 16 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from django.core.checks import Error, Warning 2 | from django.test import TestCase, override_settings 3 | 4 | from two_factor import checks 5 | 6 | 7 | class InstallAppOrderCheckTest(TestCase): 8 | @override_settings(INSTALLED_APPS=("django_otp", "two_factor", "two_factor.plugins.email")) 9 | def test_correct(self): 10 | self.assertEqual(checks.check_installed_app_order(None), []) 11 | 12 | @override_settings(INSTALLED_APPS=("django_otp", "two_factor.plugins.email", "two_factor")) 13 | def test_incorrect(self): 14 | self.assertEqual(checks.check_installed_app_order(None), 15 | [Error(checks.INSTALLED_APPS_MSG, hint=checks.INSTALLED_APPS_HINT, id=checks.INSTALLED_APPS_ID)]) 16 | 17 | @override_settings(INSTALLED_APPS=("django_otp", "two_factor.apps.TwoFactorConfig", "two_factor.plugins.email")) 18 | def test_two_factor_not_found(self): 19 | self.assertEqual(checks.check_installed_app_order(None), 20 | [Warning(checks.MISSING_MSG, hint=checks.MISSING_HINT, id=checks.MISSING_ID)]) 21 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.test import TestCase 3 | 4 | from two_factor.plugins.phonenumber.validators import ( 5 | validate_international_phonenumber, 6 | ) 7 | 8 | 9 | class ValidatorsTest(TestCase): 10 | def test_phone_number_validator_on_form_valid(self): 11 | class TestForm(forms.Form): 12 | number = forms.CharField(validators=[validate_international_phonenumber]) 13 | 14 | form = TestForm({ 15 | 'number': '+31101234567', 16 | }) 17 | 18 | self.assertTrue(form.is_valid()) 19 | 20 | def test_phone_number_validator_on_form_invalid(self): 21 | class TestForm(forms.Form): 22 | number = forms.CharField(validators=[validate_international_phonenumber]) 23 | 24 | form = TestForm({ 25 | 'number': '+3110123456', 26 | }) 27 | 28 | self.assertFalse(form.is_valid()) 29 | self.assertIn('number', form.errors) 30 | 31 | self.assertEqual(form.errors['number'], 32 | [str(validate_international_phonenumber.message)]) 33 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/core/setup_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |{% blocktrans trimmed %}Congratulations, you've successfully enabled two-factor 8 | authentication.{% endblocktrans %}
9 | 10 | {% if not phone_methods %} 11 |{% trans "Back to Account Security" %}
13 | {% else %} 14 |{% blocktrans trimmed %}However, it might happen that you don't have access to 15 | your primary token device. To enable account recovery, add a phone 16 | number.{% endblocktrans %}
17 | 18 | {% trans "Back to Account Security" %} 20 |{% trans "Add Phone Number" %}
22 | {% endif %} 23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/migrations/0002_alter_webauthndevice_public_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.1 on 2023-03-02 19:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("two_factor_webauthn", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="webauthndevice", 15 | name="public_key", 16 | field=models.TextField(), 17 | ), 18 | ] 19 | 20 | def unapply(self, project_state, schema_editor, collect_sql=False): 21 | if schema_editor.connection.vendor == 'mysql': 22 | # we avoided creating a unique index on long text. 23 | # fix the model so it reflects this. 24 | app_config = project_state.apps.get_app_config('two_factor_webauthn') 25 | model = app_config.get_model('webauthndevice') 26 | field = model._meta.get_field('public_key') 27 | field._unique = False 28 | 29 | return super().unapply(project_state, schema_editor, collect_sql) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 Bouke Haarsma 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /docs/requirements.rst: -------------------------------------------------------------------------------- 1 | Requirements 2 | ============ 3 | 4 | Django 5 | ------ 6 | Supported Django versions are supported. Currently this list includes Django 4.2, 7 | 5.0, 5.1 and 5.2. 8 | 9 | Python 10 | ------ 11 | The following Python versions are supported: 3.9, 3.10, 3.11, 3.12 and 3.13 with a 12 | limit to what Django itself supports. As support for older Django versions is 13 | dropped, the minimum version might be raised. See also `What Python version can 14 | I use with Django?`_. 15 | 16 | django-otp 17 | ---------- 18 | This project is used for generating one-time passwords. Version 0.8.x and above 19 | are supported. 20 | 21 | django-formtools 22 | ---------------- 23 | Formerly known as ``django.contrib.formtools``, it has been separated from 24 | Django 1.8 into a new package. Version 1.0 is supported. 25 | 26 | .. _What Python version can I use with Django?: 27 | https://docs.djangoproject.com/en/stable/faq/install/#what-python-version-can-i-use-with-django 28 | .. _django-otp: https://pypi.python.org/pypi/django-otp 29 | .. _Supported versions: 30 | https://docs.djangoproject.com/en/stable/internals/release-process/#supported-versions 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-two-factor-auth' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: 3.11 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools build twine 27 | 28 | - name: Build package 29 | run: | 30 | python -m build --version 31 | python -m build 32 | twine check dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-two-factor-auth/upload 41 | -------------------------------------------------------------------------------- /two_factor/management/commands/two_factor_disable.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.management.base import BaseCommand, CommandError 3 | from django_otp import devices_for_user 4 | 5 | 6 | class Command(BaseCommand): 7 | """ 8 | Command for disabling two-factor authentication for certain users. 9 | 10 | The command accepts any number of usernames, and will remove all OTP 11 | devices for those users. 12 | 13 | Example usage:: 14 | 15 | manage.py two_factor_disable bouke steve 16 | """ 17 | help = 'Disables two-factor authentication for the given users' 18 | 19 | def add_arguments(self, parser): 20 | parser.add_argument('args', metavar='usernames', nargs='*') 21 | 22 | def handle(self, *usernames, **options): 23 | User = get_user_model() 24 | for username in usernames: 25 | try: 26 | user = User.objects.get_by_natural_key(username) 27 | except User.DoesNotExist: 28 | raise CommandError('User "%s" does not exist' % username) 29 | 30 | for device in devices_for_user(user): 31 | device.delete() 32 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/tests/test_method.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from tests.utils import UserMixin 4 | from two_factor.plugins.phonenumber.method import PhoneCallMethod, SMSMethod 5 | 6 | 7 | class PhoneMethodBaseTestMixin(UserMixin): 8 | def test_get_devices(self): 9 | other_method_code = PhoneCallMethod.code if isinstance(self.method, SMSMethod) else SMSMethod.code 10 | user = self.create_user() 11 | backup_device = user.phonedevice_set.create(name='backup', number='+12024561111', method=self.method.code) 12 | default_device = user.phonedevice_set.create(name='default', number='+12024561111', method=self.method.code) 13 | user.phonedevice_set.create(name='default', number='+12024561111', method=other_method_code) 14 | 15 | method_device_pks = [device.pk for device in self.method.get_devices(user)] 16 | self.assertEqual(method_device_pks, [backup_device.pk, default_device.pk]) 17 | 18 | 19 | class PhoneCallMethodTest(PhoneMethodBaseTestMixin, TestCase): 20 | method = PhoneCallMethod() 21 | 22 | 23 | class SMSMethodTest(PhoneMethodBaseTestMixin, TestCase): 24 | method = SMSMethod() 25 | -------------------------------------------------------------------------------- /example/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.forms import UserCreationForm 3 | from django.shortcuts import redirect, resolve_url 4 | from django.utils.decorators import method_decorator 5 | from django.views.decorators.cache import never_cache 6 | from django.views.generic import FormView, TemplateView 7 | 8 | from two_factor.views import OTPRequiredMixin 9 | 10 | 11 | class HomeView(TemplateView): 12 | template_name = 'home.html' 13 | 14 | 15 | class RegistrationView(FormView): 16 | template_name = 'registration.html' 17 | form_class = UserCreationForm 18 | 19 | def form_valid(self, form): 20 | form.save() 21 | return redirect('registration_complete') 22 | 23 | 24 | class RegistrationCompleteView(TemplateView): 25 | template_name = 'registration_complete.html' 26 | 27 | def get_context_data(self, **kwargs): 28 | context = super().get_context_data(**kwargs) 29 | context['login_url'] = resolve_url(settings.LOGIN_URL) 30 | return context 31 | 32 | 33 | @method_decorator(never_cache, name='dispatch') 34 | class ExampleSecretView(OTPRequiredMixin, TemplateView): 35 | template_name = 'secret.html' 36 | -------------------------------------------------------------------------------- /two_factor/gateways/fake.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | 5 | 6 | class Fake: 7 | """ 8 | Prints the tokens to the logger. You will have to set the message level of 9 | the ``two_factor`` logger to ``INFO`` for them to appear in the console. 10 | Useful for local development. You should configure your logging like this:: 11 | 12 | LOGGING = { 13 | 'version': 1, 14 | 'disable_existing_loggers': False, 15 | 'handlers': { 16 | 'console': { 17 | 'level': 'DEBUG', 18 | 'class': 'logging.StreamHandler', 19 | }, 20 | }, 21 | 'loggers': { 22 | 'two_factor': { 23 | 'handlers': ['console'], 24 | 'level': 'INFO', 25 | } 26 | } 27 | } 28 | """ 29 | @staticmethod 30 | def make_call(device, token): 31 | logger.info('Fake call to %s: "Your token is: %s"', device.number.as_e164, token) 32 | 33 | @staticmethod 34 | def send_sms(device, token): 35 | logger.info('Fake SMS to %s: "Your token is: %s"', device.number.as_e164, token) 36 | -------------------------------------------------------------------------------- /two_factor/checks.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core.checks import Error, Warning, register 3 | 4 | INSTALLED_APPS_MSG = "two_factor should come before its plugins in INSTALLED_APPS" 5 | INSTALLED_APPS_HINT = "Check documentation for proper ordering." 6 | INSTALLED_APPS_ID = "two_factor.E001" 7 | 8 | MISSING_MSG = "Could not reliably determine where in INSTALLED_APPS two_factor appears" 9 | MISSING_HINT = INSTALLED_APPS_HINT 10 | MISSING_ID = "two_factor.W001" 11 | 12 | @register() 13 | def check_installed_app_order(app_configs, **kwargs): 14 | """Check the order in which two_factor and its plugins are loaded""" 15 | apps = [app for app in settings.INSTALLED_APPS if app.startswith("two_factor")] 16 | if "two_factor" not in apps: 17 | # user might be using "two_factor.apps.TwoFactorConfig" or their own 18 | # custom app config for two_factor, so give them a warning 19 | return [Warning( 20 | MISSING_MSG, 21 | hint=MISSING_HINT, 22 | id=MISSING_ID, 23 | )] 24 | elif apps[0] != "two_factor": 25 | return [Error( 26 | INSTALLED_APPS_MSG, 27 | hint=INSTALLED_APPS_HINT, 28 | id=INSTALLED_APPS_ID, 29 | )] 30 | 31 | return [] 32 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/core/backup_tokens.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |{% blocktrans trimmed %}Backup tokens can be used when your primary and backup 7 | phone numbers aren't available. The backup tokens below can be used 8 | for login verification. If you've used up all your backup tokens, you 9 | can generate a new set of backup tokens. Only the backup tokens shown 10 | below will be valid.{% endblocktrans %}
11 | 12 | {% if device.token_set.count %} 13 |{% blocktrans %}Print these tokens and keep them somewhere safe.{% endblocktrans %}
19 | {% else %} 20 |{% trans "You don't have any backup codes yet." %}
21 | {% endif %} 22 | 23 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.conf import settings 3 | from django.test.signals import setting_changed 4 | 5 | from two_factor.plugins.registry import registry 6 | 7 | 8 | class TwoFactorPhoneNumberConfig(AppConfig): 9 | name = 'two_factor.plugins.phonenumber' 10 | verbose_name = "Django Two Factor Authentication – Phone Method" 11 | default_auto_field = "django.db.models.AutoField" 12 | url_prefix = 'phone' 13 | 14 | def ready(self): 15 | update_registered_methods(self, None, None) 16 | setting_changed.connect(update_registered_methods) 17 | 18 | 19 | def update_registered_methods(sender, setting, value, **kwargs): 20 | # This allows for dynamic registration, typically when testing. 21 | from .method import PhoneCallMethod, SMSMethod 22 | 23 | phone_number_app_installed = apps.is_installed('two_factor.plugins.phonenumber') 24 | 25 | if phone_number_app_installed and getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None): 26 | registry.register(PhoneCallMethod()) 27 | else: 28 | registry.unregister('call') 29 | if phone_number_app_installed and getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None): 30 | registry.register(SMSMethod()) 31 | else: 32 | registry.unregister('sms') 33 | -------------------------------------------------------------------------------- /two_factor/management/commands/two_factor_status.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.core.management.base import BaseCommand, CommandError 3 | 4 | from ...utils import default_device 5 | 6 | 7 | class Command(BaseCommand): 8 | """ 9 | Command to check two-factor authentication status for certain users. 10 | 11 | The command accepts any number of usernames, and will list if OTP is 12 | enabled or disabled for those users. 13 | 14 | Example usage:: 15 | 16 | manage.py two_factor_status bouke steve 17 | bouke: enabled 18 | steve: disabled 19 | """ 20 | help = 'Checks two-factor authentication status for the given users' 21 | 22 | def add_arguments(self, parser): 23 | parser.add_argument('args', metavar='usernames', nargs='*') 24 | 25 | def handle(self, *usernames, **options): 26 | User = get_user_model() 27 | for username in usernames: 28 | try: 29 | user = User.objects.get_by_natural_key(username) 30 | except User.DoesNotExist: 31 | raise CommandError('User "%s" does not exist' % username) 32 | 33 | self.stdout.write('%s: %s' % ( 34 | username, 35 | 'enabled' if default_device(user) else self.style.ERROR('disabled') 36 | )) 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs flake8 example test coverage migrations 2 | 3 | docs: 4 | tox -e docs 5 | 6 | flake8: 7 | tox -e ruff,isort 8 | 9 | example: 10 | DJANGO_SETTINGS_MODULE=example.settings PYTHONPATH=. \ 11 | django-admin migrate 12 | DJANGO_SETTINGS_MODULE=example.settings PYTHONPATH=. \ 13 | django-admin runserver 14 | 15 | example-webauthn: 16 | DJANGO_SETTINGS_MODULE=example.settings_webauthn PYTHONPATH=. \ 17 | django-admin migrate 18 | DJANGO_SETTINGS_MODULE=example.settings_webauthn PYTHONPATH=. \ 19 | django-admin runserver 20 | 21 | test: 22 | DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH=. \ 23 | django-admin test ${TARGET} 24 | 25 | migrations: 26 | DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH=. \ 27 | django-admin makemigrations two_factor 28 | 29 | coverage: 30 | coverage erase 31 | DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH=. \ 32 | coverage run --parallel --source=two_factor \ 33 | `which django-admin` test ${TARGET} 34 | coverage combine 35 | coverage html 36 | coverage report --precision=2 37 | 38 | tx-pull: 39 | tx pull -a --force 40 | cd two_factor; django-admin compilemessages 41 | cd example; django-admin compilemessages 42 | 43 | tx-push: 44 | cd two_factor; django-admin makemessages -l en -e html,txt,py,xml 45 | cd example; django-admin makemessages -l en -e html,txt,py,xml 46 | tx push -s 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve django-two-factor-auth 4 | title: "BUG: Short description of the problem" 5 | labels: "bug" 6 | assignees: "" 7 | --- 8 | 9 | 10 | 11 | ## Expected Behavior 12 | 13 | 14 | 15 | ## Current Behavior 16 | 17 | 18 | 19 | ## Possible Solution 20 | 21 | 22 | 23 | ## Steps to Reproduce (for bugs) 24 | 25 | 26 | 27 | 28 | 1. 29 | 2. 30 | 3. 31 | 4. 32 | 33 | ## Context 34 | 35 | 36 | 37 | 38 | ## Your Environment 39 | 40 | 41 | 42 | - Browser and version: 43 | - Python version: 44 | - Django version: 45 | - django-otp version: 46 | - django-two-factor-auth version: 47 | - Link to your project: 48 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/templatetags/phonenumber.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.translation import gettext as _ 3 | 4 | from ..utils import ( 5 | format_phone_number as format_phone_number_utils, 6 | mask_phone_number as mask_phone_number_utils, 7 | ) 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.filter 13 | def mask_phone_number(number): 14 | return mask_phone_number_utils(number) 15 | 16 | 17 | mask_phone_number.__doc__ = mask_phone_number_utils.__doc__ 18 | 19 | 20 | @register.filter 21 | def format_phone_number(number): 22 | return format_phone_number_utils(number) 23 | 24 | 25 | format_phone_number.__doc__ = format_phone_number_utils.__doc__ 26 | 27 | 28 | @register.filter 29 | def device_action(device): 30 | """ 31 | Generates an actionable text for a :class:`~two_factor.plugins.phonenumber.models.PhoneDevice`. 32 | 33 | Examples: 34 | 35 | * Send text message to `+31 * ******58` 36 | * Call number `+31 * ******58` 37 | """ 38 | assert device.__class__.__name__ == 'PhoneDevice' 39 | number = mask_phone_number_utils(format_phone_number_utils(device.number)) 40 | if device.method == 'sms': 41 | return _('Send text message to %s') % number 42 | elif device.method == 'call': 43 | return _('Call number %s') % number 44 | raise NotImplementedError('Unknown method: %s' % device.method) 45 | -------------------------------------------------------------------------------- /tests/test_views_backuptokens.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from .utils import UserMixin 5 | 6 | 7 | class BackupTokensTest(UserMixin, TestCase): 8 | def setUp(self): 9 | super().setUp() 10 | self.create_user() 11 | self.enable_otp() 12 | self.login_user() 13 | 14 | def test_empty(self): 15 | response = self.client.get(reverse('two_factor:backup_tokens')) 16 | self.assertContains(response, 'You don\'t have any backup codes yet.') 17 | 18 | def test_generate(self): 19 | url = reverse('two_factor:backup_tokens') 20 | 21 | response = self.client.post(url) 22 | self.assertRedirects(response, url) 23 | 24 | response = self.client.get(url) 25 | first_set = set([token.token for token in 26 | response.context_data['device'].token_set.all()]) 27 | self.assertNotContains(response, 'You don\'t have any backup codes ' 28 | 'yet.') 29 | self.assertEqual(10, len(first_set)) 30 | 31 | # Generating the tokens should give a fresh set 32 | self.client.post(url) 33 | response = self.client.get(url) 34 | second_set = set([token.token for token in 35 | response.context_data['device'].token_set.all()]) 36 | self.assertNotEqual(first_set, second_set) 37 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.contrib.auth.views import LogoutView 4 | from django.urls import include, path 5 | 6 | from two_factor.gateways.twilio.urls import urlpatterns as tf_twilio_urls 7 | from two_factor.urls import urlpatterns as tf_urls 8 | 9 | from .views import ( 10 | ExampleSecretView, HomeView, RegistrationCompleteView, RegistrationView, 11 | ) 12 | 13 | urlpatterns = [ 14 | path( 15 | '', 16 | HomeView.as_view(), 17 | name='home', 18 | ), 19 | path( 20 | 'account/logout/', 21 | LogoutView.as_view(), 22 | name='logout', 23 | ), 24 | path( 25 | 'secret/', 26 | ExampleSecretView.as_view(), 27 | name='secret', 28 | ), 29 | path( 30 | 'account/register/', 31 | RegistrationView.as_view(), 32 | name='registration', 33 | ), 34 | path( 35 | 'account/register/done/', 36 | RegistrationCompleteView.as_view(), 37 | name='registration_complete', 38 | ), 39 | path('', include(tf_urls)), 40 | path('', include(tf_twilio_urls)), 41 | path('', include('user_sessions.urls', 'user_sessions')), 42 | path('admin/', admin.site.urls), 43 | ] 44 | 45 | if settings.DEBUG: 46 | import debug_toolbar 47 | urlpatterns += [ 48 | path('__debug__/', include(debug_toolbar.urls)), 49 | ] 50 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .models import PhoneDevice 5 | from .utils import get_available_phone_methods 6 | from .validators import validate_international_phonenumber 7 | 8 | 9 | class PhoneNumberMethodForm(forms.ModelForm): 10 | number = forms.CharField(label=_("Phone Number"), 11 | validators=[validate_international_phonenumber]) 12 | method = forms.ChoiceField(widget=forms.RadioSelect, label=_('Method')) 13 | 14 | class Meta: 15 | model = PhoneDevice 16 | fields = ['number', 'method'] 17 | 18 | @staticmethod 19 | def get_available_choices(): 20 | choices = [] 21 | for method in get_available_phone_methods(): 22 | choices.append((method.code, method.verbose_name)) 23 | return choices 24 | 25 | def __init__(self, **kwargs): 26 | super().__init__(**kwargs) 27 | self.fields['method'].choices = self.get_available_choices() 28 | 29 | 30 | class PhoneNumberForm(forms.ModelForm): 31 | # Cannot use PhoneNumberField, as it produces a PhoneNumber object, which cannot be serialized. 32 | number = forms.CharField(label=_("Phone Number"), 33 | validators=[validate_international_phonenumber]) 34 | 35 | class Meta: 36 | model = PhoneDevice 37 | fields = ['number'] 38 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Screenshots (if appropriate): 16 | 17 | ## Types of changes 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My code follows the code style of this project. 27 | - [ ] My change requires a change to the documentation. 28 | - [ ] I have updated the documentation accordingly. 29 | - [ ] I have added tests to cover my changes. 30 | - [ ] All new and existing tests passed. 31 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | name: Python ${{ matrix.python-version }} 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: # only run tests on the min & max supported versions of Python 12 | python-version: ['3.9', '3.13'] 13 | env: 14 | COVERAGE_OPTIONS: "-a" 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install Tox and any other packages 23 | run: pip install tox tox-gh-actions coverage 24 | - name: Test with tox 25 | run: tox 26 | - name: Generate coverage XML report 27 | run: coverage xml 28 | - name: Codecov 29 | uses: codecov/codecov-action@v3 30 | env: 31 | PYTHON: ${{matrix.python-version}} 32 | with: 33 | env_vars: PYTHON 34 | 35 | 36 | code_quality: 37 | 38 | name: Code Quality 39 | runs-on: ubuntu-latest 40 | 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Set up Python 3.11 44 | uses: actions/setup-python@v5 45 | with: 46 | python-version: 3.11 47 | - name: Install Tox 48 | run: pip install tox 49 | - name: isort 50 | run: tox -e isort 51 | - name: ruff 52 | run: tox -e ruff 53 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django Two-Factor Authentication Documentation 2 | ============================================== 3 | 4 | Complete Two-Factor Authentication for Django. Built on top of the one-time 5 | password framework django-otp_ and Django's built-in authentication framework 6 | ``django.contrib.auth`` for providing the easiest integration into most Django 7 | projects. Inspired by the user experience of Google's Two-Step Authentication, 8 | allowing users to authenticate through call, text messages (SMS) or by using a 9 | token generator app like Google Authenticator. 10 | 11 | Contents: 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | requirements 17 | installation 18 | configuration 19 | implementing 20 | management-commands 21 | class-reference 22 | 23 | I would love to hear your feedback on this application. If you run into 24 | problems, please file an issue on GitHub_, or contribute to the project by 25 | forking the repository and sending some pull requests. 26 | 27 | This application is currently translated into English, Dutch, Hebrew, Arabic, 28 | German, Chinese, Spanish, French, Swedish, Portuguese (Brazil), Polish, 29 | Italian, Hungarian, Finnish and Danish. You can contribute your own language 30 | using Transifex_. 31 | 32 | Indices and tables 33 | ================== 34 | 35 | * :ref:`genindex` 36 | * :ref:`modindex` 37 | * :ref:`search` 38 | 39 | .. _django-otp: https://pypi.python.org/pypi/django-otp 40 | .. _Transifex: https://explore.transifex.com/Bouke/django-two-factor-auth/ 41 | .. _GitHub: https://github.com/Bouke/django-two-factor-auth/issues 42 | -------------------------------------------------------------------------------- /two_factor/plugins/yubikey/method.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from otp_yubikey.models import RemoteYubikeyDevice, ValidationService 3 | 4 | from two_factor.plugins.registry import MethodBase 5 | 6 | from .forms import YubiKeyAuthenticationForm, YubiKeyDeviceForm 7 | 8 | 9 | class YubikeyMethod(MethodBase): 10 | code = 'yubikey' 11 | verbose_name = _('YubiKey') 12 | 13 | def get_devices(self, user): 14 | return RemoteYubikeyDevice.objects.filter(user=user) 15 | 16 | def recognize_device(self, device): 17 | return isinstance(device, RemoteYubikeyDevice) 18 | 19 | def get_setup_forms(self, *args): 20 | return {'yubikey': YubiKeyDeviceForm} 21 | 22 | def get_device_from_setup_data(self, request, setup_data, **kwargs): 23 | public_id = setup_data.get('yubikey', {}).get('token', '')[:-32] 24 | try: 25 | service = ValidationService.objects.get(name='default') 26 | except ValidationService.DoesNotExist: 27 | raise KeyError("No ValidationService found with name 'default'") 28 | except ValidationService.MultipleObjectsReturned: 29 | raise KeyError("Multiple ValidationService found with name 'default'") 30 | return RemoteYubikeyDevice( 31 | name='default', user=request.user, public_id=public_id, service=service 32 | ) 33 | 34 | def get_token_form_class(self): 35 | return YubiKeyAuthenticationForm 36 | 37 | def get_action(self, device): 38 | return _('Use your Yubikey device') 39 | 40 | def get_verbose_action(self, device): 41 | return _('Please use your Yubikey device.') 42 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import phonenumbers 4 | 5 | from two_factor.plugins.registry import MethodNotFoundError, registry 6 | 7 | phone_mask = re.compile(r'(?<=.{3})[0-9](?=.{2})') 8 | 9 | 10 | def get_available_phone_methods(): 11 | methods = [] 12 | for code in ['sms', 'call']: 13 | try: 14 | method = registry.get_method(code) 15 | except MethodNotFoundError: 16 | pass 17 | else: 18 | methods.append(method) 19 | 20 | return methods 21 | 22 | 23 | def backup_phones(user): 24 | if not user or user.is_anonymous: 25 | return [] 26 | 27 | phones = [] 28 | for method in get_available_phone_methods(): 29 | phones += list(method.get_devices(user)) 30 | 31 | return [phone for phone in phones if phone.name == 'backup'] 32 | 33 | 34 | def mask_phone_number(number): 35 | """ 36 | Masks a phone number, only first 3 and last 2 digits visible. 37 | 38 | Examples: 39 | 40 | * `+31 * ******58` 41 | 42 | :param number: str or phonenumber object 43 | :return: str 44 | """ 45 | if isinstance(number, phonenumbers.PhoneNumber): 46 | number = format_phone_number(number) 47 | return phone_mask.sub('*', number) 48 | 49 | 50 | def format_phone_number(number): 51 | """ 52 | Formats a phone number in international notation. 53 | :param number: str or phonenumber object 54 | :return: str 55 | """ 56 | if not isinstance(number, phonenumbers.PhoneNumber): 57 | number = phonenumbers.parse(number) 58 | return phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.INTERNATIONAL) 59 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.http.response import Http404 3 | from django.utils.decorators import method_decorator 4 | from django.views.decorators.cache import never_cache 5 | from django.views.generic import TemplateView 6 | 7 | 8 | @method_decorator(never_cache, name='dispatch') 9 | class DynamicJS(TemplateView): 10 | def get_extra_context_data(self): 11 | raise NotImplementedError() 12 | 13 | def get_context_data(self, *args, **kwargs): 14 | extra_context = self.get_extra_context_data() 15 | if not extra_context: 16 | raise Http404() 17 | 18 | context = super().get_context_data(*args, **kwargs) 19 | context.update(extra_context) 20 | 21 | return context 22 | 23 | 24 | @method_decorator(login_required, name='dispatch') 25 | class CreateCredentialJS(DynamicJS): 26 | template_name = 'two_factor_webauthn/create_credential.js' 27 | 28 | def get_extra_context_data(self): 29 | credential_creation_options = self.request.session.get( 30 | 'webauthn_creation_options') 31 | if credential_creation_options: 32 | return {'credential_creation_options': credential_creation_options} 33 | return None 34 | 35 | 36 | class GetCredentialJS(DynamicJS): 37 | template_name = 'two_factor_webauthn/get_credential.js' 38 | 39 | def get_extra_context_data(self): 40 | credential_request_options = self.request.session.get('webauthn_request_options') 41 | if credential_request_options: 42 | return {'credential_request_options': credential_request_options} 43 | return None 44 | -------------------------------------------------------------------------------- /two_factor/plugins/email/method.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from django_otp.plugins.otp_email.models import EmailDevice 3 | 4 | from two_factor.plugins.registry import MethodBase 5 | 6 | from .forms import AuthenticationTokenForm, DeviceValidationForm, EmailForm 7 | from .utils import mask_email 8 | 9 | 10 | class EmailMethod(MethodBase): 11 | code = 'email' 12 | verbose_name = _('Email') 13 | 14 | def get_devices(self, user): 15 | return EmailDevice.objects.devices_for_user(user).all() 16 | 17 | def recognize_device(self, device): 18 | return isinstance(device, EmailDevice) 19 | 20 | def get_setup_forms(self, wizard): 21 | forms = {} 22 | if not wizard.request.user.email: 23 | forms[self.code] = EmailForm 24 | forms['validation'] = DeviceValidationForm 25 | return forms 26 | 27 | def get_device_from_setup_data(self, request, setup_data, **kwargs): 28 | if setup_data and not request.user.email: 29 | request.user.email = setup_data.get('email').get('email') 30 | request.user.save(update_fields=['email']) 31 | device = EmailDevice.objects.devices_for_user(request.user).first() 32 | if not device: 33 | device = EmailDevice(user=request.user, name='default', confirmed=False) 34 | return device 35 | 36 | def get_token_form_class(self): 37 | return AuthenticationTokenForm 38 | 39 | def get_action(self, device): 40 | email = device.email or device.user.email 41 | return _('Send email to %s') % (email and mask_email(email) or None,) 42 | 43 | def get_verbose_action(self, device): 44 | return _('We sent you an email, please enter the token we sent.') 45 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/method.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | from two_factor.plugins.registry import MethodBase 4 | 5 | from .forms import PhoneNumberForm 6 | from .models import PhoneDevice 7 | from .utils import format_phone_number, mask_phone_number 8 | 9 | 10 | class PhoneMethodBase(MethodBase): 11 | def get_devices(self, user): 12 | return PhoneDevice.objects.filter(user=user, method=self.code) 13 | 14 | def recognize_device(self, device): 15 | return isinstance(device, PhoneDevice) and device.method == self.code 16 | 17 | def get_setup_forms(self, *args): 18 | return {self.code: PhoneNumberForm} 19 | 20 | def get_device_from_setup_data(self, request, storage_data, **kwargs): 21 | return PhoneDevice( 22 | key=kwargs['key'], 23 | name='default', 24 | user=request.user, 25 | method=self.code, 26 | number=storage_data.get(self.code, {}).get('number'), 27 | ) 28 | 29 | def get_action(self, device): 30 | number = mask_phone_number(format_phone_number(device.number)) 31 | return self.action % number 32 | 33 | def get_verbose_action(self, device): 34 | return self.verbose_action 35 | 36 | 37 | class PhoneCallMethod(PhoneMethodBase): 38 | code = 'call' 39 | verbose_name = _('Phone call') 40 | action = _('Call number %s') 41 | verbose_action = _('We are calling your phone right now, please enter the digits you hear.') 42 | 43 | 44 | class SMSMethod(PhoneMethodBase): 45 | code = 'sms' 46 | verbose_name = _('Text message') 47 | action = _('Send text message to %s') 48 | verbose_action = _('We sent you a text message, please enter the token we sent.') 49 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from two_factor.plugins.registry import ( 4 | GeneratorMethod, MethodBase, MethodNotFoundError, registry, 5 | ) 6 | 7 | 8 | class FakeMethod(MethodBase): 9 | code = 'fake-method' 10 | 11 | 12 | class RegistryTest(TestCase): 13 | def setUp(self) -> None: 14 | self.old_methods = list(registry._methods) 15 | return super().setUp() 16 | 17 | def tearDown(self) -> None: 18 | registry._methods = self.old_methods 19 | return super().tearDown() 20 | 21 | def test_register(self): 22 | previous_length = len(registry._methods) 23 | expected_length = previous_length + 1 24 | 25 | registry.register(FakeMethod()) 26 | self.assertEqual(len(registry._methods), expected_length) 27 | 28 | def test_register_twice(self): 29 | previous_length = len(registry._methods) 30 | expected_length = previous_length 31 | 32 | registry.register(GeneratorMethod()) 33 | self.assertEqual(len(registry._methods), expected_length) 34 | 35 | def test_unregister(self): 36 | previous_length = len(registry._methods) 37 | expected_length = previous_length - 1 38 | 39 | registry.unregister('generator') 40 | self.assertEqual(len(registry._methods), expected_length) 41 | 42 | def test_unregister_non_registered(self): 43 | previous_length = len(registry._methods) 44 | expected_length = previous_length 45 | 46 | registry.unregister('fake-method') 47 | self.assertEqual(len(registry._methods), expected_length) 48 | 49 | def test_unknown_method(self): 50 | with self.assertRaises(MethodNotFoundError): 51 | registry.get_method("not-existing-method") 52 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/templates/two_factor_webauthn/create_credential.js: -------------------------------------------------------------------------------- 1 | let credentialCreationOptions = {{ credential_creation_options|safe }}; 2 | 3 | credentialCreationOptions.challenge = b64str2ab(credentialCreationOptions.challenge); 4 | for (let i = 0; i < credentialCreationOptions.excludeCredentials.length; i++) { 5 | credentialCreationOptions.excludeCredentials[i].id = b64str2ab(credentialCreationOptions.excludeCredentials[i].id); 6 | } 7 | credentialCreationOptions.user.id = b64str2ab(credentialCreationOptions.user.id); 8 | 9 | navigator.credentials.create({ 10 | publicKey: credentialCreationOptions 11 | }).then((attestationCredential) => { 12 | let response = attestationCredential.response, 13 | serializableAttestationCredential = { 14 | id: attestationCredential.id, 15 | rawId: ab2str(attestationCredential.rawId), 16 | response: { 17 | clientDataJSON: ab2str(response.clientDataJSON), 18 | attestationObject: ab2str(response.attestationObject), 19 | }, 20 | type: attestationCredential.type, 21 | }, 22 | tokenField = document.querySelector('[name=webauthn-token]'), 23 | form = tokenField.closest('form'); 24 | 25 | tokenField.value = JSON.stringify(serializableAttestationCredential); 26 | form.submit(); 27 | 28 | }, (reason) => { 29 | console.debug("Registration error: ", reason); 30 | 31 | let errMsgNode = document.createElement("p"), 32 | tokenField = document.querySelector('#id_webauthn-token'); 33 | 34 | errMsgNode.setAttribute("class", "text-danger"); 35 | errMsgNode.appendChild(document.createTextNode(reason)); 36 | tokenField.parentNode.insertBefore(errMsgNode, tokenField.nextSibling); 37 | }); 38 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/templates/two_factor_webauthn/get_credential.js: -------------------------------------------------------------------------------- 1 | let credentialRequestOptions = {{ credential_request_options|safe }}; 2 | 3 | credentialRequestOptions.challenge = b64str2ab(credentialRequestOptions.challenge); 4 | for (let i = 0; i < credentialRequestOptions.allowCredentials.length; i++) { 5 | credentialRequestOptions.allowCredentials[i].id = b64str2ab(credentialRequestOptions.allowCredentials[i].id); 6 | } 7 | 8 | navigator.credentials.get({ 9 | publicKey: credentialRequestOptions 10 | }).then((assertionCredential) => { 11 | let response = assertionCredential.response, 12 | serializableAssertionCredential = { 13 | id: assertionCredential.id, 14 | rawId: ab2str(assertionCredential.rawId), 15 | response: { 16 | clientDataJSON: ab2str(response.clientDataJSON), 17 | authenticatorData: ab2str(response.authenticatorData), 18 | signature: ab2str(response.signature), 19 | userHandle: ab2str(response.userHandle), 20 | }, 21 | type: assertionCredential.type, 22 | }, 23 | tokenField = document.querySelector('[name=token-otp_token]'), 24 | authenticationTokenForm = document.forms[0]; 25 | 26 | tokenField.value = JSON.stringify(serializableAssertionCredential); 27 | authenticationTokenForm.submit(); 28 | }, (reason) => { 29 | console.debug("Authentication error: ", reason); 30 | 31 | let errMsgNode = document.createElement("p"), 32 | tokenField = document.querySelector('[name=token-otp_token]'); 33 | 34 | errMsgNode.setAttribute("class", "text-danger"); 35 | errMsgNode.appendChild(document.createTextNode(reason)); 36 | tokenField.parentNode.insertBefore(errMsgNode, tokenField.nextSibling); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/test_views_profile.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase, override_settings 3 | from django.urls import reverse 4 | 5 | from two_factor.plugins.registry import MethodNotFoundError, registry 6 | 7 | from .utils import UserMixin 8 | 9 | 10 | @override_settings( 11 | TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake', 12 | TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake', 13 | ) 14 | class ProfileTest(UserMixin, TestCase): 15 | def setUp(self): 16 | super().setUp() 17 | self.user = self.create_user() 18 | self.enable_otp() 19 | self.login_user() 20 | 21 | def get_profile(self): 22 | url = reverse('two_factor:profile') 23 | return self.client.get(url) 24 | 25 | def test_get_profile_without_phonenumber_plugin_enabled(self): 26 | without_phonenumber_plugin = [ 27 | app for app in settings.INSTALLED_APPS if app != 'two_factor.plugins.phonenumber'] 28 | 29 | with override_settings(INSTALLED_APPS=without_phonenumber_plugin): 30 | with self.assertRaises(MethodNotFoundError): 31 | registry.get_method('call') 32 | with self.assertRaises(MethodNotFoundError): 33 | registry.get_method('sms') 34 | 35 | response = self.get_profile() 36 | 37 | self.assertNotIn('available_phone_methods', response.context) 38 | 39 | def test_get_profile_with_phonenumer_plugin_enabled(self): 40 | self.assertTrue(registry.get_method('call')) 41 | self.assertTrue(registry.get_method('sms')) 42 | 43 | response = self.get_profile() 44 | available_phone_method_codes = {method.code for method in response.context['available_phone_methods']} 45 | self.assertTrue(available_phone_method_codes == {'call', 'sms'}) 46 | -------------------------------------------------------------------------------- /tests/test_views_disable.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.shortcuts import resolve_url 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | from django_otp import DEVICE_ID_SESSION_KEY, devices_for_user 6 | 7 | from .utils import UserMixin 8 | 9 | 10 | class DisableTest(UserMixin, TestCase): 11 | def setUp(self): 12 | super().setUp() 13 | self.user = self.create_user() 14 | self.enable_otp() 15 | self.login_user() 16 | 17 | def test_get(self): 18 | response = self.client.get(reverse('two_factor:disable')) 19 | self.assertContains(response, 'Yes, I am sure') 20 | 21 | def test_post_no_data(self): 22 | response = self.client.post(reverse('two_factor:disable')) 23 | self.assertEqual(response.context_data['form'].errors, 24 | {'understand': ['This field is required.']}) 25 | 26 | def test_post_success(self): 27 | response = self.client.post(reverse('two_factor:disable'), 28 | {'understand': '1'}) 29 | self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) 30 | self.assertEqual(list(devices_for_user(self.user)), []) 31 | 32 | def test_cannot_disable_twice(self): 33 | [i.delete() for i in devices_for_user(self.user)] 34 | response = self.client.get(reverse('two_factor:disable')) 35 | self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) 36 | 37 | def test_cannot_disable_without_verified(self): 38 | # remove OTP data from session 39 | session = self.client.session 40 | del session[DEVICE_ID_SESSION_KEY] 41 | session.save() 42 | response = self.client.get(reverse('two_factor:disable')) 43 | self.assertRedirects(response, resolve_url(settings.LOGIN_REDIRECT_URL)) 44 | -------------------------------------------------------------------------------- /two_factor/migrations/0003_auto_20150817_1733.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.apps import apps 4 | from django.db import migrations 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def migrate_phone_numbers(apps, schema_editor): 10 | import phonenumbers 11 | 12 | PhoneDevice = apps.get_model("two_factor", "PhoneDevice") 13 | for device in PhoneDevice.objects.all(): 14 | try: 15 | number = phonenumbers.parse(device.number) 16 | if not phonenumbers.is_valid_number(number): 17 | logger.info("User '%s' has an invalid phone number '%s'." % (device.user, device.number)) 18 | device.number = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164) 19 | device.save() 20 | except phonenumbers.NumberParseException as e: 21 | # Do not modify/delete the device, as it worked before. However this might result in issues elsewhere, 22 | # so do log a warning. 23 | logger.warning("User '%s' has an invalid phone number '%s': %s. Please resolve this issue, " 24 | "as it might result in errors." % (device.user, device.number, e)) 25 | 26 | 27 | class Migration(migrations.Migration): 28 | 29 | dependencies = [ 30 | ('two_factor', '0002_auto_20150110_0810'), 31 | ] 32 | 33 | if apps.is_installed('two_factor.plugins.phonenumber'): 34 | from phonenumber_field.modelfields import PhoneNumberField 35 | 36 | operations = [ 37 | migrations.RunPython(migrate_phone_numbers, reverse_code=migrations.RunPython.noop), 38 | migrations.AlterField( 39 | model_name='phonedevice', 40 | name='number', 41 | field=PhoneNumberField(max_length=16, verbose_name='number'), 42 | ), 43 | ] 44 | 45 | else: 46 | operations = [] 47 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from unittest import skipUnless 2 | 3 | from django.forms import ValidationError 4 | from django.test import RequestFactory, TestCase 5 | from django.urls import reverse 6 | 7 | try: 8 | import webauthn 9 | 10 | from two_factor.plugins.webauthn.forms import ( 11 | WebauthnAuthenticationTokenForm, WebauthnDeviceValidationForm, 12 | ) 13 | except ImportError: 14 | webauthn = None 15 | 16 | 17 | @skipUnless(webauthn, 'package webauthn is not present') 18 | class WebauthnAuthenticationFormTests(TestCase): 19 | def test_verify_token_with_invalid_token(self): 20 | request_factory = RequestFactory() 21 | data = {'otp-token': 'invalid-token'} 22 | request = request_factory.post(reverse('two_factor:login'), data=data) 23 | request.session = { 24 | 'webauthn_request_challenge': 'a-challenge', 25 | 'webauthn_request_options': 'some-options', 26 | } 27 | 28 | form = WebauthnAuthenticationTokenForm(None, None, request, data=data) 29 | 30 | with self.assertRaises(ValidationError) as context: 31 | form._verify_token(None, 'invalid-token') 32 | 33 | self.assertEqual(context.exception.code, 'invalid_token') 34 | 35 | 36 | @skipUnless(webauthn, 'package webauthn is not present') 37 | class WebauthnDeviceValidationFormTests(TestCase): 38 | def test_clean_token_with_invalid_token(self): 39 | request_factory = RequestFactory() 40 | data = {'token': 'invalid-token'} 41 | request = request_factory.post(reverse('two_factor:setup'), data=data) 42 | request.session = {'webauthn_creation_challenge': 'a-challenge'} 43 | 44 | form = WebauthnDeviceValidationForm(None, request, data=data) 45 | 46 | self.assertFalse(form.is_valid()) 47 | self.assertEqual(form.error_messages.keys(), {'invalid_token'}) 48 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.models import ( 3 | AbstractBaseUser, PermissionsMixin, UserManager as BaseUserManager, 4 | ) 5 | from django.db import models 6 | 7 | # Only define these classes when we're testing a custom user model. Otherwise 8 | # we'll get SystemCheckError "fields.E304". 9 | if settings.AUTH_USER_MODEL == "tests.User": 10 | class UserManager(BaseUserManager): 11 | def _create_user(self, username, email, password, 12 | is_staff, is_superuser, **extra_fields): 13 | """ 14 | Creates and saves a User with the given email and password. 15 | """ 16 | email = self.normalize_email(email) 17 | user = self.model(email=email, is_staff=is_staff, 18 | is_superuser=is_superuser, **extra_fields) 19 | user.set_password(password) 20 | user.save(using=self._db) 21 | return user 22 | 23 | def create_user(self, username, email=None, password=None, **extra_fields): 24 | return self._create_user(username, email, password, False, False, **extra_fields) 25 | 26 | def create_superuser(self, username, email, password, **extra_fields): 27 | return self._create_user(username, email, password, True, True, **extra_fields) 28 | 29 | class User(AbstractBaseUser, PermissionsMixin): 30 | """ 31 | Custom User model inheriting from AbstractBaseUser. Should be admin site 32 | compatible. 33 | 34 | Email and password are required. Other fields are optional. 35 | """ 36 | email = models.EmailField(blank=True, unique=True) 37 | is_staff = models.BooleanField(default=False) 38 | 39 | objects = UserManager() 40 | 41 | USERNAME_FIELD = 'email' 42 | 43 | def get_short_name(self): 44 | return self.email.split('@')[0] 45 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{39,310,311,312}-dj42-{normal,yubikey,custom_user,webauthn} 4 | py{310,311,312}-dj50-{normal,yubikey,custom_user,webauthn} 5 | py{310,311,312,313}-dj{51,52}-{normal,yubikey,custom_user,webauthn} 6 | py{312,313}-djmain-{normal,yubikey,custom_user,webauthn} 7 | 8 | [gh-actions] 9 | python = 10 | 3.9: py39 11 | 3.10: py310 12 | 3.11: py311 13 | 3.12: py312 14 | 3.13: py313 15 | 16 | [gh-actions:env] 17 | DJANGO = 18 | 4.2: dj42 19 | 5.0: dj50 20 | 5.1: dj51 21 | 5.2: dj52 22 | main: djmain 23 | VARIANT = 24 | normal: normal 25 | webauthn: webauthn 26 | yubikey: yubikey 27 | custom_user: custom_user 28 | 29 | [testenv] 30 | passenv = 31 | HOME 32 | DISPLAY 33 | setenv = 34 | PYTHONDONTWRITEBYTECODE=1 35 | PYTHONWARNINGS=always 36 | custom_user: AUTH_USER_MODEL=tests.User 37 | basepython = 38 | py39: python3.9 39 | py310: python3.10 40 | py311: python3.11 41 | py312: python3.12 42 | py313: python3.13 43 | deps = 44 | dj42: Django<5.0 45 | dj50: Django<5.1 46 | dj51: Django<5.2 47 | dj52: Django<5.3 48 | djmain: https://github.com/django/django/archive/main.tar.gz 49 | webauthn: -rrequirements_e2e.txt 50 | extras = 51 | tests 52 | call 53 | phonenumberslite 54 | yubikey: yubikey 55 | webauthn: webauthn 56 | ignore_outcome = 57 | djmain: True 58 | commands = 59 | coverage run {env:COVERAGE_OPTIONS:} {envbindir}/django-admin test -v 2 --pythonpath=./ --settings=tests.settings 60 | coverage report 61 | 62 | [testenv:ruff] 63 | basepython = python3 64 | extras = linting 65 | commands = ruff check . 66 | 67 | [testenv:isort] 68 | basepython = python3 69 | extras = linting 70 | commands = isort -c --diff example tests two_factor 71 | 72 | [testenv:docs] 73 | allowlist_externals = make 74 | changedir = docs 75 | commands = make html 76 | extras = docs 77 | -------------------------------------------------------------------------------- /docs/class-reference.rst: -------------------------------------------------------------------------------- 1 | Class Reference 2 | =============== 3 | 4 | Admin Site 5 | ---------- 6 | .. autoclass:: two_factor.admin.AdminSiteOTPRequired 7 | .. autoclass:: two_factor.admin.AdminSiteOTPRequiredMixin 8 | 9 | Decorators 10 | ---------- 11 | .. automodule:: django_otp.decorators 12 | :members: 13 | 14 | Models 15 | ------ 16 | .. autoclass:: two_factor.plugins.phonenumber.models.PhoneDevice 17 | .. autoclass:: django_otp.plugins.otp_static.models.StaticDevice 18 | .. autoclass:: django_otp.plugins.otp_static.models.StaticToken 19 | .. autoclass:: django_otp.plugins.otp_totp.models.TOTPDevice 20 | 21 | Middleware 22 | ---------- 23 | .. autoclass:: django_otp.middleware.OTPMiddleware 24 | 25 | Signals 26 | ------- 27 | .. module:: two_factor.signals 28 | .. data:: user_verified 29 | 30 | Sent when a user is verified against a OTP device. Provides the following 31 | arguments: 32 | 33 | ``sender`` 34 | The class sending the signal (``'two_factor.views.core'``). 35 | 36 | ``user`` 37 | The user that was verified. 38 | 39 | ``device`` 40 | The OTP device that was used. 41 | 42 | 43 | ``request`` 44 | The ``HttpRequest`` in which the user was verified. 45 | 46 | Template Tags 47 | -------------- 48 | .. automodule:: two_factor.plugins.phonenumber.templatetags.phonenumber 49 | :members: 50 | 51 | Utilities 52 | --------- 53 | .. automodule:: two_factor.views.utils 54 | :members: 55 | 56 | Views 57 | ----- 58 | .. autoclass:: two_factor.views.LoginView 59 | .. autoclass:: two_factor.views.SetupView 60 | .. autoclass:: two_factor.views.SetupCompleteView 61 | .. autoclass:: two_factor.views.BackupTokensView 62 | .. autoclass:: two_factor.views.ProfileView 63 | .. autoclass:: two_factor.views.DisableView 64 | .. autoclass:: two_factor.plugins.phonenumber.views.PhoneSetupView 65 | .. autoclass:: two_factor.plugins.phonenumber.views.PhoneDeleteView 66 | 67 | View Mixins 68 | ----------- 69 | .. automodule:: two_factor.views.mixins 70 | :members: 71 | -------------------------------------------------------------------------------- /example/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "django-two-factor-auth – Demo Application" %}{% endblock %} 5 | {% block nav_home %}active{% endblock %} 6 | 7 | {% block content %} 8 |{% blocktrans trimmed %}Welcome to the example app of
11 | django-two-factor-auth. Use this example to get an
12 | understanding of what the app can do for you.{% endblocktrans %}
{% blocktrans trimmed%}Have you provided your Twilio settings in the
16 | settings_private.py file? By doing so, the example app will
17 | be able to call and text you to verify your authentication tokens.
18 | Otherwise, the tokens will be shown on the screen.{% endblocktrans %}
{% blocktrans %}Enter your credentials.{% endblocktrans %}
14 | {% elif wizard.steps.current == 'token' %} 15 |{{ device|as_verbose_action }}
16 | {% elif wizard.steps.current == 'backup' %} 17 |{% blocktrans trimmed %}Use this form for entering backup tokens for logging in. 18 | These tokens have been generated for you to print and keep safe. Please 19 | enter one of these backup tokens to login to your account.{% endblocktrans %}
20 | {% endif %} 21 | 22 | 44 | 45 | {% block 'backup_tokens' %} 46 | {% if backup_tokens %} 47 |{% blocktrans with primary=default_device|as_action %}Primary method: {{ primary }}{% endblocktrans %}
10 | 11 | {% if available_phone_methods %} 12 |{% blocktrans trimmed %}If your primary method is not available, we are able to 14 | send backup tokens to the phone numbers listed below.{% endblocktrans %}
15 | {% if backup_phones %} 16 |{% trans "Add Phone Number" %}
32 | {% endif %} 33 | 34 |36 | {% blocktrans trimmed %}If you don't have any device with you, you can access 37 | your account using backup tokens.{% endblocktrans %} 38 | {% blocktrans trimmed count counter=backup_tokens %} 39 | You have only one backup token remaining. 40 | {% plural %} 41 | You have {{ counter }} backup tokens remaining. 42 | {% endblocktrans %} 43 |
44 | 46 | 47 |{% blocktrans trimmed %}However we strongly discourage you to do so, you can 49 | also disable two-factor authentication for your account.{% endblocktrans %}
50 |51 | {% trans "Disable Two-Factor Authentication" %}
52 | {% else %} 53 |{% blocktrans trimmed %}Two-factor authentication is not enabled for your 54 | account. Enable two-factor authentication for enhanced account 55 | security.{% endblocktrans %}
56 |57 | {% trans "Enable Two-Factor Authentication" %} 58 |
59 | {% endif %} 60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | import django_otp.util 3 | import phonenumber_field.modelfields 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import two_factor.plugins.phonenumber.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ('two_factor', '0008_delete_phonedevice'), 16 | ] 17 | 18 | operations = [ 19 | migrations.SeparateDatabaseAndState( 20 | state_operations=[ 21 | migrations.CreateModel( 22 | name='PhoneDevice', 23 | fields=[ 24 | ('id', 25 | models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 26 | ('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)), 27 | ('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')), 28 | ('throttling_failure_timestamp', models.DateTimeField( 29 | blank=True, default=None, 30 | help_text='A timestamp of the last failed verification attempt. ' 31 | 'Null if last attempt succeeded.', 32 | null=True)), 33 | ('throttling_failure_count', 34 | models.PositiveIntegerField(default=0, help_text='Number of successive failed attempts.')), 35 | ('number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)), 36 | ('key', models.CharField(default=django_otp.util.random_hex, 37 | help_text='Hex-encoded secret key', 38 | max_length=40, 39 | validators=[two_factor.plugins.phonenumber.models.key_validator])), 40 | ('method', 41 | models.CharField(choices=[('call', 'Phone Call'), ('sms', 'Text Message')], max_length=4, 42 | verbose_name='method')), 43 | ('user', models.ForeignKey(help_text='The user that this device belongs to.', 44 | on_delete=django.db.models.deletion.CASCADE, 45 | to=settings.AUTH_USER_MODEL)), 46 | ], 47 | options={ 48 | 'db_table': 'two_factor_phonedevice', 49 | }, 50 | ), 51 | ], 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /two_factor/views/profile.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.contrib.auth.decorators import login_required 4 | from django.shortcuts import redirect, resolve_url 5 | from django.utils.decorators import method_decorator 6 | from django.utils.functional import lazy 7 | from django.views.decorators.cache import never_cache 8 | from django.views.generic import FormView, TemplateView 9 | from django_otp import devices_for_user 10 | from django_otp.decorators import otp_required 11 | 12 | from ..forms import DisableForm 13 | from ..utils import default_device 14 | 15 | 16 | @method_decorator([never_cache, login_required], name='dispatch') 17 | class ProfileView(TemplateView): 18 | """ 19 | View used by users for managing two-factor configuration. 20 | 21 | This view shows whether two-factor has been configured for the user's 22 | account. If two-factor is enabled, it also lists the primary verification 23 | method and backup verification methods. 24 | """ 25 | template_name = 'two_factor/profile/profile.html' 26 | 27 | def get_context_data(self, **kwargs): 28 | user = self.request.user 29 | 30 | try: 31 | backup_tokens = user.staticdevice_set.all()[0].token_set.count() 32 | 33 | except Exception: 34 | backup_tokens = 0 35 | 36 | context = super().get_context_data(**kwargs) 37 | context.update({ 38 | 'default_device': default_device(user), 39 | 'default_device_type': default_device(user).__class__.__name__, 40 | 'backup_tokens': backup_tokens, 41 | }) 42 | 43 | if apps.is_installed("two_factor.plugins.phonenumber"): 44 | from two_factor.plugins.phonenumber.utils import ( 45 | backup_phones, get_available_phone_methods, 46 | ) 47 | 48 | context.update({ 49 | "backup_phones": backup_phones(self.request.user), 50 | "available_phone_methods": get_available_phone_methods(), 51 | }) 52 | 53 | return context 54 | 55 | 56 | @method_decorator(never_cache, name='dispatch') 57 | class DisableView(FormView): 58 | """ 59 | View for disabling two-factor for a user's account. 60 | """ 61 | template_name = 'two_factor/profile/disable.html' 62 | success_url = lazy(resolve_url, str)(settings.LOGIN_REDIRECT_URL) 63 | form_class = DisableForm 64 | 65 | def dispatch(self, *args, **kwargs): 66 | # We call otp_required here because we want to use self.success_url as 67 | # the login_url. Using it as a class decorator would make it difficult 68 | # for users who wish to override this property 69 | fn = otp_required(super().dispatch, login_url=self.success_url, redirect_field_name=None) 70 | return fn(*args, **kwargs) 71 | 72 | def form_valid(self, form): 73 | for device in devices_for_user(self.request.user): 74 | device.delete() 75 | return redirect(self.success_url) 76 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import skipUnless 3 | 4 | from django.test import TestCase 5 | 6 | try: 7 | import webauthn 8 | from webauthn.helpers import bytes_to_base64url 9 | from webauthn.helpers.structs import ( 10 | PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, 11 | ) 12 | 13 | from two_factor.plugins.webauthn.utils import ( 14 | make_credential_creation_options, make_credential_request_options, 15 | ) 16 | except ImportError: 17 | webauthn = None 18 | 19 | 20 | @skipUnless(webauthn, 'package webauthn is not present') 21 | class UtilsTests(TestCase): 22 | def setUp(self): 23 | super().setUp() 24 | self.mocked_user = PublicKeyCredentialUserEntity( 25 | id=b'mocked-user-id', name='mocked-username', display_name='Mocked Display Name') 26 | self.mocked_rp = PublicKeyCredentialRpEntity(id='mocked-rp-id', name='mocked-rp-name') 27 | self.mocked_challenge = bytes_to_base64url(b'mocked-challenge') 28 | self.mocked_user_id_b64 = bytes_to_base64url(b'mocked-user-id') 29 | self.mocked_credential_id_b64 = bytes_to_base64url(b'mocked-credential-id') 30 | 31 | def test_make_credential_creation_options(self): 32 | json_options, challenge_b64 = make_credential_creation_options( 33 | self.mocked_user, self.mocked_rp, [self.mocked_credential_id_b64], challenge=self.mocked_challenge) 34 | options = json.loads(json_options) 35 | 36 | self.assertEqual(options['rp'], {'id': self.mocked_rp.id, 'name': self.mocked_rp.name}) 37 | self.assertEqual( 38 | options['user'], 39 | {'id': self.mocked_user_id_b64, 'name': 'mocked-username', 'displayName': 'Mocked Display Name'}, 40 | ) 41 | self.assertEqual(options['challenge'], self.mocked_challenge) 42 | self.assertEqual(options['excludeCredentials'], [{'type': 'public-key', 'id': self.mocked_credential_id_b64}]) 43 | self.assertEqual( 44 | options['authenticatorSelection'], 45 | {'requireResidentKey': False, 'userVerification': 'discouraged'}, 46 | ) 47 | self.assertEqual(options['attestation'], 'none') 48 | self.assertEqual(challenge_b64, self.mocked_challenge) 49 | 50 | def test_make_credential_request_options(self): 51 | json_options, challenge_b64 = make_credential_request_options( 52 | self.mocked_rp, [self.mocked_credential_id_b64], challenge=self.mocked_challenge) 53 | options = json.loads(json_options) 54 | 55 | self.assertEqual(options['rpId'], self.mocked_rp.id) 56 | self.assertEqual(options['challenge'], self.mocked_challenge) 57 | self.assertEqual(len(options['allowCredentials']), 1) 58 | self.assertEqual(options['allowCredentials'][0]['type'], 'public-key') 59 | self.assertEqual(options['allowCredentials'][0]['id'], self.mocked_credential_id_b64) 60 | self.assertEqual(options['userVerification'], 'discouraged') 61 | self.assertEqual(challenge_b64, self.mocked_challenge) 62 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | try: 4 | import otp_yubikey 5 | except ImportError: 6 | otp_yubikey = None 7 | 8 | try: 9 | import webauthn 10 | except ImportError: 11 | webauthn = None 12 | 13 | BASE_DIR = os.path.dirname(__file__) 14 | 15 | SECRET_KEY = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' 16 | 17 | INSTALLED_APPS = [ 18 | 'django.contrib.auth', 19 | 'django.contrib.admin', 20 | 'django.contrib.contenttypes', 21 | 'django.contrib.sessions', 22 | 'django.contrib.messages', 23 | 'django_otp', 24 | 'django_otp.plugins.otp_static', 25 | 'django_otp.plugins.otp_totp', 26 | 'django_otp.plugins.otp_email', 27 | 'two_factor', 28 | 'two_factor.plugins.email', 29 | 'two_factor.plugins.phonenumber', 30 | 'tests', 31 | ] 32 | 33 | if otp_yubikey: 34 | INSTALLED_APPS.extend(['otp_yubikey', 'two_factor.plugins.yubikey']) 35 | 36 | if webauthn: 37 | INSTALLED_APPS.extend(['two_factor.plugins.webauthn']) 38 | 39 | MIDDLEWARE = ( 40 | 'django.middleware.common.CommonMiddleware', 41 | 'django.contrib.sessions.middleware.SessionMiddleware', 42 | 'django.middleware.csrf.CsrfViewMiddleware', 43 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 44 | 'django.contrib.messages.middleware.MessageMiddleware', 45 | 'django_otp.middleware.OTPMiddleware', 46 | 'two_factor.middleware.threadlocals.ThreadLocals', 47 | ) 48 | 49 | ROOT_URLCONF = 'tests.urls' 50 | 51 | STATIC_URL = '/static/' 52 | 53 | LOGIN_URL = 'two_factor:login' 54 | LOGIN_REDIRECT_URL = 'two_factor:profile' 55 | 56 | CACHES = { 57 | 'default': { 58 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 59 | } 60 | } 61 | 62 | DATABASES = { 63 | 'default': { 64 | 'ENGINE': 'django.db.backends.sqlite3', 65 | 'NAME': ':memory:', 66 | } 67 | } 68 | 69 | TEMPLATES = [ 70 | { 71 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 72 | 'DIRS': [ 73 | os.path.join(BASE_DIR, 'templates'), 74 | ], 75 | 'APP_DIRS': True, 76 | 'OPTIONS': { 77 | 'context_processors': [ 78 | 'django.contrib.auth.context_processors.auth', 79 | 'django.template.context_processors.debug', 80 | 'django.template.context_processors.i18n', 81 | 'django.template.context_processors.media', 82 | 'django.template.context_processors.static', 83 | 'django.template.context_processors.tz', 84 | 'django.contrib.messages.context_processors.messages', 85 | ], 86 | }, 87 | }, 88 | ] 89 | 90 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 91 | 92 | TWO_FACTOR_PATCH_ADMIN = False 93 | 94 | TWO_FACTOR_WEBAUTHN_RP_NAME = 'Test Server' 95 | 96 | AUTH_USER_MODEL = os.environ.get('AUTH_USER_MODEL', 'auth.User') 97 | PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] 98 | 99 | EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' 100 | DEFAULT_FROM_EMAIL = 'test@test.org' 101 | 102 | TWO_FACTOR_PHONE_THROTTLE_FACTOR = 10 103 | OTP_TOTP_THROTTLE_FACTOR = 10 104 | 105 | OTP_EMAIL_COOLDOWN_DURATION = 0 106 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/core/setup.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block extra_media %} 5 | {{ form.media }} 6 | {% endblock %} 7 | 8 | {% block content %} 9 |{% blocktrans trimmed %}You are about to take your account security to the 12 | next level. Follow the steps in this wizard to enable two-factor 13 | authentication.{% endblocktrans %}
14 | {% elif wizard.steps.current == 'method' %} 15 |{% blocktrans trimmed %}Please select which authentication method you would 16 | like to use.{% endblocktrans %}
17 | {% elif wizard.steps.current == 'generator' %} 18 |{% blocktrans trimmed %}To start using a token generator, please use your 19 | smartphone to scan the QR code below. For example, use Google 20 | Authenticator.{% endblocktrans %}
21 |{% blocktrans trimmed %}Alternatively you can use the following secret to 23 | setup TOTP in your authenticator or password manager manually.{% endblocktrans %}
24 |{% translate "TOTP Secret:" %} {{ secret_key }}
25 |{% blocktrans %}Then, enter the token generated by the app.{% endblocktrans %}
26 | 27 | {% elif wizard.steps.current == 'sms' %} 28 |{% blocktrans trimmed %}Please enter the phone number you wish to receive the 29 | text messages on. This number will be validated in the next step. 30 | {% endblocktrans %}
31 | {% elif wizard.steps.current == 'call' %} 32 |{% blocktrans trimmed %}Please enter the phone number you wish to be called on. 33 | This number will be validated in the next step. {% endblocktrans %}
34 | {% elif wizard.steps.current == 'validation' %} 35 | {% if challenge_succeeded %} 36 | {% if device.method == 'call' %} 37 |{% blocktrans trimmed %}We are calling your phone right now, please enter the 38 | digits you hear.{% endblocktrans %}
39 | {% elif device.method == 'sms' %} 40 |{% blocktrans trimmed %}We sent you a text message, please enter the tokens we 41 | sent.{% endblocktrans %}
42 | {% endif %} 43 | {% else %} 44 |{% blocktrans trimmed %}We've 45 | encountered an issue with the selected authentication method. Please 46 | go back and verify that you entered your information correctly, try 47 | again, or use a different authentication method instead. If the issue 48 | persists, contact the site administrator.{% endblocktrans %}
49 | {% endif %} 50 | {% elif wizard.steps.current == 'yubikey' %} 51 |{% blocktrans trimmed %}To identify and verify your YubiKey, please insert a 52 | token in the field below. Your YubiKey will be linked to your 53 | account.{% endblocktrans %}
54 | {% endif %} 55 | 56 | 64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /example/templates/_base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 |django-two-factor-auth. Use this "
73 | "example to get an understanding of what the app can do for you."
74 | msgstr ""
75 |
76 | #: templates/home.html:14
77 | msgid "Please verify your settings"
78 | msgstr ""
79 |
80 | #: templates/home.html:15
81 | msgid ""
82 | "Have you provided your Twilio settings in the settings_private.py"
83 | "code> file? By doing so, the example app will be able to call and text you "
84 | "to verify your authentication tokens. Otherwise, the tokens will be shown on "
85 | "the screen."
86 | msgstr ""
87 |
88 | #: templates/home.html:26
89 | msgid "Next steps:"
90 | msgstr ""
91 |
92 | #: templates/home.html:28
93 | #, python-format
94 | msgid "Start by registering an account."
95 | msgstr ""
96 |
97 | #: templates/home.html:30
98 | #, python-format
99 | msgid "Login to your account."
100 | msgstr ""
101 |
102 | #: templates/home.html:32
103 | #, python-format
104 | msgid "Enable two-factor authentication."
105 | msgstr ""
106 |
107 | #: templates/home.html:34
108 | #, python-format
109 | msgid ""
110 | "Then, logout and login once more to your account to see two-factor authentication at "
112 | "work."
113 | msgstr ""
114 |
115 | #: templates/home.html:37
116 | #, python-format
117 | msgid ""
118 | "At last, you've gained access to the secret page"
119 | "a>! :-)"
120 | msgstr ""
121 |
122 | #: templates/registration.html:5
123 | msgid "Registration"
124 | msgstr ""
125 |
126 | #: templates/registration.html:10
127 | msgid "Register"
128 | msgstr ""
129 |
130 | #: templates/registration/logged_out.html:5
131 | msgid "Logged Out"
132 | msgstr ""
133 |
134 | #: templates/registration/logged_out.html:6
135 | msgid "See you around!"
136 | msgstr ""
137 |
138 | #: templates/registration_complete.html:5
139 | msgid "Registration Complete"
140 | msgstr ""
141 |
142 | #: templates/registration_complete.html:6
143 | msgid "Congratulations, you've successfully registered an account."
144 | msgstr ""
145 |
146 | #: templates/secret.html:9
147 | msgid ""
148 | "Congratulations, you've made it. You have successfully enabled two-factor "
149 | "authentication and logged in with your token."
150 | msgstr ""
151 |
--------------------------------------------------------------------------------