├── .codecov.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── .tx └── config ├── CHANGELOG.md ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── Makefile ├── class-reference.rst ├── conf.py ├── configuration.rst ├── extensions │ ├── __init__.py │ └── settings.py ├── implementing.rst ├── index.rst ├── installation.rst ├── management-commands.rst └── requirements.rst ├── example ├── __init__.py ├── gateways.py ├── locale │ ├── ar │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── as │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ca_ES │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── cs │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── da_DK │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── en_GB │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fa │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fi │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ha │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── he_IL │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── hi_IN │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── hu_HU │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── it │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ja │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── lt │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── nb │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── nl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ro │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ru │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── sv │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── tr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── vi │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── zh_CN │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── manage.py ├── settings.py ├── settings_private.py.dist ├── settings_webauthn.py ├── templates │ ├── _base.html │ ├── _messages.html │ ├── home.html │ ├── registration.html │ ├── registration │ │ └── logged_out.html │ ├── registration_complete.html │ ├── secret.html │ ├── two_factor │ │ ├── _base.html │ │ ├── _base_focus.html │ │ └── _wizard_forms.html │ └── user_sessions │ │ └── _base.html ├── urls.py └── views.py ├── pyproject.toml ├── requirements_dev.txt ├── requirements_e2e.txt ├── tests ├── __init__.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── templates │ └── secure.html ├── test_admin.py ├── test_commands.py ├── test_email.py ├── test_forms.py ├── test_gateways.py ├── test_registry.py ├── test_totpdeviceform.py ├── test_utils.py ├── test_validators.py ├── test_views_backuptokens.py ├── test_views_disable.py ├── test_views_login.py ├── test_views_mixins.py ├── test_views_phone.py ├── test_views_profile.py ├── test_views_qrcode.py ├── test_views_setup.py ├── test_yubikey.py ├── urls.py ├── urls_admin.py ├── urls_otp_admin.py ├── utils.py └── views.py ├── tox.ini └── two_factor ├── __init__.py ├── admin.py ├── apps.py ├── forms.py ├── gateways ├── __init__.py ├── fake.py └── twilio │ ├── __init__.py │ ├── gateway.py │ ├── urls.py │ └── views.py ├── locale ├── ar │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── as │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── ca_ES │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── cs │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── da_DK │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── de │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── en │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── en_GB │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── es │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── fa │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── fi │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── fr │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── ha │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── he_IL │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── hi_IN │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── hu_HU │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── it │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── ja │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── lt │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── nb │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── nl │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── pl │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── pt_BR │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── ro │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── ru │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── sv │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── tr │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── vi │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po └── zh_CN │ └── LC_MESSAGES │ ├── django.mo │ └── django.po ├── management ├── __init__.py └── commands │ ├── __init__.py │ ├── two_factor_disable.py │ └── two_factor_status.py ├── middleware ├── __init__.py └── threadlocals.py ├── migrations ├── 0001_initial.py ├── 0001_squashed_0008_delete_phonedevice.py ├── 0002_auto_20150110_0810.py ├── 0003_auto_20150817_1733.py ├── 0004_auto_20160205_1827.py ├── 0005_auto_20160224_0450.py ├── 0006_phonedevice_key_default.py ├── 0007_auto_20201201_1019.py ├── 0008_delete_phonedevice.py └── __init__.py ├── plugins ├── __init__.py ├── email │ ├── __init__.py │ ├── apps.py │ ├── forms.py │ ├── method.py │ └── utils.py ├── phonenumber │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── method.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0001_squashed_0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── templatetags │ │ ├── __init__.py │ │ └── phonenumber.py │ ├── tests │ │ ├── __init__.py │ │ └── test_method.py │ ├── urls.py │ ├── utils.py │ ├── validators.py │ └── views.py ├── registry.py ├── webauthn │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── method.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_webauthndevice_public_key.py │ │ └── __init__.py │ ├── models.py │ ├── static │ │ └── two_factor │ │ │ └── js │ │ │ └── webauthn_utils.js │ ├── templates │ │ └── two_factor_webauthn │ │ │ ├── create_credential.js │ │ │ └── get_credential.js │ ├── tests │ │ ├── __init__.py │ │ ├── test_e2e.py │ │ ├── test_forms.py │ │ ├── test_utils.py │ │ └── test_views_setup.py │ ├── urls.py │ ├── utils.py │ └── views.py └── yubikey │ ├── __init__.py │ ├── apps.py │ ├── forms.py │ └── method.py ├── signals.py ├── templates └── two_factor │ ├── _base.html │ ├── _base_focus.html │ ├── _wizard_actions.html │ ├── _wizard_forms.html │ ├── core │ ├── backup_tokens.html │ ├── login.html │ ├── otp_required.html │ ├── phone_register.html │ ├── setup.html │ └── setup_complete.html │ ├── profile │ ├── disable.html │ └── profile.html │ └── twilio │ ├── press_a_key.xml │ ├── sms_message.html │ └── token.xml ├── templatetags ├── __init__.py └── two_factor_tags.py ├── urls.py ├── utils.py └── views ├── __init__.py ├── core.py ├── mixins.py ├── profile.py └── utils.py /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for django-two-factor-auth 4 | title: "FEATURE REQUEST: Short description of requested feature" 5 | labels: "enhancement" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | example/database.sqlite 3 | example/settings_private.py 4 | *.egg-info 5 | /.coverage.* 6 | /.coverage 7 | /.tox/ 8 | /htmlcov/ 9 | /docs/_build/ 10 | /dist/ 11 | .eggs/ 12 | 13 | .idea/ 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/isort 3 | rev: 6.0.1 4 | hooks: 5 | - id: isort 6 | args: ['--check-only', '--diff'] 7 | 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.11.4 10 | hooks: 11 | - id: ruff 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 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [o:Bouke:p:django-two-factor-auth:r:example] 5 | file_filter = example/locale//LC_MESSAGES/django.po 6 | source_file = example/locale/en/LC_MESSAGES/django.po 7 | source_lang = en 8 | 9 | [o:Bouke:p:django-two-factor-auth:r:two_factor] 10 | file_filter = two_factor/locale//LC_MESSAGES/django.po 11 | source_file = two_factor/locale/en/LC_MESSAGES/django.po 12 | source_lang = en 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://jazzband.co/static/img/jazzband.svg 2 | :target: https://jazzband.co/ 3 | :alt: Jazzband 4 | 5 | This is a `Jazzband `_ project. By contributing you agree 6 | to abide by the `Contributor Code of Conduct `_ 7 | and follow the `guidelines `_. 8 | 9 | Contribute 10 | ========== 11 | 12 | * Submit issues to the `issue tracker`_ on Github. 13 | * Fork the `source code`_ at Github. 14 | * Write some code and make sure it is covered with unit tests. 15 | * Send a pull request with your changes. 16 | * Provide a translation using Transifex_. 17 | 18 | Local installation 19 | ------------------ 20 | 21 | Install the development dependencies, which also installs the package in editable mode 22 | for local development and additional development tools. 23 | 24 | .. code-block:: console 25 | 26 | pip install -r requirements_dev.txt 27 | 28 | Running tests 29 | ------------- 30 | This project aims for full code-coverage, this means that your code should be 31 | well-tested. Also test branches for hardened code. You can run the full test 32 | suite with:: 33 | 34 | make test 35 | 36 | Or run a specific test with:: 37 | 38 | make test TARGET=tests.tests.TwilioGatewayTest 39 | 40 | For Python compatibility, tox_ is used. You can run the full test suite, 41 | covering all supported Python and Django version with:: 42 | 43 | tox 44 | 45 | Releasing 46 | --------- 47 | The following actions are required to push a new version: 48 | 49 | * Update release notes and version number in `pyproject.toml` and `docs/conf.py` 50 | 51 | * If any new translations strings were added, push the new source language to 52 | Transifex_. Make sure translators have sufficient time to translate those 53 | new strings:: 54 | 55 | make tx-push 56 | 57 | * Add migrations:: 58 | 59 | python example/manage.py makemigrations two_factor 60 | git commit two_factor/migrations -m "Added migrations" 61 | 62 | * Update translations:: 63 | 64 | make tx-pull 65 | 66 | * Trigger the packaging and upload:: 67 | 68 | git tag 69 | git push && git push --tags 70 | 71 | The `.github/workflows/release.yml` file should do the remaining work and 72 | publish the release to PyPi. 73 | 74 | .. _issue tracker: https://github.com/jazzband/django-two-factor-auth/issues 75 | .. _source code: https://github.com/jazzband/django-two-factor-auth 76 | .. _Transifex: https://explore.transifex.com/Bouke/django-two-factor-auth/ 77 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst LICENSE 2 | prune two_factor/locale 3 | recursive-include two_factor/locale * 4 | recursive-include two_factor/templates * 5 | recursive-include two_factor/plugins/*/static * 6 | recursive-include two_factor/plugins/*/templates * 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Django Two-Factor Authentication 3 | ================================ 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | 9 | .. image:: https://github.com/jazzband/django-two-factor-auth/workflows/build/badge.svg?branch=master 10 | :alt: Build Status 11 | :target: https://github.com/jazzband/django-two-factor-auth/actions 12 | 13 | .. image:: https://codecov.io/gh/jazzband/django-two-factor-auth/branch/master/graph/badge.svg 14 | :alt: Test Coverage 15 | :target: https://codecov.io/gh/jazzband/django-two-factor-auth 16 | 17 | .. image:: https://badge.fury.io/py/django-two-factor-auth.svg 18 | :alt: PyPI 19 | :target: https://pypi.python.org/pypi/django-two-factor-auth 20 | 21 | Complete Two-Factor Authentication for Django. Built on top of the one-time 22 | password framework django-otp_ and Django's built-in authentication framework 23 | ``django.contrib.auth`` for providing the easiest integration into most Django 24 | projects. Inspired by the user experience of Google's Two-Step Authentication, 25 | allowing users to authenticate through call, text messages (SMS), by using a 26 | token generator app like Google Authenticator or a YubiKey_ hardware token 27 | generator (optional). 28 | 29 | If you run into problems, please file an issue on GitHub, or contribute to the 30 | project by forking the repository and sending some pull requests. The package 31 | is translated into English, Dutch and other languages. Please contribute your 32 | own language using Transifex_. 33 | 34 | Test drive this app through the `example app`_. It demos most features except 35 | the Twilio integration. The example also includes django-user-sessions_ for 36 | providing Django sessions with a foreign key to the user. Although the package 37 | is optional, it improves account security control over 38 | ``django.contrib.sessions``. 39 | 40 | Compatible with supported Django and Python versions. At the moment of writing 41 | that includes 4.2, 5.0, and 5.1 on Python 3.8 to 3.12. 42 | Documentation is available at `readthedocs.io`_. 43 | 44 | 45 | Installation 46 | ============ 47 | Refer to the `installation instructions`_ in the documentation. 48 | 49 | 50 | Getting help 51 | ============ 52 | 53 | For general questions regarding this package, please hop over to `Stack Overflow`_. 54 | If you think there is an issue with this package; check if the 55 | issue is already listed (either open or closed), and file an issue if 56 | it's not. 57 | 58 | 59 | Contribute 60 | ========== 61 | Read the `contribution guidelines`_. 62 | 63 | 64 | See Also 65 | ======== 66 | Have a look at django-user-sessions_ for Django sessions with a foreign key to 67 | the user. This package is also included in the `example app`_. 68 | 69 | 70 | License 71 | ======= 72 | The project is licensed under the MIT license. 73 | 74 | .. _`example app`: 75 | https://github.com/jazzband/django-two-factor-auth/tree/master/example 76 | .. _django-otp: https://pypi.org/project/django-otp/ 77 | .. _Transifex: https://explore.transifex.com/Bouke/django-two-factor-auth/ 78 | .. _Twilio: https://www.twilio.com/ 79 | .. _contribution guidelines: 80 | https://github.com/jazzband/django-two-factor-auth/blob/master/CONTRIBUTING.rst 81 | .. _django-user-sessions: https://pypi.org/project/django-user-sessions/ 82 | .. _readthedocs.io: https://django-two-factor-auth.readthedocs.io/en/stable/index.html 83 | .. _`installation instructions`: 84 | https://django-two-factor-auth.readthedocs.io/en/stable/installation.html 85 | .. _`Stack Overflow`: 86 | https://stackoverflow.com/questions/tagged/django-two-factor-auth 87 | .. _Yubikey: https://www.yubico.com/products/yubikey-hardware/ 88 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= -W 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | apidocs: 16 | cd ..; sphinx-apidoc -f -e -o docs/ django_sendfile "*tests*" 17 | 18 | .PHONY: help apidocs Makefile 19 | 20 | # Catch-all target: route all unknown targets to Sphinx using the new 21 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 22 | %: Makefile 23 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/docs/extensions/__init__.py -------------------------------------------------------------------------------- /docs/extensions/settings.py: -------------------------------------------------------------------------------- 1 | def setup(app): 2 | app.add_crossref_type( 3 | directivename="setting", 4 | rolename="setting", 5 | indextemplate="pair: %s; setting", 6 | ) 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /example/locale/ar/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/ar/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/as/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/as/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ca_ES/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/ca_ES/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/da_DK/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/da_DK/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # django-two-factor-auth example translation. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the django-two-factor-auth package. 4 | # 5 | # Translators: 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-two-factor-auth\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-05-15 22:55+0200\n" 11 | "PO-Revision-Date: 2013-11-20 09:58+0000\n" 12 | "Last-Translator: Bouke Haarsma \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: en\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: gateways.py:11 21 | #, python-format 22 | msgid "Fake call to %(number)s: \"Your token is: %(token)s\"" 23 | msgstr "" 24 | 25 | #: gateways.py:16 26 | #, python-format 27 | msgid "Fake SMS to %(number)s: \"Your token is: %(token)s\"" 28 | msgstr "" 29 | 30 | #: templates/_base.html:15 templates/two_factor/_base_focus.html:7 31 | msgid "Demo" 32 | msgstr "" 33 | 34 | #: templates/_base.html:26 35 | msgid "Home" 36 | msgstr "" 37 | 38 | #: templates/_base.html:29 templates/secret.html:7 39 | msgid "Secret Page" 40 | msgstr "" 41 | 42 | #: templates/_base.html:39 43 | msgid "Account Security" 44 | msgstr "" 45 | 46 | #: templates/_base.html:41 47 | msgid "Sessions" 48 | msgstr "" 49 | 50 | #: templates/_base.html:43 51 | msgid "Logout" 52 | msgstr "" 53 | 54 | #: templates/_base.html:47 55 | msgid "Not logged in" 56 | msgstr "" 57 | 58 | #: templates/_base.html:50 templates/registration_complete.html:9 59 | msgid "Login" 60 | msgstr "" 61 | 62 | #: templates/home.html:4 63 | msgid "django-two-factor-auth – Demo Application" 64 | msgstr "" 65 | 66 | #: templates/home.html:8 67 | msgid "Improve Your Security your users will thank you" 68 | msgstr "" 69 | 70 | #: templates/home.html:10 71 | msgid "" 72 | "Welcome to the example app of 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 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! :-)" 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 | -------------------------------------------------------------------------------- /example/locale/en_GB/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/en_GB/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/fa/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/fa/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/fi/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/fi/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ha/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/ha/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/he_IL/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/hu_HU/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/it/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ja/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/ja/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/lt/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/lt/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/nb/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/nb/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/nl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/nl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ro/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/ro/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/sv/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/sv/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/tr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/tr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/vi/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/vi/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/locale/zh_CN/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/example/locale/zh_CN/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | # Set this directory's root on the path 6 | sys.path.append(os.path.dirname(os.path.abspath(os.path.dirname(__file__)))) 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 10 | 11 | from django.core.management import execute_from_command_line 12 | 13 | execute_from_command_line(sys.argv) 14 | -------------------------------------------------------------------------------- /example/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = True 4 | 5 | PROJECT_PATH = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': os.path.join(PROJECT_PATH, 'database.sqlite'), 11 | } 12 | } 13 | 14 | STATIC_URL = '/static/' 15 | 16 | AUTHENTICATION_BACKENDS = ( 17 | 'django.contrib.auth.backends.ModelBackend', 18 | ) 19 | 20 | TIME_ZONE = 'Europe/Amsterdam' 21 | 22 | # Make this unique, and don't share it with anybody. 23 | SECRET_KEY = 'DO NOT USE THIS KEY!' 24 | 25 | MIDDLEWARE = ( 26 | 'django.middleware.common.CommonMiddleware', 27 | 'user_sessions.middleware.SessionMiddleware', 28 | 'django.middleware.csrf.CsrfViewMiddleware', 29 | 'django.middleware.locale.LocaleMiddleware', 30 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 31 | 'django.contrib.messages.middleware.MessageMiddleware', 32 | 'django_otp.middleware.OTPMiddleware', 33 | 'debug_toolbar.middleware.DebugToolbarMiddleware', 34 | 'two_factor.middleware.threadlocals.ThreadLocals', 35 | ) 36 | 37 | ROOT_URLCONF = 'example.urls' 38 | 39 | TEMPLATES = [ 40 | { 41 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 42 | 'DIRS': [os.path.join(PROJECT_PATH, 'templates')], 43 | 'APP_DIRS': True, 44 | 'OPTIONS': { 45 | 'context_processors': [ 46 | 'django.template.context_processors.debug', 47 | 'django.template.context_processors.request', 48 | 'django.contrib.auth.context_processors.auth', 49 | 'django.contrib.messages.context_processors.messages', 50 | ], 51 | }, 52 | }, 53 | ] 54 | 55 | INSTALLED_APPS = [ 56 | 'django.contrib.auth', 57 | 'django.contrib.contenttypes', 58 | 'user_sessions', 59 | 'django.contrib.messages', 60 | 'django.contrib.staticfiles', 61 | 'django.contrib.admin', 62 | 'django_otp', 63 | 'django_otp.plugins.otp_static', 64 | 'django_otp.plugins.otp_totp', 65 | 'django_otp.plugins.otp_email', 66 | 'two_factor', 67 | 'two_factor.plugins.phonenumber', 68 | 'two_factor.plugins.email', 69 | 'example', 70 | 71 | 'debug_toolbar', 72 | 'bootstrapform' 73 | ] 74 | 75 | 76 | LOGOUT_REDIRECT_URL = 'home' 77 | LOGIN_URL = 'two_factor:login' 78 | LOGIN_REDIRECT_URL = 'two_factor:profile' 79 | 80 | INTERNAL_IPS = ('127.0.0.1',) 81 | 82 | LOGGING = { 83 | 'version': 1, 84 | 'disable_existing_loggers': False, 85 | 'handlers': { 86 | 'console': { 87 | 'level': 'DEBUG', 88 | 'class': 'logging.StreamHandler', 89 | }, 90 | }, 91 | 'loggers': { 92 | 'two_factor': { 93 | 'handlers': ['console'], 94 | 'level': 'INFO', 95 | } 96 | } 97 | } 98 | 99 | TWO_FACTOR_CALL_GATEWAY = 'example.gateways.Messages' 100 | TWO_FACTOR_SMS_GATEWAY = 'example.gateways.Messages' 101 | PHONENUMBER_DEFAULT_REGION = 'NL' 102 | 103 | TWO_FACTOR_REMEMBER_COOKIE_AGE = 120 # Set to 2 minute for testing 104 | 105 | TWO_FACTOR_PHONE_THROTTLE_FACTOR = 10 106 | OTP_TOTP_THROTTLE_FACTOR = 10 107 | 108 | TWO_FACTOR_WEBAUTHN_RP_NAME = 'Demo Application' 109 | 110 | SESSION_ENGINE = 'user_sessions.backends.db' 111 | 112 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 113 | DEFAULT_FROM_EMAIL = 'webmaster@example.org' 114 | 115 | SILENCED_SYSTEM_CHECKS = ['admin.E410'] 116 | 117 | try: 118 | from .settings_private import * # noqa 119 | except ImportError: 120 | pass 121 | -------------------------------------------------------------------------------- /example/settings_private.py.dist: -------------------------------------------------------------------------------- 1 | TWO_FACTOR_SMS_GATEWAY = 'two_factor.gateways.twilio.gateway.Twilio' 2 | TWO_FACTOR_CALL_GATEWAY = 'two_factor.gateways.twilio.gateway.Twilio' 3 | TWILIO_ACCOUNT_SID = '' 4 | TWILIO_AUTH_TOKEN = '' 5 | TWILIO_CALLER_ID = '' 6 | -------------------------------------------------------------------------------- /example/settings_webauthn.py: -------------------------------------------------------------------------------- 1 | from .settings import * # noqa: F403 2 | 3 | INSTALLED_APPS.extend(['two_factor.plugins.webauthn']) # noqa: F405 4 | -------------------------------------------------------------------------------- /example/templates/_base.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | {% block title %}{% endblock %} 6 | 7 | 8 | 9 | 10 | {% block extra_media %}{% endblock %} 11 | 12 | 13 | {% block content_wrapper %} 14 | 63 | 64 |
65 | {% include "_messages.html" %} 66 | {% block content %}{% endblock %} 67 |
68 | 69 | {% endblock %} 70 | 71 | 72 | -------------------------------------------------------------------------------- /example/templates/_messages.html: -------------------------------------------------------------------------------- 1 | {% if messages %} 2 | {% for message in messages %} 3 |
{{ message }}
4 | {% endfor %} 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /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 %}Improve Your Security your users will thank 9 | you{% endblocktrans %}

10 |

{% 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 %}

13 | 14 |

{% trans "Please verify your settings" %}

15 |

{% 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 %}

19 | 20 | {% url 'registration' as reg_url %} 21 | {% url 'two_factor:login' as login_url %} 22 | {% url 'two_factor:setup' as setup_url %} 23 | {% url 'logout' as logout_url %} 24 | {% url 'secret' as secret_url %} 25 | 26 |

{% trans "Next steps:" %}

27 |
    28 |
  1. {% blocktrans trimmed %}Start by registering an 29 | account.{% endblocktrans %}
  2. 30 |
  3. {% blocktrans trimmed %}Login to your account. 31 | {% endblocktrans %}
  4. 32 |
  5. {% blocktrans trimmed %}Enable two-factor 33 | authentication.{% endblocktrans %}
  6. 34 |
  7. {% blocktrans trimmed %}Then, logout and 35 | login once more to your account to see 36 | two-factor authentication at work.{% endblocktrans %}
  8. 37 |
  9. {% blocktrans trimmed %}At last, you've gained access to the 38 | secret page! :-){% endblocktrans %}
  10. 39 |
40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /example/templates/registration.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% block title %}{% trans "Registration" %}{% endblock %}

6 | 7 |
8 | {% csrf_token %} 9 | {{ form.as_table }}
10 | 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /example/templates/registration/logged_out.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% block title %}{% trans "Logged Out" %}{% endblock %}

6 |

{% trans "See you around!" %}

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /example/templates/registration_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% block title %}{% trans "Registration Complete" %}{% endblock %}

6 |

{% blocktrans trimmed %}Congratulations, you've successfully registered an 7 | account.{% endblocktrans %}

8 |

{% trans "Login" %}

10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /example/templates/secret.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block nav_secret %}active{% endblock %} 5 | 6 | {% block content %} 7 |

{% block title %}{% trans "Secret Page" %}{% endblock %}

8 | 9 |

{% 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 | -------------------------------------------------------------------------------- /example/templates/two_factor/_base.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% block nav_profile %}active{% endblock %} 3 | -------------------------------------------------------------------------------- /example/templates/two_factor/_base_focus.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content_wrapper %} 5 | 10 | 11 |
12 |
13 |
14 | {% include "_messages.html" %} 15 | {% block content %}{% endblock %} 16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /example/templates/two_factor/_wizard_forms.html: -------------------------------------------------------------------------------- 1 | {% load bootstrap %} 2 | {{ wizard.management_form }} 3 |
4 | {{ wizard.form|bootstrap_horizontal:'col-sm-3 col-md-3' }} 5 |
6 | -------------------------------------------------------------------------------- /example/templates/user_sessions/_base.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" %} 2 | {% block nav_sessions %}active{% endblock %} 3 | 4 | {% block content_wrapper %} 5 |

The session list is 6 | powered by django-user-sessions to list the location, 7 | browser and IP-address.

8 | {{ block.super }} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-two-factor-auth" 7 | version = "1.17.0" 8 | description = "Complete Two-Factor Authentication for Django" 9 | readme = "README.rst" 10 | authors = [ 11 | {name = "Bouke Haarsma", email = "bouke@haarsma.eu"}, 12 | ] 13 | maintainers = [ 14 | {name = "Claude Paroz", email = "claude@2xlibre.net"}, 15 | {name = "Matt Molyneaux", email = "moggers87+git@moggers87.co.uk"}, 16 | ] 17 | license = {text = "MIT"} 18 | requires-python = ">= 3.9" 19 | dependencies = [ 20 | "Django>=4.2", 21 | "django_otp>=0.8.0", 22 | "qrcode>=4.0.0,<7.99", 23 | "django-phonenumber-field<9", 24 | "django-formtools", 25 | ] 26 | keywords = ["django", "two-factor"] 27 | classifiers = [ 28 | "Development Status :: 5 - Production/Stable", 29 | "Environment :: Web Environment", 30 | "Framework :: Django", 31 | "Framework :: Django :: 4.2", 32 | "Framework :: Django :: 5.0", 33 | "Framework :: Django :: 5.1", 34 | "Framework :: Django :: 5.2", 35 | "Intended Audience :: Developers", 36 | "License :: OSI Approved :: MIT License", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python", 39 | "Programming Language :: Python :: 3", 40 | "Programming Language :: Python :: 3 :: Only", 41 | "Programming Language :: Python :: 3.9", 42 | "Programming Language :: Python :: 3.10", 43 | "Programming Language :: Python :: 3.11", 44 | "Programming Language :: Python :: 3.12", 45 | "Programming Language :: Python :: 3.13", 46 | "Topic :: Security", 47 | "Topic :: System :: Systems Administration :: Authentication/Directory", 48 | ] 49 | 50 | [project.optional-dependencies] 51 | call = ['twilio>=6.0'] 52 | sms = ['twilio>=6.0'] 53 | webauthn = ['webauthn>=2.0,<2.99'] 54 | yubikey = ['django-otp-yubikey'] 55 | phonenumbers = ['phonenumbers>=7.0.9,<8.99'] 56 | phonenumberslite = ['phonenumberslite>=7.0.9,<8.99'] 57 | # used internally for local development & CI 58 | tests = [ 59 | "coverage", 60 | "freezegun", 61 | "tox", 62 | ] 63 | linting = [ 64 | "ruff", 65 | "isort<=5.99", 66 | ] 67 | docs = [ 68 | "sphinx", 69 | "sphinx_rtd_theme", 70 | "django-two-factor-auth[call]", 71 | "django-two-factor-auth[webauthn]", 72 | "django-two-factor-auth[yubikey]", 73 | "django-two-factor-auth[phonenumberslite]", 74 | ] 75 | 76 | [project.urls] 77 | Homepage = "https://github.com/jazzband/django-two-factor-auth" 78 | Documentation = "https://django-two-factor-auth.readthedocs.io/en/stable/" 79 | Changelog = "https://github.com/jazzband/django-two-factor-auth/blob/master/CHANGELOG.md" 80 | 81 | [tool.ruff] 82 | line-length = 119 83 | target-version = "py39" 84 | extend-exclude = ["docs"] 85 | 86 | [tool.ruff.lint] 87 | select = [ 88 | "F", # Pyflakes 89 | "E", # pycodestyle (Error) 90 | "W", # pycodestyle (Warning) 91 | # "I", # isort (waiting for https://github.com/astral-sh/ruff/issues/2600) 92 | ] 93 | 94 | # [tool.ruff.lint.isort] 95 | # combine-as-imports = true 96 | # known-first-party = ["two_factor"] 97 | 98 | [tool.isort] 99 | combine_as_imports = true 100 | default_section = "THIRDPARTY" 101 | include_trailing_comma = true 102 | known_first_party = "two_factor" 103 | line_length = 79 104 | multi_line_output = 5 105 | sections="FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 106 | 107 | [tool.coverage.run] 108 | branch = true 109 | source = [ 110 | "tests", 111 | "two_factor", 112 | ] 113 | omit = ["*/migrations/*"] 114 | 115 | [tool.coverage.report] 116 | exclude_also = [ 117 | # Don't complain about missing debug-only code: 118 | "def __repr__", 119 | # Don't complain if tests don't hit defensive assertion code: 120 | "raise AssertionError", 121 | "raise NotImplementedError", 122 | ] 123 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | # The app itself 2 | 3 | -e .[call,sms,webauthn,yubikey,phonenumberslite,tests,linting] 4 | 5 | # Example app 6 | 7 | django-debug-toolbar 8 | django-bootstrap-form 9 | django-user-sessions 10 | 11 | # Documentation 12 | 13 | Sphinx 14 | sphinx_rtd_theme 15 | 16 | # Build 17 | 18 | wheel 19 | twine 20 | -------------------------------------------------------------------------------- /requirements_e2e.txt: -------------------------------------------------------------------------------- 1 | # test with selenium 2 | selenium~=4.30.0 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/tests/__init__.py -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-07 16:07 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | import tests.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('auth', '0001_initial'), 15 | ] 16 | 17 | if settings.AUTH_USER_MODEL == "tests.User": 18 | operations = [ 19 | migrations.CreateModel( 20 | name='User', 21 | fields=[ 22 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 23 | ('password', models.CharField(max_length=128, verbose_name='password')), 24 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 25 | ('is_superuser', models.BooleanField( 26 | default=False, 27 | help_text='Designates that this user has all permissions without explicitly assigning them.', 28 | verbose_name='superuser status' 29 | )), 30 | ('email', models.EmailField(blank=True, max_length=254, unique=True)), 31 | ('is_staff', models.BooleanField(default=False)), 32 | ('groups', models.ManyToManyField( 33 | blank=True, 34 | help_text='The groups this user belongs to. A user will get all permissions ' 35 | 'granted to each of their groups.', 36 | related_name='user_set', 37 | related_query_name='user', 38 | to='auth.Group', 39 | verbose_name='groups' 40 | )), 41 | ('user_permissions', models.ManyToManyField( 42 | blank=True, 43 | help_text='Specific permissions for this user.', 44 | related_name='user_set', 45 | related_query_name='user', 46 | to='auth.Permission', 47 | verbose_name='user permissions' 48 | )), 49 | ], 50 | options={ 51 | 'abstract': False, 52 | }, 53 | managers=[ 54 | ('objects', tests.models.UserManager()), 55 | ], 56 | ), 57 | ] 58 | else: 59 | operations = [] 60 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/tests/migrations/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/templates/secure.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/tests/templates/secure.html -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import StringIO 3 | 4 | from django.core.management import CommandError, call_command 5 | from django.test import TestCase 6 | from django_otp import devices_for_user 7 | 8 | from .utils import UserMixin 9 | 10 | 11 | class DisableCommandTest(UserMixin, TestCase): 12 | def _assert_raises(self, err_type, err_message): 13 | return self.assertRaisesMessage(err_type, err_message) 14 | 15 | def test_raises(self): 16 | stdout = StringIO() 17 | stderr = StringIO() 18 | with self._assert_raises(CommandError, 'User "some_username" does not exist'): 19 | call_command( 20 | 'two_factor_disable', 'some_username', 21 | stdout=stdout, stderr=stderr) 22 | 23 | with self._assert_raises(CommandError, 'User "other_username" does not exist'): 24 | call_command( 25 | 'two_factor_disable', 'other_username', 'some_username', 26 | stdout=stdout, stderr=stderr) 27 | 28 | def test_disable_single(self): 29 | user = self.create_user() 30 | self.enable_otp(user) 31 | call_command('two_factor_disable', 'bouke@example.com') 32 | self.assertEqual(list(devices_for_user(user)), []) 33 | 34 | def test_happy_flow_multiple(self): 35 | usernames = ['user%d@example.com' % i for i in range(0, 3)] 36 | users = [self.create_user(username) for username in usernames] 37 | [self.enable_otp(user) for user in users] 38 | call_command('two_factor_disable', *usernames[:2]) 39 | self.assertEqual(list(devices_for_user(users[0])), []) 40 | self.assertEqual(list(devices_for_user(users[1])), []) 41 | self.assertNotEqual(list(devices_for_user(users[2])), []) 42 | 43 | 44 | class StatusCommandTest(UserMixin, TestCase): 45 | def _assert_raises(self, err_type, err_message): 46 | return self.assertRaisesMessage(err_type, err_message) 47 | 48 | def setUp(self): 49 | super().setUp() 50 | os.environ['DJANGO_COLORS'] = 'nocolor' 51 | 52 | def test_raises(self): 53 | stdout = StringIO() 54 | stderr = StringIO() 55 | with self._assert_raises(CommandError, 'User "some_username" does not exist'): 56 | call_command( 57 | 'two_factor_status', 'some_username', 58 | stdout=stdout, stderr=stderr) 59 | 60 | with self._assert_raises(CommandError, 'User "other_username" does not exist'): 61 | call_command( 62 | 'two_factor_status', 'other_username', 'some_username', 63 | stdout=stdout, stderr=stderr) 64 | 65 | def test_status_single(self): 66 | user = self.create_user() 67 | stdout = StringIO() 68 | call_command('two_factor_status', 'bouke@example.com', stdout=stdout) 69 | self.assertEqual(stdout.getvalue(), 'bouke@example.com: disabled\n') 70 | 71 | stdout = StringIO() 72 | self.enable_otp(user) 73 | call_command('two_factor_status', 'bouke@example.com', stdout=stdout) 74 | self.assertEqual(stdout.getvalue(), 'bouke@example.com: enabled\n') 75 | 76 | def test_status_mutiple(self): 77 | users = [self.create_user(n) for n in ['user0@example.com', 'user1@example.com']] 78 | self.enable_otp(users[0]) 79 | stdout = StringIO() 80 | call_command('two_factor_status', 'user0@example.com', 'user1@example.com', stdout=stdout) 81 | self.assertEqual(stdout.getvalue(), 'user0@example.com: enabled\n' 82 | 'user1@example.com: disabled\n') 83 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from two_factor.forms import AuthenticationTokenForm 4 | 5 | 6 | class FormTests(TestCase): 7 | def test_auth_token_form(self): 8 | form = AuthenticationTokenForm(None, None, data={'otp_token': '005428'}) 9 | self.assertTrue(form.is_valid()) 10 | self.assertEqual(form.cleaned_data['otp_token'], '005428') 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/test_totpdeviceform.py: -------------------------------------------------------------------------------- 1 | from binascii import unhexlify 2 | from unittest.mock import patch 3 | 4 | import django_otp.oath 5 | from django.test import TestCase 6 | 7 | from two_factor.forms import TOTPDeviceForm 8 | 9 | # Use this as the Unix time for all TOTPs. It is chosen arbitrarily 10 | # as 3 Jan 2022 11 | TEST_TIME = 1641194517 12 | 13 | 14 | @patch('django_otp.oath.time', return_value=TEST_TIME) 15 | class TOTPDeviceFormTest(TestCase): 16 | """ 17 | This class tests how the TOTPDeviceForm validator handles drift between its clock 18 | and the TOTP device. 19 | 20 | If there is a drift in the range [-tolerance, +tolerance], (tolerance is hardcoded 21 | to 1), then the TOTP should be accepted. Outside this range, it should be rejected. 22 | """ 23 | 24 | key = '12345678901234567890' 25 | 26 | def setUp(self): 27 | super().setUp() 28 | self.bin_key = unhexlify(TOTPDeviceFormTest.key.encode()) 29 | self.empty_form = TOTPDeviceForm(TOTPDeviceFormTest.key, None) 30 | 31 | def totp_with_offset(self, offset): 32 | return django_otp.oath.totp( 33 | self.bin_key, self.empty_form.step, 34 | self.empty_form.t0, self.empty_form.digits, self.empty_form.drift + offset 35 | ) 36 | 37 | def test_offset_0(self, mock_test): 38 | device_totp = self.totp_with_offset(0) 39 | form = TOTPDeviceForm(TOTPDeviceFormTest.key, None, data={'token': device_totp}) 40 | self.assertTrue(form.is_valid()) 41 | 42 | def test_offset_minus1(self, mock_test): 43 | device_totp = self.totp_with_offset(-1) 44 | form = TOTPDeviceForm(TOTPDeviceFormTest.key, None, data={'token': device_totp}) 45 | self.assertTrue(form.is_valid()) 46 | 47 | def test_offset_plus1(self, mock_test): 48 | device_totp = self.totp_with_offset(1) 49 | form = TOTPDeviceForm(TOTPDeviceFormTest.key, None, data={'token': device_totp}) 50 | self.assertTrue(form.is_valid()) 51 | 52 | def test_offset_minus2(self, mock_test): 53 | device_totp = self.totp_with_offset(-2) 54 | form = TOTPDeviceForm(TOTPDeviceFormTest.key, None, data={'token': device_totp}) 55 | self.assertFalse(form.is_valid()) 56 | self.assertEqual(form.errors['token'][0], TOTPDeviceForm.error_messages['invalid_token']) 57 | 58 | def test_offset_plus2(self, mock_test): 59 | device_totp = self.totp_with_offset(2) 60 | form = TOTPDeviceForm(TOTPDeviceFormTest.key, None, data={'token': device_totp}) 61 | self.assertFalse(form.is_valid()) 62 | self.assertEqual(form.errors['token'][0], TOTPDeviceForm.error_messages['invalid_token']) 63 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.assertTrue(response.context['available_phone_methods'] == []) 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_qrcode.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import qrcode.image.svg 4 | from django.test import RequestFactory, TestCase 5 | from django.urls import reverse 6 | 7 | from two_factor.utils import get_otpauth_url 8 | from two_factor.views.core import QRGeneratorView 9 | 10 | from .utils import UserMixin 11 | 12 | 13 | class CustomQRView(QRGeneratorView): 14 | def get_issuer(self): 15 | return "Custom Test Issuer" 16 | 17 | 18 | class QRTest(UserMixin, TestCase): 19 | test_secret = 'This is a test secret for an OTP Token' 20 | test_img = 'This is a test string that represents a QRCode' 21 | 22 | def setUp(self): 23 | super().setUp() 24 | self.user = self.create_user(username='ⓑỚ𝓾⒦ȩ') 25 | self.login_user() 26 | 27 | def test_without_secret(self): 28 | response = self.client.get(reverse('two_factor:qr')) 29 | self.assertEqual(response.status_code, 404) 30 | 31 | @mock.patch('qrcode.make') 32 | def test_with_secret(self, mockqrcode): 33 | # Setup the mock data 34 | def side_effect(resp): 35 | resp.write(self.test_img) 36 | mockimg = mock.Mock() 37 | mockimg.save.side_effect = side_effect 38 | mockqrcode.return_value = mockimg 39 | 40 | # Setup the session 41 | session = self.client.session 42 | session['django_two_factor-qr_secret_key'] = self.test_secret 43 | session.save() 44 | 45 | # Get default image factory 46 | default_factory = qrcode.image.svg.SvgPathImage 47 | 48 | # Get the QR code 49 | response = self.client.get(reverse('two_factor:qr')) 50 | 51 | # Check things went as expected 52 | mockqrcode.assert_called_with( 53 | get_otpauth_url(accountname=self.user.get_username(), 54 | secret=self.test_secret, issuer="testserver"), 55 | image_factory=default_factory) 56 | mockimg.save.assert_called_with(mock.ANY) 57 | self.assertEqual(response.status_code, 200) 58 | self.assertEqual(response.content.decode('utf-8'), self.test_img) 59 | self.assertEqual(response['Content-Type'], 'image/svg+xml; charset=utf-8') 60 | 61 | @mock.patch('qrcode.make') 62 | def test_custom_issuer(self, mockqrcode): 63 | # Setup the mock data 64 | def side_effect(resp): 65 | resp.write(self.test_img) 66 | mockimg = mock.Mock() 67 | mockimg.save.side_effect = side_effect 68 | mockqrcode.return_value = mockimg 69 | 70 | # Setup the session 71 | session = self.client.session 72 | session['django_two_factor-qr_secret_key'] = self.test_secret 73 | session.save() 74 | 75 | # Get default image factory 76 | default_factory = qrcode.image.svg.SvgPathImage 77 | 78 | # Get the QR code 79 | factory = RequestFactory() 80 | request = factory.get(reverse('two_factor:qr')) 81 | request.user = self.user 82 | request.session = session 83 | response = CustomQRView.as_view()(request) 84 | 85 | # Check things went as expected 86 | mockqrcode.assert_called_with( 87 | get_otpauth_url(accountname=self.user.get_username(), 88 | secret=self.test_secret, issuer="Custom Test Issuer"), 89 | image_factory=default_factory) 90 | mockimg.save.assert_called_with(mock.ANY) 91 | self.assertEqual(response.status_code, 200) 92 | self.assertEqual(response.content.decode('utf-8'), self.test_img) 93 | self.assertEqual(response['Content-Type'], 'image/svg+xml; charset=utf-8') 94 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.views import LogoutView 2 | from django.urls import include, path 3 | 4 | from two_factor.gateways.twilio.urls import urlpatterns as tf_twilio_urls 5 | from two_factor.urls import urlpatterns as tf_urls 6 | from two_factor.views import LoginView, SetupView 7 | 8 | from .views import SecureView, plain_view 9 | 10 | urlpatterns = [ 11 | path( 12 | 'account/login/', 13 | LoginView.as_view(), 14 | name='login', 15 | ), 16 | path( 17 | 'account/logout/', 18 | LogoutView.as_view(), 19 | name='logout', 20 | ), 21 | path( 22 | 'account/custom-field-name-login/', 23 | LoginView.as_view(redirect_field_name='next_page'), 24 | name='custom-field-name-login', 25 | ), 26 | path( 27 | 'account/custom-allowed-success-url-login/', 28 | LoginView.as_view( 29 | success_url_allowed_hosts={'test.allowed-success-url.com'} 30 | ), 31 | name='custom-allowed-success-url-login', 32 | ), 33 | path( 34 | 'account/custom-redirect-authenticated-user-login/', 35 | LoginView.as_view( 36 | redirect_authenticated_user=True 37 | ), 38 | name='custom-redirect-authenticated-user-login', 39 | ), 40 | path( 41 | 'account/setup-backup-tokens-redirect/', 42 | SetupView.as_view(success_url='two_factor:backup_tokens'), 43 | name='setup-backup_tokens-redirect' 44 | ), 45 | path( 46 | 'plain/', 47 | plain_view, 48 | name="plain", 49 | ), 50 | path( 51 | 'secure/', 52 | SecureView.as_view(), 53 | ), 54 | path( 55 | 'secure/raises/', 56 | SecureView.as_view(raise_anonymous=True, raise_unverified=True), 57 | ), 58 | path( 59 | 'secure/redirect_unverified/', 60 | SecureView.as_view(raise_anonymous=True, 61 | verification_url='/account/login/'), 62 | ), 63 | path('', include(tf_urls)), 64 | path('', include(tf_twilio_urls)), 65 | ] 66 | -------------------------------------------------------------------------------- /tests/urls_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from .urls import urlpatterns 5 | 6 | urlpatterns += [ 7 | path('admin/', admin.site.urls), 8 | ] 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import get_user_model 3 | from django.shortcuts import resolve_url 4 | from django.test.utils import TestContextDecorator 5 | from django_otp import DEVICE_ID_SESSION_KEY 6 | from django_otp.oath import totp 7 | 8 | from two_factor.plugins.registry import registry 9 | from two_factor.utils import default_device, totp_digits 10 | 11 | 12 | class UserMixin: 13 | @classmethod 14 | def setUpClass(cls): 15 | super().setUpClass() 16 | cls.login_url = resolve_url(settings.LOGIN_URL) 17 | cls.User = get_user_model() 18 | 19 | def setUp(self): 20 | super().setUp() 21 | self._passwords = {} 22 | 23 | def create_user(self, username='bouke@example.com', password='secret', **kwargs): 24 | user = self.User.objects.create_user(username=username, email=username, password=password, **kwargs) 25 | self._passwords[user] = password 26 | return user 27 | 28 | def create_superuser(self, username='bouke@example.com', password='secret', **kwargs): 29 | user = self.User.objects.create_superuser(username=username, email=username, password=password, **kwargs) 30 | self._passwords[user] = password 31 | return user 32 | 33 | def login_user(self): 34 | user = list(self._passwords.keys())[0] 35 | username = user.get_username() 36 | assert self.client.login(username=username, password=self._passwords[user]) 37 | if default_device(user): 38 | session = self.client.session 39 | session[DEVICE_ID_SESSION_KEY] = default_device(user).persistent_id 40 | session.save() 41 | 42 | def enable_otp(self, user=None): 43 | if user is None: 44 | user = list(self._passwords.keys())[0] 45 | return user.totpdevice_set.create(name='default') 46 | 47 | 48 | class method_registry(TestContextDecorator): 49 | def __init__(self, method_codes): 50 | self.codes = method_codes 51 | super().__init__() 52 | 53 | def enable(self): 54 | # We count on the fact that initially registry._methods is full (default test settings) 55 | self.old_methods = registry._methods 56 | registry._methods = [m for m in self.old_methods if m.code in self.codes] 57 | 58 | def disable(self): 59 | registry._methods = self.old_methods 60 | 61 | 62 | def totp_str(key): 63 | return str(totp(key)).zfill(totp_digits()) 64 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.views.generic import TemplateView 3 | 4 | from two_factor.views import OTPRequiredMixin 5 | 6 | 7 | class SecureView(OTPRequiredMixin, TemplateView): 8 | template_name = 'secure.html' 9 | 10 | 11 | def plain_view(request): 12 | """ Non-class based view """ 13 | return HttpResponse('plain') 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /two_factor/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /two_factor/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.admin import AdminSite 3 | from django.contrib.auth import REDIRECT_FIELD_NAME 4 | from django.contrib.auth.views import redirect_to_login 5 | from django.shortcuts import resolve_url 6 | from django.utils.http import url_has_allowed_host_and_scheme 7 | 8 | from .utils import monkeypatch_method 9 | 10 | 11 | class AdminSiteOTPRequiredMixin: 12 | """ 13 | Mixin for enforcing OTP verified staff users. 14 | 15 | Custom admin views should either be wrapped using :meth:`admin_view` or 16 | use :meth:`has_permission` in order to secure those views. 17 | """ 18 | 19 | def has_permission(self, request): 20 | """ 21 | Returns True if the given HttpRequest has permission to view 22 | *at least one* page in the admin site. 23 | """ 24 | if not super().has_permission(request): 25 | return False 26 | return request.user.is_verified() 27 | 28 | def login(self, request, extra_context=None): 29 | """ 30 | Redirects to the site login page for the given HttpRequest. 31 | """ 32 | redirect_to = request.POST.get(REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME)) 33 | 34 | if not redirect_to or not url_has_allowed_host_and_scheme(url=redirect_to, allowed_hosts=[request.get_host()]): 35 | redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL) 36 | 37 | return redirect_to_login(redirect_to) 38 | 39 | 40 | class AdminSiteOTPRequired(AdminSiteOTPRequiredMixin, AdminSite): 41 | """ 42 | AdminSite enforcing OTP verified staff users. 43 | """ 44 | pass 45 | 46 | 47 | def patch_admin(): 48 | @monkeypatch_method(AdminSite) 49 | def login(self, request, extra_context=None): 50 | """ 51 | Redirects to the site login page for the given HttpRequest. 52 | """ 53 | redirect_to = request.POST.get(REDIRECT_FIELD_NAME, request.GET.get(REDIRECT_FIELD_NAME)) 54 | 55 | if not redirect_to or not url_has_allowed_host_and_scheme(url=redirect_to, allowed_hosts=[request.get_host()]): 56 | redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL) 57 | 58 | return redirect_to_login(redirect_to) 59 | 60 | 61 | def unpatch_admin(): 62 | setattr(AdminSite, 'login', original_login) 63 | 64 | 65 | original_login = AdminSite.login 66 | -------------------------------------------------------------------------------- /two_factor/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | 4 | 5 | class TwoFactorConfig(AppConfig): 6 | name = 'two_factor' 7 | verbose_name = "Django Two Factor Authentication" 8 | 9 | def ready(self): 10 | if getattr(settings, 'TWO_FACTOR_PATCH_ADMIN', True): 11 | from .admin import patch_admin 12 | patch_admin() 13 | -------------------------------------------------------------------------------- /two_factor/gateways/__init__.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | 5 | def get_gateway_class(import_path): 6 | return import_string(import_path) 7 | 8 | 9 | def make_call(device, token): 10 | gateway = get_gateway_class(getattr(settings, 'TWO_FACTOR_CALL_GATEWAY'))() 11 | gateway.make_call(device=device, token=token) 12 | 13 | 14 | def send_sms(device, token): 15 | gateway = get_gateway_class(getattr(settings, 'TWO_FACTOR_SMS_GATEWAY'))() 16 | gateway.send_sms(device=device, token=token) 17 | -------------------------------------------------------------------------------- /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/gateways/twilio/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/gateways/twilio/__init__.py -------------------------------------------------------------------------------- /two_factor/gateways/twilio/gateway.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | 3 | from django.conf import settings 4 | from django.template.loader import render_to_string 5 | from django.urls import reverse 6 | from django.utils import translation 7 | from django.utils.translation import pgettext 8 | from twilio.rest import Client 9 | 10 | from two_factor.middleware.threadlocals import get_current_request 11 | 12 | # Supported voice languages, see http://bit.ly/187I5cr 13 | VOICE_LANGUAGES = ('en', 'en-gb', 'es', 'fr', 'it', 'de', 'da-DK', 'de-DE', 14 | 'en-AU', 'en-CA', 'en-GB', 'en-IN', 'en-US', 'ca-ES', 15 | 'es-ES', 'es-MX', 'fi-FI', 'fr-CA', 'fr-FR', 'it-IT', 16 | 'ja-JP', 'ko-KR', 'nb-NO', 'nl-NL', 'pl-PL', 'pt-BR', 17 | 'pt-PT', 'ru-RU', 'sv-SE', 'zh-CN', 'zh-HK', 'zh-TW') 18 | 19 | 20 | class Twilio: 21 | """ 22 | Gateway for sending text messages and making phone calls using Twilio_. 23 | 24 | All you need is your Twilio Account SID and Token, as shown in your Twilio 25 | account dashboard. 26 | 27 | ``TWILIO_ACCOUNT_SID`` 28 | Should be set to your account's SID. 29 | 30 | ``TWILIO_AUTH_TOKEN`` 31 | Should be set to your account's authorization token. 32 | 33 | ``TWILIO_CALLER_ID`` 34 | Should be set to a verified phone number. Twilio_ differentiates between 35 | numbers verified for making phone calls and sending text messages. 36 | 37 | ``TWILIO_MESSAGING_SERVICE_SID`` 38 | Can be set to a Twilio Messaging Service for SMS. This service can wrap multiple 39 | phone numbers and choose one depending on the destination country. 40 | When left empty the ``TWILIO_CALLER_ID`` will be used as sender ID. 41 | 42 | .. _Twilio: http://www.twilio.com/ 43 | """ 44 | 45 | def __init__(self): 46 | self.client = Client(getattr(settings, 'TWILIO_ACCOUNT_SID'), 47 | getattr(settings, 'TWILIO_AUTH_TOKEN')) 48 | 49 | def make_call(self, device, token): 50 | locale = translation.get_language() 51 | validate_voice_locale(locale) 52 | 53 | request = get_current_request() 54 | url = reverse('two_factor_twilio:call_app', kwargs={'token': token}) 55 | url = '%s?%s' % (url, urlencode({'locale': locale})) 56 | uri = request.build_absolute_uri(url) 57 | self.client.calls.create(to=device.number.as_e164, 58 | from_=getattr(settings, 'TWILIO_CALLER_ID'), 59 | url=uri, method='GET', timeout=15) 60 | 61 | def send_sms(self, device, token): 62 | """ 63 | send sms using template 'two_factor/twilio/sms_message.html' 64 | """ 65 | body = render_to_string( 66 | 'two_factor/twilio/sms_message.html', 67 | {'token': token} 68 | ) 69 | send_kwargs = { 70 | 'to': device.number.as_e164, 71 | 'body': body 72 | } 73 | messaging_service_sid = getattr(settings, 'TWILIO_MESSAGING_SERVICE_SID', None) 74 | if messaging_service_sid is not None: 75 | send_kwargs['messaging_service_sid'] = messaging_service_sid 76 | else: 77 | send_kwargs['from_'] = getattr(settings, 'TWILIO_CALLER_ID') 78 | 79 | self.client.messages.create(**send_kwargs) 80 | 81 | 82 | def validate_voice_locale(locale): 83 | with translation.override(locale): 84 | voice_locale = pgettext('twilio_locale', 'en') 85 | if voice_locale not in VOICE_LANGUAGES: 86 | raise NotImplementedError('The language "%s" is not ' 87 | 'supported by Twilio' % voice_locale) 88 | -------------------------------------------------------------------------------- /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//', 8 | TwilioCallApp.as_view(), 9 | name='call_app', 10 | ), 11 | ], 'two_factor_twilio') 12 | -------------------------------------------------------------------------------- /two_factor/gateways/twilio/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sites.shortcuts import get_current_site 3 | from django.template.response import TemplateResponse 4 | from django.utils import translation 5 | from django.utils.decorators import method_decorator 6 | from django.utils.translation import check_for_language, pgettext 7 | from django.views.decorators.cache import never_cache 8 | from django.views.decorators.csrf import csrf_exempt 9 | from django.views.generic import View 10 | 11 | from .gateway import validate_voice_locale 12 | 13 | 14 | @method_decorator([never_cache, csrf_exempt], name='dispatch') 15 | class TwilioCallApp(View): 16 | """ 17 | View used by Twilio for the interactive token verification by phone. 18 | """ 19 | templates = { 20 | 'press_a_key': 'two_factor/twilio/press_a_key.xml', 21 | 'token': 'two_factor/twilio/token.xml', 22 | } 23 | 24 | def get(self, request, token): 25 | return self.create_response(request, self.templates['press_a_key']) 26 | 27 | def post(self, request, token): 28 | return self.create_response(request, self.templates['token']) 29 | 30 | def create_response(self, request, template_path): 31 | with translation.override(self.get_locale()): 32 | template_context = { 33 | 'locale': self.get_twilio_locale(), 34 | 'site_name': get_current_site(self.request).name, 35 | 'token': list(str(self.kwargs['token'])) if self.request.method == 'POST' else '', 36 | } 37 | return TemplateResponse(request, template_path, template_context, content_type='text/xml') 38 | 39 | def get_locale(self): 40 | locale = self.request.GET.get('locale', '') 41 | if not check_for_language(locale): 42 | locale = settings.LANGUAGE_CODE 43 | validate_voice_locale(locale) 44 | return locale 45 | 46 | def get_twilio_locale(self): 47 | # Translators: twilio_locale should be a locale supported by 48 | # Twilio, see http://bit.ly/187I5cr 49 | return pgettext('twilio_locale', 'en') 50 | -------------------------------------------------------------------------------- /two_factor/locale/ar/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/as/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/ca_ES/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/ca_ES/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/da_DK/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/da_DK/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/en_GB/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/en_GB/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/ha/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/he_IL/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/he_IL/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/hi_IN/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/hi_IN/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/hu_HU/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/hu_HU/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/it/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/ro/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/sv/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/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/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/vi/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/locale/zh_CN/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/locale/zh_CN/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /two_factor/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/management/__init__.py -------------------------------------------------------------------------------- /two_factor/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/management/commands/__init__.py -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /two_factor/middleware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/middleware/__init__.py -------------------------------------------------------------------------------- /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/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | import django.core.validators 2 | from django.conf import settings 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='PhoneDevice', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)), 18 | ('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')), 19 | ('number', models.CharField( 20 | max_length=16, 21 | verbose_name='number', 22 | validators=[django.core.validators.RegexValidator( 23 | regex='^(\\+|00)', 24 | message='Please enter a valid phone number, including your country code ' 25 | 'starting with + or 00.', 26 | code='invalid-phone-number' 27 | )] 28 | )), 29 | ('key', models.CharField(help_text='Hex-encoded secret key', max_length=40)), 30 | ('method', models.CharField( 31 | max_length=4, 32 | verbose_name='method', 33 | choices=[('call', 'Phone Call'), ('sms', 'Text Message')] 34 | )), 35 | ('user', models.ForeignKey( 36 | help_text='The user that this device belongs to.', 37 | to=settings.AUTH_USER_MODEL, 38 | on_delete=models.CASCADE 39 | )), 40 | ], 41 | options={ 42 | 'abstract': False, 43 | }, 44 | bases=(models.Model,), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /two_factor/migrations/0001_squashed_0008_delete_phonedevice.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | """ 7 | two_factor used to contain the PhoneDevice model, now moved to a plugin 8 | directory. This squashed migration avoid for new projects to run those 9 | old migration files requiring phonenumber_field imports (now optional). 10 | """ 11 | 12 | replaces = [ 13 | ('two_factor', '0001_initial'), ('two_factor', '0002_auto_20150110_0810'), 14 | ('two_factor', '0003_auto_20150817_1733'), ('two_factor', '0004_auto_20160205_1827'), 15 | ('two_factor', '0005_auto_20160224_0450'), ('two_factor', '0006_phonedevice_key_default'), 16 | ('two_factor', '0007_auto_20201201_1019'), ('two_factor', '0008_delete_phonedevice'), 17 | ] 18 | 19 | initial = True 20 | 21 | dependencies = [ 22 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 23 | ] 24 | 25 | operations = [] 26 | -------------------------------------------------------------------------------- /two_factor/migrations/0002_auto_20150110_0810.py: -------------------------------------------------------------------------------- 1 | import django_otp.util 2 | from django.db import migrations, models 3 | 4 | 5 | def key_validator(): 6 | pass # Fake function, enough for migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('two_factor', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='phonedevice', 18 | name='key', 19 | field=models.CharField( 20 | default=django_otp.util.random_hex, 21 | help_text='Hex-encoded secret key', 22 | max_length=40, 23 | validators=[key_validator] 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /two_factor/migrations/0003_auto_20150817_1733.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import phonenumbers 4 | from django.db import migrations 5 | from phonenumber_field.modelfields import PhoneNumberField 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def migrate_phone_numbers(apps, schema_editor): 11 | PhoneDevice = apps.get_model("two_factor", "PhoneDevice") 12 | for device in PhoneDevice.objects.all(): 13 | try: 14 | number = phonenumbers.parse(device.number) 15 | if not phonenumbers.is_valid_number(number): 16 | logger.info("User '%s' has an invalid phone number '%s'." % (device.user, device.number)) 17 | device.number = phonenumbers.format_number(number, phonenumbers.PhoneNumberFormat.E164) 18 | device.save() 19 | except phonenumbers.NumberParseException as e: 20 | # Do not modify/delete the device, as it worked before. However this might result in issues elsewhere, 21 | # so do log a warning. 22 | logger.warning("User '%s' has an invalid phone number '%s': %s. Please resolve this issue, " 23 | "as it might result in errors." % (device.user, device.number, e)) 24 | 25 | 26 | class Migration(migrations.Migration): 27 | 28 | dependencies = [ 29 | ('two_factor', '0002_auto_20150110_0810'), 30 | ] 31 | 32 | operations = [ 33 | migrations.RunPython(migrate_phone_numbers, reverse_code=migrations.RunPython.noop), 34 | migrations.AlterField( 35 | model_name='phonedevice', 36 | name='number', 37 | field=PhoneNumberField(max_length=16, verbose_name='number'), 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /two_factor/migrations/0004_auto_20160205_1827.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.2 on 2016-02-05 17:27 2 | 3 | import phonenumber_field.modelfields 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('two_factor', '0003_auto_20150817_1733'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='phonedevice', 16 | name='number', 17 | field=phonenumber_field.modelfields.PhoneNumberField(max_length=128), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /two_factor/migrations/0005_auto_20160224_0450.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.2 on 2016-02-24 04:50 2 | 3 | import django.db.models.deletion 4 | import django_otp.util 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | def key_validator(): 10 | pass # Fake function, enough for migrations 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | dependencies = [ 16 | ('two_factor', '0004_auto_20160205_1827'), 17 | ] 18 | 19 | operations = [ 20 | migrations.AlterField( 21 | model_name='phonedevice', 22 | name='confirmed', 23 | field=models.BooleanField(default=True, help_text='Is this device ready for use?'), 24 | ), 25 | migrations.AlterField( 26 | model_name='phonedevice', 27 | name='key', 28 | field=models.CharField( 29 | default=django_otp.util.random_hex, 30 | help_text='Hex-encoded secret key', 31 | max_length=40, 32 | validators=[key_validator] 33 | ), 34 | ), 35 | migrations.AlterField( 36 | model_name='phonedevice', 37 | name='method', 38 | field=models.CharField( 39 | choices=[('call', 'Phone Call'), ('sms', 'Text Message')], 40 | max_length=4, 41 | verbose_name='method' 42 | ), 43 | ), 44 | migrations.AlterField( 45 | model_name='phonedevice', 46 | name='name', 47 | field=models.CharField(help_text='The human-readable name of this device.', max_length=64), 48 | ), 49 | migrations.AlterField( 50 | model_name='phonedevice', 51 | name='user', 52 | field=models.ForeignKey( 53 | help_text='The user that this device belongs to.', 54 | on_delete=django.db.models.deletion.CASCADE, 55 | to=settings.AUTH_USER_MODEL 56 | ), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /two_factor/migrations/0006_phonedevice_key_default.py: -------------------------------------------------------------------------------- 1 | import django_otp.util 2 | from django.db import migrations, models 3 | 4 | 5 | def key_validator(): 6 | pass # Fake function, enough for migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('two_factor', '0005_auto_20160224_0450'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='phonedevice', 18 | name='key', 19 | field=models.CharField( 20 | default=django_otp.util.random_hex, 21 | help_text='Hex-encoded secret key', 22 | max_length=40, 23 | validators=[key_validator] 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /two_factor/migrations/0007_auto_20201201_1019.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-12-01 10:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('two_factor', '0006_phonedevice_key_default'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='phonedevice', 15 | name='throttling_failure_count', 16 | field=models.PositiveIntegerField(default=0, help_text='Number of successive failed attempts.'), 17 | ), 18 | migrations.AddField( 19 | model_name='phonedevice', 20 | name='throttling_failure_timestamp', 21 | field=models.DateTimeField( 22 | blank=True, 23 | default=None, 24 | help_text='A timestamp of the last failed verification attempt. Null if last attempt succeeded.', 25 | null=True 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /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/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/migrations/__init__.py -------------------------------------------------------------------------------- /two_factor/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/plugins/__init__.py -------------------------------------------------------------------------------- /two_factor/plugins/email/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION <= (3, 2): 4 | default_app_config = 'two_factor.plugins.email.apps.TwoFactorEmailConfig' 5 | -------------------------------------------------------------------------------- /two_factor/plugins/email/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from two_factor.plugins.registry import registry 4 | 5 | 6 | class TwoFactorEmailConfig(AppConfig): 7 | name = 'two_factor.plugins.email' 8 | verbose_name = "Django Two Factor Authentication – Email Method" 9 | 10 | def ready(self): 11 | from .method import EmailMethod 12 | 13 | registry.register(EmailMethod()) 14 | -------------------------------------------------------------------------------- /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/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/email/utils.py: -------------------------------------------------------------------------------- 1 | def mask_email(email): 2 | """ 3 | Masks an email address, only first and last characters of the local part visible. 4 | 5 | Examples: 6 | 7 | * `j******e@example.com` 8 | * `t**@example.com` 9 | 10 | :param email: str 11 | :return: str 12 | """ 13 | local_part, domain = email.split('@') 14 | local_part_length = len(local_part) 15 | 16 | if local_part_length < 4: 17 | masked_local_part = local_part[0] + '*' * (local_part_length - 1) 18 | else: 19 | masked_local_part = local_part[0] + '*' * (local_part_length - 2) + local_part[-1] 20 | 21 | return f'{masked_local_part}@{domain}' 22 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | if django.VERSION <= (3, 2): 4 | default_app_config = 'two_factor.plugins.phonenumber.apps.TwoFactorPhoneNumberConfig' 5 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import PhoneDevice 4 | 5 | 6 | class PhoneDeviceAdmin(admin.ModelAdmin): 7 | """ 8 | :class:`~django.contrib.admin.ModelAdmin` for 9 | :class:`~two_factor.plugins.phonenumber.models.PhoneDevice`. 10 | """ 11 | raw_id_fields = ('user',) 12 | 13 | 14 | admin.site.register(PhoneDevice, PhoneDeviceAdmin) 15 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/plugins/phonenumber/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/plugins/phonenumber/migrations/__init__.py -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/models.py: -------------------------------------------------------------------------------- 1 | from binascii import unhexlify 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils.translation import gettext_lazy as _ 6 | from django_otp.models import Device, ThrottlingMixin 7 | from django_otp.oath import totp 8 | from django_otp.util import hex_validator, random_hex 9 | from phonenumber_field.modelfields import PhoneNumberField 10 | 11 | from two_factor.gateways import make_call, send_sms 12 | 13 | PHONE_METHODS = ( 14 | ('call', _('Phone Call')), 15 | ('sms', _('Text Message')), 16 | ) 17 | 18 | 19 | def key_validator(*args, **kwargs): 20 | """Wraps hex_validator generator, to keep makemigrations happy.""" 21 | return hex_validator()(*args, **kwargs) 22 | 23 | 24 | class PhoneDevice(ThrottlingMixin, Device): 25 | """ 26 | Model with phone number and token seed linked to a user. 27 | """ 28 | class Meta: 29 | db_table = 'two_factor_phonedevice' 30 | 31 | number = PhoneNumberField() 32 | key = models.CharField(max_length=40, 33 | validators=[key_validator], 34 | default=random_hex, 35 | help_text="Hex-encoded secret key") 36 | method = models.CharField(max_length=4, choices=PHONE_METHODS, 37 | verbose_name=_('method')) 38 | 39 | def __repr__(self): 40 | return ''.format( 41 | self.number, 42 | self.method, 43 | ) 44 | 45 | @property 46 | def bin_key(self): 47 | return unhexlify(self.key.encode()) 48 | 49 | def validate_token(self, token): 50 | # local import to avoid circular import 51 | from two_factor.utils import totp_digits 52 | 53 | try: 54 | token = int(token) 55 | except ValueError: 56 | return False 57 | 58 | for drift in range(-5, 1): 59 | if totp(self.bin_key, drift=drift, digits=totp_digits()) == token: 60 | return True 61 | return False 62 | 63 | def verify_token(self, token): 64 | # If the PhoneDevice doesn't have an id, we are setting up the device, 65 | # therefore the throttle is not relevant. 66 | commit_throttle = False 67 | if self.id: 68 | commit_throttle = True 69 | verify_allowed, _ = self.verify_is_allowed() 70 | 71 | if verify_allowed: 72 | verified = self.validate_token(token) 73 | if verified: 74 | self.throttle_reset(commit=commit_throttle) 75 | else: 76 | self.throttle_increment(commit=commit_throttle) 77 | else: 78 | verified = False 79 | 80 | return verified 81 | 82 | def generate_challenge(self): 83 | # local import to avoid circular import 84 | from two_factor.utils import totp_digits 85 | 86 | """ 87 | Sends the current TOTP token to `self.number` using `self.method`. 88 | """ 89 | verify_allowed, _ = self.verify_is_allowed() 90 | if not verify_allowed: 91 | return None 92 | 93 | no_digits = totp_digits() 94 | token = str(totp(self.bin_key, digits=no_digits)).zfill(no_digits) 95 | if self.method == 'call': 96 | make_call(device=self, token=token) 97 | else: 98 | send_sms(device=self, token=token) 99 | 100 | def get_throttle_factor(self): 101 | return getattr(settings, 'TWO_FACTOR_PHONE_THROTTLE_FACTOR', 1) 102 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/plugins/phonenumber/templatetags/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/plugins/phonenumber/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import PhoneDeleteView, PhoneSetupView 4 | 5 | urlpatterns = [ 6 | path( 7 | 'register/', 8 | PhoneSetupView.as_view(), 9 | name='phone_create', 10 | ), 11 | path( 12 | 'unregister//', 13 | PhoneDeleteView.as_view(), 14 | name='phone_delete', 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /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/phonenumber/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.utils.translation import gettext_lazy as _ 3 | from phonenumber_field.phonenumber import to_python 4 | 5 | 6 | def validate_international_phonenumber(value): 7 | phone_number = to_python(value) 8 | if phone_number and not phone_number.is_valid(): 9 | raise ValidationError(validate_international_phonenumber.message, 10 | code='invalid') 11 | 12 | 13 | validate_international_phonenumber.message = \ 14 | _('Please enter a valid phone number, including your country code ' 15 | 'starting with + or 00.') 16 | -------------------------------------------------------------------------------- /two_factor/plugins/phonenumber/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.shortcuts import redirect, resolve_url 3 | from django.utils.decorators import method_decorator 4 | from django.views.decorators.cache import never_cache 5 | from django.views.generic import DeleteView 6 | from django_otp.decorators import otp_required 7 | from django_otp.util import random_hex 8 | 9 | from two_factor.forms import DeviceValidationForm 10 | from two_factor.views.utils import IdempotentSessionWizardView 11 | 12 | from .forms import PhoneNumberMethodForm 13 | from .models import PhoneDevice 14 | from .utils import get_available_phone_methods 15 | 16 | 17 | @method_decorator([never_cache, otp_required], name='dispatch') 18 | class PhoneSetupView(IdempotentSessionWizardView): 19 | """ 20 | View for configuring a phone number for receiving tokens. 21 | 22 | A user can have multiple backup :class:`~two_factor.models.PhoneDevice` 23 | for receiving OTP tokens. If the primary phone number is not available, as 24 | the battery might have drained or the phone is lost, these backup phone 25 | numbers can be used for verification. 26 | """ 27 | template_name = 'two_factor/core/phone_register.html' 28 | success_url = settings.LOGIN_REDIRECT_URL 29 | form_list = ( 30 | ('setup', PhoneNumberMethodForm), 31 | ('validation', DeviceValidationForm), 32 | ) 33 | key_name = 'key' 34 | 35 | def get(self, request, *args, **kwargs): 36 | """ 37 | Start the setup wizard. Redirect if no phone methods available. 38 | """ 39 | if not get_available_phone_methods(): 40 | return redirect(self.success_url) 41 | return super().get(request, *args, **kwargs) 42 | 43 | def done(self, form_list, **kwargs): 44 | """ 45 | Store the device and redirect to profile page. 46 | """ 47 | self.get_device(user=self.request.user, name='backup').save() 48 | return redirect(self.success_url) 49 | 50 | def render_next_step(self, form, **kwargs): 51 | """ 52 | In the validation step, ask the device to generate a challenge. 53 | """ 54 | next_step = self.steps.next 55 | if next_step == 'validation': 56 | self.get_device().generate_challenge() 57 | return super().render_next_step(form, **kwargs) 58 | 59 | def get_form_kwargs(self, step=None): 60 | """ 61 | Provide the device to the DeviceValidationForm. 62 | """ 63 | if step == 'validation': 64 | return {'device': self.get_device()} 65 | return {} 66 | 67 | def get_device(self, **kwargs): 68 | """ 69 | Uses the data from the setup step and generated key to recreate device. 70 | """ 71 | kwargs = kwargs or {} 72 | kwargs.update(self.storage.validated_step_data.get('setup', {})) 73 | return PhoneDevice(key=self.get_key(), **kwargs) 74 | 75 | def get_key(self): 76 | """ 77 | The key is preserved between steps and stored as ascii in the session. 78 | """ 79 | if self.key_name not in self.storage.extra_data: 80 | self.storage.extra_data[self.key_name] = random_hex(20) 81 | return self.storage.extra_data[self.key_name] 82 | 83 | def get_context_data(self, form, **kwargs): 84 | kwargs.setdefault('cancel_url', resolve_url(self.success_url)) 85 | return super().get_context_data(form, **kwargs) 86 | 87 | 88 | @method_decorator([never_cache, otp_required], name='dispatch') 89 | class PhoneDeleteView(DeleteView): 90 | """ 91 | View for removing a phone number used for verification. 92 | """ 93 | success_url = settings.LOGIN_REDIRECT_URL 94 | 95 | def get_queryset(self): 96 | return self.request.user.phonedevice_set.filter(name='backup') 97 | 98 | def get_success_url(self): 99 | return resolve_url(self.success_url) 100 | -------------------------------------------------------------------------------- /two_factor/plugins/registry.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | 4 | class MethodNotFoundError(LookupError): 5 | """Registry method was not found 6 | 7 | Check that the appropriate method has been registered 8 | """ 9 | def __init__(self, code, methods): 10 | super().__init__(f"{code} not found in {[m.code for m in methods]}") 11 | 12 | 13 | class MethodBase: 14 | code = None 15 | verbose_name = None 16 | form_path = None 17 | 18 | def get_devices(self, user): 19 | raise NotImplementedError() 20 | 21 | def get_other_authentication_devices(self, user, main_device): 22 | devices = self.get_devices(user) 23 | return ( 24 | device for device in devices 25 | if (type(device) is not type(main_device)) or (device.pk != main_device.pk) 26 | ) 27 | 28 | def recognize_device(self, device): 29 | """ 30 | Return True if the device can be handled by this method. 31 | """ 32 | return False 33 | 34 | def get_setup_forms(self, wizard): 35 | """ 36 | Return a dict where keys are setup wizard step names, and the values 37 | the form class matching the step. 38 | """ 39 | return {} # pragma: no cover 40 | 41 | def get_device_from_setup_data(self, request, setup_data): 42 | """ 43 | Obtain device instance from 2fa setup wizard data. 44 | """ 45 | return None # pragma: no cover 46 | 47 | def get_token_form_class(self): 48 | """ 49 | Return the authentication token form class. 50 | """ 51 | from two_factor.forms import AuthenticationTokenForm 52 | 53 | return AuthenticationTokenForm 54 | 55 | def get_action(self, device): 56 | raise NotImplementedError() 57 | 58 | def get_verbose_action(self, device): 59 | raise NotImplementedError() 60 | 61 | 62 | class GeneratorMethod(MethodBase): 63 | code = 'generator' 64 | verbose_name = _('Token generator') 65 | form_path = 'two_factor.forms.TOTPDeviceForm' 66 | 67 | def get_devices(self, user): 68 | return user.totpdevice_set.all() 69 | 70 | def get_setup_forms(self, *args): 71 | from two_factor.forms import TOTPDeviceForm 72 | 73 | return {'generator': TOTPDeviceForm} 74 | 75 | def get_action(self, device): 76 | return _('Enter the token generated by your token generator') 77 | 78 | def get_verbose_action(self, device): 79 | return _('Please enter the token generated by your token generator.') 80 | 81 | 82 | class MethodRegistry: 83 | _methods = [] 84 | 85 | def __init__(self): 86 | self.register(GeneratorMethod()) 87 | 88 | def register(self, method): 89 | for registered_method in self._methods: 90 | if method.code == registered_method.code: 91 | return # Already registered, ignore. 92 | 93 | self._methods.append(method) 94 | 95 | def unregister(self, code): 96 | self._methods = [m for m in self._methods if m.code != code] 97 | 98 | def get_method(self, code): 99 | try: 100 | return [meth for meth in self._methods if meth.code == code][0] 101 | except IndexError: 102 | raise MethodNotFoundError(code, self._methods) 103 | 104 | def get_methods(self): 105 | return self._methods 106 | 107 | def method_from_device(self, device): 108 | for method in self._methods: 109 | if method.recognize_device(device): 110 | return method 111 | # Default to GeneratorMethod 112 | return GeneratorMethod() 113 | 114 | 115 | registry = MethodRegistry() 116 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/plugins/webauthn/__init__.py -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import ModelAdmin, register 2 | 3 | from .models import WebauthnDevice 4 | 5 | 6 | @register(WebauthnDevice) 7 | class WebauthnDeviceAdmin(ModelAdmin): 8 | list_display = ['user', 'name', 'created_at', 'last_used_at', 'confirmed'] 9 | raw_id_fields = ['user'] 10 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | from two_factor.plugins.registry import registry 6 | 7 | 8 | class TwoFactorWebauthnConfig(AppConfig): 9 | name = 'two_factor.plugins.webauthn' 10 | label = 'two_factor_webauthn' 11 | verbose_name = "Django Two Factor Authentication - WebAuthn Method" 12 | url_prefix = 'webauthn' 13 | default_auto_field = 'django.db.models.AutoField' 14 | 15 | defaults = { 16 | 'TWO_FACTOR_WEBAUTHN_RP_NAME': None, 17 | 'TWO_FACTOR_WEBAUTHN_AUTHENTICATOR_ATTACHMENT': None, 18 | 'TWO_FACTOR_WEBAUTHN_PREFERRED_TRANSPORTS': None, 19 | 'TWO_FACTOR_WEBAUTHN_UV_REQUIREMENT': 'discouraged', 20 | 'TWO_FACTOR_WEBAUTHN_ATTESTATION_CONVEYANCE': 'none', 21 | 'TWO_FACTOR_WEBAUTHN_PEM_ROOT_CERTS_BYTES_BY_FMT': None, 22 | 'TWO_FACTOR_WEBAUTHN_ENTITIES_FORM_MIXIN': 23 | 'two_factor.plugins.webauthn.forms.DefaultWebauthnEntitiesFormMixin', 24 | 'TWO_FACTOR_WEBAUTHN_RP_ID': None, 25 | 'TWO_FACTOR_WEBAUTHN_THROTTLE_FACTOR': 1, 26 | } 27 | 28 | def ready(self): 29 | try: 30 | from webauthn.helpers.structs import ( 31 | AttestationConveyancePreference, 32 | ) 33 | except ImportError: 34 | raise ImproperlyConfigured( 35 | "'webauthn' must be installed to be able to use the webauthn plugin." 36 | ) 37 | 38 | for name, default in self.defaults.items(): 39 | value = getattr(settings, name, default) 40 | setattr(settings, name, value) 41 | 42 | if not settings.TWO_FACTOR_WEBAUTHN_RP_NAME: 43 | raise ImproperlyConfigured('The TWO_FACTOR_WEBAUTHN_RP_NAME setting must not be empty.') 44 | 45 | if settings.TWO_FACTOR_WEBAUTHN_ATTESTATION_CONVEYANCE == AttestationConveyancePreference.ENTERPRISE: 46 | raise ImproperlyConfigured( 47 | f"'{AttestationConveyancePreference.ENTERPRISE}' is not a supported" 48 | " value for TWO_FACTOR_WEBAUTHN_ATTESTATION_CONVEYANCE." 49 | ) 50 | 51 | from .method import WebAuthnMethod 52 | 53 | registry.register(WebAuthnMethod()) 54 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/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 ( 6 | WebauthnAuthenticationTokenForm, WebauthnDeviceValidationForm, 7 | ) 8 | from .models import WebauthnDevice 9 | from .utils import verify_registration_response 10 | 11 | 12 | class WebAuthnMethod(MethodBase): 13 | code = 'webauthn' 14 | verbose_name = _('WebAuthn') 15 | 16 | def get_devices(self, user): 17 | return user.webauthn_keys.all() 18 | 19 | def get_other_authentication_devices(self, user, main_device): 20 | # authentication is attempted on all WebAuthn devices at the same time 21 | # if main_device is a WebAuthn device then WebAuthn is the primary method 22 | # and there are no "other" WebAuthn devices 23 | if self.recognize_device(main_device): 24 | return [] 25 | 26 | for device in self.get_devices(user): 27 | # first WebAuthn device found is enough to trigger on all of them at the same time 28 | return [device] 29 | return [] 30 | 31 | def recognize_device(self, device): 32 | return isinstance(device, WebauthnDevice) 33 | 34 | def get_setup_forms(self, *args): 35 | return {self.code: WebauthnDeviceValidationForm} 36 | 37 | def get_device_from_setup_data(self, request, setup_data, **kwargs): 38 | webauthn_setup_data = setup_data.get('webauthn') 39 | if webauthn_setup_data is None: 40 | return None 41 | 42 | expected_rp_id = webauthn_setup_data['expected_rp_id'] 43 | expected_origin = webauthn_setup_data['expected_origin'] 44 | expected_challenge = webauthn_setup_data['expected_challenge'] 45 | token = webauthn_setup_data['token'] 46 | 47 | public_key, key_handle, sign_count = verify_registration_response( 48 | expected_rp_id, expected_origin, expected_challenge, token) 49 | 50 | return WebauthnDevice( 51 | name='default', 52 | public_key=public_key, 53 | key_handle=key_handle, 54 | sign_count=sign_count, 55 | user=request.user 56 | ) 57 | 58 | def get_token_form_class(self): 59 | return WebauthnAuthenticationTokenForm 60 | 61 | def get_action(self, device): 62 | return _('Authenticate using a WebAuthn-compatible device') 63 | 64 | def get_verbose_action(self, device): 65 | return _('Please use your WebAuthn-compatible device to authenticate.') 66 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-08-21 02:53 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | def create_webauthn_device_model(unique_public_key): 9 | return migrations.CreateModel( 10 | name='WebauthnDevice', 11 | fields=[ 12 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 13 | ('name', models.CharField(help_text='The human-readable name of this device.', max_length=64)), 14 | ('confirmed', models.BooleanField(default=True, help_text='Is this device ready for use?')), 15 | ( 16 | 'throttling_failure_timestamp', 17 | models.DateTimeField( 18 | blank=True, 19 | default=None, 20 | help_text=( 21 | 'A timestamp of the last failed verification attempt.' 22 | ' Null if last attempt succeeded.' 23 | ), 24 | null=True, 25 | ), 26 | ), 27 | ( 28 | 'throttling_failure_count', 29 | models.PositiveIntegerField( 30 | default=0, 31 | help_text='Number of successive failed attempts.', 32 | ), 33 | ), 34 | ('created_at', models.DateTimeField(auto_now_add=True)), 35 | ('last_used_at', models.DateTimeField(null=True)), 36 | ('public_key', models.TextField(unique=unique_public_key)), 37 | ('key_handle', models.TextField()), 38 | ('sign_count', models.IntegerField()), 39 | ( 40 | 'user', 41 | models.ForeignKey( 42 | on_delete=django.db.models.deletion.CASCADE, 43 | related_name='webauthn_keys', 44 | to=settings.AUTH_USER_MODEL, 45 | ), 46 | ), 47 | ], 48 | options={ 49 | 'abstract': False, 50 | }, 51 | ) 52 | 53 | 54 | class Migration(migrations.Migration): 55 | 56 | initial = True 57 | 58 | dependencies = [ 59 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 60 | ] 61 | 62 | operations = [create_webauthn_device_model(True)] 63 | 64 | def apply(self, project_state, schema_editor, collect_sql=False): 65 | if schema_editor.connection.vendor == 'mysql': 66 | # avoid creating a unique index on long text 67 | self.operations = [create_webauthn_device_model(False)] 68 | 69 | return super().apply(project_state, schema_editor, collect_sql) 70 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/plugins/webauthn/migrations/__init__.py -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django_otp.models import Device, ThrottlingMixin 4 | 5 | 6 | class WebauthnDevice(ThrottlingMixin, Device): 7 | """ 8 | Model for Webauthn authentication 9 | """ 10 | user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='webauthn_keys', on_delete=models.CASCADE) 11 | created_at = models.DateTimeField(auto_now_add=True) 12 | last_used_at = models.DateTimeField(null=True) 13 | 14 | public_key = models.TextField() 15 | key_handle = models.TextField() 16 | sign_count = models.IntegerField() 17 | 18 | def get_throttle_factor(self): 19 | return settings.TWO_FACTOR_WEBAUTHN_THROTTLE_FACTOR 20 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/static/two_factor/js/webauthn_utils.js: -------------------------------------------------------------------------------- 1 | function ab2str(buf) { 2 | if (buf == null) { 3 | return null; 4 | }; 5 | 6 | return btoa(String.fromCharCode.apply(null, new Uint8Array(buf))); 7 | } 8 | 9 | function b64str2ab(b64_encoded_string) { 10 | if (b64_encoded_string == null) { 11 | return null; 12 | }; 13 | 14 | let string = atob(b64_encoded_string.replace(/_/g, '/').replace(/-/g, '+')), 15 | buf = new ArrayBuffer(string.length), 16 | bufView = new Uint8Array(buf); 17 | for (var i = 0, strLen = string.length; i < strLen; i++) { 18 | bufView[i] = string.charCodeAt(i); 19 | } 20 | return buf; 21 | } 22 | -------------------------------------------------------------------------------- /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 = document.forms[0]; 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 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/plugins/webauthn/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /two_factor/plugins/webauthn/tests/test_views_setup.py: -------------------------------------------------------------------------------- 1 | from unittest import mock, skipUnless 2 | 3 | from django.test import TestCase 4 | from django.urls import reverse 5 | 6 | from tests.utils import UserMixin 7 | 8 | try: 9 | import webauthn 10 | except ImportError: 11 | webauthn = None 12 | 13 | 14 | class SetupTest(UserMixin, TestCase): 15 | def setUp(self): 16 | super().setUp() 17 | self.user = self.create_user() 18 | self.login_user() 19 | 20 | @skipUnless(webauthn, 'package webauthn is not present') 21 | def test_setup_webauthn(self): 22 | self.assertEqual(0, self.user.webauthn_keys.count()) 23 | 24 | response = self.client.post( 25 | reverse('two_factor:setup'), 26 | data={'setup_view-current_step': 'welcome'}) 27 | self.assertContains(response, 'Method:') 28 | 29 | response = self.client.post( 30 | reverse('two_factor:setup'), 31 | data={'setup_view-current_step': 'method', 32 | 'method-method': 'webauthn'}) 33 | self.assertContains(response, 'Token:') 34 | session = self.client.session 35 | self.assertIn('webauthn_creation_options', session.keys()) 36 | 37 | response = self.client.post( 38 | reverse('two_factor:setup'), 39 | data={'setup_view-current_step': 'webauthn'}) 40 | self.assertEqual(response.context_data['wizard']['form'].errors, 41 | {'token': ['This field is required.']}) 42 | 43 | with mock.patch( 44 | "two_factor.plugins.webauthn.forms.parse_registration_credential_json" 45 | ), mock.patch( 46 | "two_factor.plugins.webauthn.method.verify_registration_response" 47 | ) as verify_registration_response: 48 | verify_registration_response.return_value = ( 49 | 'mocked_public_key', 50 | 'mocked_credential_id', 51 | 0, 52 | ) 53 | 54 | response = self.client.post( 55 | reverse('two_factor:setup'), 56 | data={'setup_view-current_step': 'webauthn', 57 | 'webauthn-token': 'a_valid_token'}) 58 | 59 | self.assertRedirects(response, reverse('two_factor:setup_complete')) 60 | self.assertEqual(1, self.user.webauthn_keys.count()) 61 | -------------------------------------------------------------------------------- /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/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/yubikey/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /two_factor/plugins/yubikey/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | from two_factor.plugins.registry import registry 5 | 6 | 7 | class TwoFactorYubikeyConfig(AppConfig): 8 | name = 'two_factor.plugins.yubikey' 9 | verbose_name = "Django Two Factor Authentication – Yubikey Method" 10 | 11 | def ready(self): 12 | if not apps.is_installed('otp_yubikey'): 13 | raise ImproperlyConfigured( 14 | "'otp_yubikey' must be installed to be able to use the yubikey plugin." 15 | ) 16 | 17 | from .method import YubikeyMethod 18 | 19 | registry.register(YubikeyMethod()) 20 | -------------------------------------------------------------------------------- /two_factor/plugins/yubikey/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from two_factor.forms import AuthenticationTokenForm, DeviceValidationForm 5 | 6 | 7 | class YubiKeyDeviceForm(DeviceValidationForm): 8 | token = forms.CharField(label=_("YubiKey"), widget=forms.PasswordInput()) 9 | 10 | error_messages = { 11 | 'invalid_token': _("The YubiKey could not be verified."), 12 | } 13 | idempotent = False 14 | 15 | def clean_token(self): 16 | self.device.public_id = self.cleaned_data['token'][:-32] 17 | return super().clean_token() 18 | 19 | 20 | class YubiKeyAuthenticationForm(AuthenticationTokenForm): 21 | # YubiKey generates a OTP of 44 characters (not digits). So if the 22 | # user's primary device is a YubiKey, replace the otp_token 23 | # IntegerField with a CharField. 24 | otp_token = forms.CharField(label=_('YubiKey'), widget=forms.PasswordInput(attrs={'autofocus': True})) 25 | -------------------------------------------------------------------------------- /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/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | # Signal additional parameters are: request, user, and device. 4 | user_verified = Signal() 5 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | 8 | 9 | {% block extra_media %}{% endblock %} 10 | 11 | 12 |

Provide a template named 13 | two_factor/_base.html to style this page and remove this message.

14 | 15 | {% block content_wrapper %} 16 |
17 | {% block content %}{% endblock %} 18 |
19 | {% endblock %} 20 | 21 | 22 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/_base_focus.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base.html" %} 2 | 3 | {% block content_wrapper %} 4 |
5 |
6 |
7 | {% block content %}{% endblock %} 8 |
9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/_wizard_actions.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if cancel_url %} 4 | {% trans "Cancel" %} 6 | {% endif %} 7 | {% if wizard.steps.prev %} 8 | 11 | {% else %} 12 | 13 | {% endif %} 14 | 15 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/_wizard_forms.html: -------------------------------------------------------------------------------- 1 | 2 | {{ wizard.management_form }} 3 | {{ wizard.form.as_table }} 4 |
5 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/core/backup_tokens.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% block title %}{% trans "Backup Tokens" %}{% endblock %}

6 |

{% 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 |
    14 | {% for token in device.token_set.all %} 15 |
  • {{ token.token }}
  • 16 | {% endfor %} 17 |
18 |

{% 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 |
{% csrf_token %}{{ form.as_p }} 24 | {% trans "Back to Account Security" %} 26 | 27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/core/login.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | {% load two_factor_tags %} 4 | 5 | {% block extra_media %} 6 | {{ form.media }} 7 | {% endblock %} 8 | 9 | {% block content %} 10 |

{% block title %}{% trans "Login" %}{% endblock %}

11 | 12 | {% if wizard.steps.current == 'auth' %} 13 |

{% 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 |
23 | {% block main_form_content %} 24 | {% csrf_token %} 25 | {% include "two_factor/_wizard_forms.html" %} 26 | 27 | {# hidden submit button to enable [enter] key #} 28 | 29 | 30 | {% if other_devices %} 31 |

{% trans "Or, alternatively, use one of your other authentication methods:" %}

32 |

33 | {% for other in other_devices %} 34 | 38 | {% endfor %}

39 | {% endif %} 40 | 41 | {% include "two_factor/_wizard_actions.html" %} 42 | {% endblock %} 43 |
44 | 45 | {% block 'backup_tokens' %} 46 | {% if backup_tokens %} 47 |
48 |
49 |
50 | {% csrf_token %} 51 |

{% trans "As a last resort, you can use a backup token:" %}

52 |

53 | 55 |

56 |
57 |
58 | {% endif %} 59 | {% endblock %} 60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/core/otp_required.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% block title %}{% trans "Permission Denied" %}{% endblock %}

6 | 7 |

{% 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 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/core/phone_register.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% block title %}{% trans "Add Backup Phone" %}{% endblock %}

6 | 7 | {% if wizard.steps.current == 'setup' %} 8 |

{% 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 |
{% csrf_token %} 17 | {% include "two_factor/_wizard_forms.html" %} 18 | 19 | {# hidden submit button to enable [enter] key #} 20 | 21 | 22 | {% include "two_factor/_wizard_actions.html" %} 23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /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 |

{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}

10 | {% if wizard.steps.current == 'welcome' %} 11 |

{% 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 |

QR Code

22 |

{% 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 | 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 |
{% csrf_token %} 57 | {% include "two_factor/_wizard_forms.html" %} 58 | 59 | {# hidden submit button to enable [enter] key #} 60 | 61 | 62 | {% include "two_factor/_wizard_actions.html" %} 63 |
64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/core/setup_complete.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}

6 | 7 |

{% 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/templates/two_factor/profile/disable.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base_focus.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |

{% block title %}{% trans "Disable Two-factor Authentication" %}{% endblock %}

6 |

{% blocktrans trimmed %}You are about to disable two-factor authentication. This 7 | weakens your account security, are you sure?{% endblocktrans %}

8 |
9 | {% csrf_token %} 10 | {{ form.as_table }}
11 | 13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/profile/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "two_factor/_base.html" %} 2 | {% load i18n %} 3 | {% load two_factor_tags %} 4 | 5 | {% block content %} 6 |

{% block title %}{% trans "Account Security" %}{% endblock %}

7 | 8 | {% if default_device %} 9 |

{% blocktrans with primary=default_device|as_action %}Primary method: {{ primary }}{% endblocktrans %}

10 | 11 | {% if available_phone_methods %} 12 |

{% trans "Backup Phone Numbers" %}

13 |

{% 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 |
    17 | {% for phone in backup_phones %} 18 |
  • 19 | {{ phone|as_action }} 20 |
    22 | {% csrf_token %} 23 | 25 |
    26 |
  • 27 | {% endfor %} 28 |
29 | {% endif %} 30 |

{% trans "Add Phone Number" %}

32 | {% endif %} 33 | 34 |

{% trans "Backup Tokens" %}

35 |

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 |

{% trans "Show Codes" %}

46 | 47 |

{% trans "Disable Two-Factor Authentication" %}

48 |

{% 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/templates/two_factor/twilio/press_a_key.xml: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | {% blocktrans %}Hi, this is {{ site_name }} calling. Press any key to continue.{% endblocktrans %} 5 | 6 | {% trans "You didn’t press any keys. Good bye." %} 7 | 8 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/twilio/sms_message.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans trimmed %} 3 | Your OTP token is {{ token }} 4 | {% endblocktrans %} 5 | 6 | -------------------------------------------------------------------------------- /two_factor/templates/two_factor/twilio/token.xml: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% trans "Your token is:" %} 4 | 5 | {% for digit in token %} {{ digit }} 6 | 7 | {% endfor %} {% trans "Repeat:" %} 8 | 9 | {% for digit in token %} {{ digit }} 10 | 11 | {% endfor %} {% trans "Good bye." %} 12 | 13 | -------------------------------------------------------------------------------- /two_factor/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-two-factor-auth/227bb96dd91c405e9d5b47b3d2ff29c6184d86f9/two_factor/templatetags/__init__.py -------------------------------------------------------------------------------- /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/urls.py: -------------------------------------------------------------------------------- 1 | from django.apps.registry import apps 2 | from django.urls import include, path 3 | 4 | from two_factor.views import ( 5 | BackupTokensView, DisableView, LoginView, ProfileView, QRGeneratorView, 6 | SetupCompleteView, SetupView, 7 | ) 8 | 9 | core = [ 10 | path( 11 | 'account/login/', 12 | LoginView.as_view(), 13 | name='login', 14 | ), 15 | path( 16 | 'account/two_factor/setup/', 17 | SetupView.as_view(), 18 | name='setup', 19 | ), 20 | path( 21 | 'account/two_factor/qrcode/', 22 | QRGeneratorView.as_view(), 23 | name='qr', 24 | ), 25 | path( 26 | 'account/two_factor/setup/complete/', 27 | SetupCompleteView.as_view(), 28 | name='setup_complete', 29 | ), 30 | path( 31 | 'account/two_factor/backup/tokens/', 32 | BackupTokensView.as_view(), 33 | name='backup_tokens', 34 | ), 35 | ] 36 | 37 | profile = [ 38 | path( 39 | 'account/two_factor/', 40 | ProfileView.as_view(), 41 | name='profile', 42 | ), 43 | path( 44 | 'account/two_factor/disable/', 45 | DisableView.as_view(), 46 | name='disable', 47 | ), 48 | ] 49 | 50 | plugin_urlpatterns = [] 51 | for app_config in apps.get_app_configs(): 52 | if app_config.name.startswith('two_factor.plugins.'): 53 | # Phonenumber used to be include in the two_factor core. Because we 54 | # don't want to change the url names and break backwards compatibility 55 | # we keep the urls of the phonenumber plugin in the core two_factor 56 | # namespace. 57 | if app_config.name == 'two_factor.plugins.phonenumber': 58 | namespace = None 59 | else: 60 | namespace = app_config.label 61 | try: 62 | plugin_urlpatterns.append( 63 | path( 64 | f'account/two_factor/{app_config.url_prefix}/', 65 | include(f'{app_config.name}.urls', namespace) 66 | ), 67 | ) 68 | except AttributeError: 69 | pass 70 | 71 | urlpatterns = (core + profile + plugin_urlpatterns, 'two_factor') 72 | -------------------------------------------------------------------------------- /two_factor/utils.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote, urlencode 2 | 3 | from django.conf import settings 4 | from django_otp import devices_for_user 5 | 6 | USER_DEFAULT_DEVICE_ATTR_NAME = "_default_device" 7 | 8 | 9 | def default_device(user, confirmed=True): 10 | if not user or user.is_anonymous: 11 | return 12 | if hasattr(user, USER_DEFAULT_DEVICE_ATTR_NAME): 13 | return getattr(user, USER_DEFAULT_DEVICE_ATTR_NAME) 14 | for device in devices_for_user(user, confirmed=confirmed): 15 | if device.name == 'default': 16 | setattr(user, USER_DEFAULT_DEVICE_ATTR_NAME, device) 17 | return device 18 | 19 | 20 | def get_otpauth_url(accountname, secret, issuer=None, digits=None): 21 | # For a complete run-through of all the parameters, have a look at the 22 | # specs at: 23 | # https://github.com/google/google-authenticator/wiki/Key-Uri-Format 24 | 25 | # quote and urlencode work best with bytes, not unicode strings. 26 | accountname = accountname.encode('utf8') 27 | issuer = issuer.encode('utf8') if issuer else None 28 | 29 | label = quote(b': '.join([issuer, accountname]) if issuer else accountname) 30 | 31 | # Ensure that the secret parameter is the FIRST parameter of the URI, this 32 | # allows Microsoft Authenticator to work. 33 | query = [ 34 | ('secret', secret), 35 | ('digits', digits or totp_digits()) 36 | ] 37 | 38 | if issuer: 39 | query.append(('issuer', issuer)) 40 | 41 | return 'otpauth://totp/%s?%s' % (label, urlencode(query)) 42 | 43 | 44 | # from http://mail.python.org/pipermail/python-dev/2008-January/076194.html 45 | def monkeypatch_method(cls): 46 | def decorator(func): 47 | setattr(cls, func.__name__, func) 48 | return func 49 | return decorator 50 | 51 | 52 | def totp_digits(): 53 | """ 54 | Returns the number of digits (as configured by the TWO_FACTOR_TOTP_DIGITS setting) 55 | for totp tokens. Defaults to 6 56 | """ 57 | return getattr(settings, 'TWO_FACTOR_TOTP_DIGITS', 6) 58 | -------------------------------------------------------------------------------- /two_factor/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import ( 2 | BackupTokensView, LoginView, QRGeneratorView, SetupCompleteView, SetupView, 3 | ) 4 | from .mixins import OTPRequiredMixin 5 | from .profile import DisableView, ProfileView 6 | 7 | __all__ = ( 8 | "BackupTokensView", 9 | "LoginView", 10 | "QRGeneratorView", 11 | "SetupCompleteView", 12 | "SetupView", 13 | "OTPRequiredMixin", 14 | "DisableView", 15 | "ProfileView" 16 | ) 17 | -------------------------------------------------------------------------------- /two_factor/views/mixins.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import REDIRECT_FIELD_NAME 2 | from django.contrib.auth.views import redirect_to_login 3 | from django.core.exceptions import PermissionDenied 4 | from django.template.response import TemplateResponse 5 | from django.urls import Resolver404, resolve, reverse 6 | 7 | from ..admin import AdminSiteOTPRequiredMixin 8 | from ..utils import default_device 9 | 10 | 11 | class OTPRequiredMixin: 12 | """ 13 | View mixin which verifies that the user logged in using OTP. 14 | 15 | .. note:: 16 | This mixin should be the left-most base class. 17 | """ 18 | raise_anonymous = False 19 | """ 20 | Whether to raise PermissionDenied if the user isn't logged in. 21 | """ 22 | 23 | login_url = None 24 | """ 25 | If :attr:`raise_anonymous` is set to `False`, this defines where the user 26 | will be redirected to. Defaults to ``two_factor:login``. 27 | """ 28 | 29 | redirect_field_name = REDIRECT_FIELD_NAME 30 | """ 31 | URL query name to use for providing the destination URL. 32 | """ 33 | 34 | raise_unverified = False 35 | """ 36 | Whether to raise PermissionDenied if the user isn't verified. 37 | """ 38 | 39 | verification_url = None 40 | """ 41 | If :attr:`raise_unverified` is set to `False`, this defines where the user 42 | will be redirected to. If set to ``None``, an explanation will be shown to 43 | the user on why access was denied. 44 | """ 45 | 46 | def get_login_url(self): 47 | """ 48 | Returns login url to redirect to. 49 | """ 50 | return self.login_url and str(self.login_url) or reverse('two_factor:login') 51 | 52 | def get_verification_url(self): 53 | """ 54 | Returns verification url to redirect to. 55 | """ 56 | return self.verification_url and str(self.verification_url) 57 | 58 | def dispatch(self, request, *args, **kwargs): 59 | if not request.user or not request.user.is_authenticated or \ 60 | (not request.user.is_verified() and default_device(request.user)): 61 | # If the user has not authenticated raise or redirect to the login 62 | # page. Also if the user just enabled two-factor authentication and 63 | # has not yet logged in since should also have the same result. If 64 | # the user receives a 'you need to enable TFA' by now, he gets 65 | # confuses as TFA has just been enabled. So we either raise or 66 | # redirect to the login page. 67 | if self.raise_anonymous: 68 | raise PermissionDenied() 69 | else: 70 | return redirect_to_login(request.get_full_path(), self.get_login_url()) 71 | 72 | if not request.user.is_verified(): 73 | if self.raise_unverified: 74 | raise PermissionDenied() 75 | elif self.get_verification_url(): 76 | return redirect_to_login(request.get_full_path(), self.get_verification_url()) 77 | else: 78 | return TemplateResponse( 79 | request=request, 80 | template='two_factor/core/otp_required.html', 81 | status=403, 82 | ) 83 | return super().dispatch(request, *args, **kwargs) 84 | 85 | @classmethod 86 | def is_otp_view(cls, view_path): 87 | try: 88 | next_resolver_match = resolve(view_path) 89 | except Resolver404: 90 | return False 91 | return ( 92 | hasattr(next_resolver_match.func, 'view_class') and 93 | issubclass(next_resolver_match.func.view_class, OTPRequiredMixin) 94 | ) or ( 95 | hasattr(next_resolver_match.func, 'admin_site') and 96 | isinstance(next_resolver_match.func.admin_site, AdminSiteOTPRequiredMixin) 97 | ) 98 | -------------------------------------------------------------------------------- /two_factor/views/profile.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.decorators import login_required 3 | from django.shortcuts import redirect, resolve_url 4 | from django.utils.decorators import method_decorator 5 | from django.utils.functional import lazy 6 | from django.views.decorators.cache import never_cache 7 | from django.views.generic import FormView, TemplateView 8 | from django_otp import devices_for_user 9 | from django_otp.decorators import otp_required 10 | 11 | from two_factor.plugins.phonenumber.utils import ( 12 | backup_phones, get_available_phone_methods, 13 | ) 14 | 15 | from ..forms import DisableForm 16 | from ..utils import default_device 17 | 18 | 19 | @method_decorator([never_cache, login_required], name='dispatch') 20 | class ProfileView(TemplateView): 21 | """ 22 | View used by users for managing two-factor configuration. 23 | 24 | This view shows whether two-factor has been configured for the user's 25 | account. If two-factor is enabled, it also lists the primary verification 26 | method and backup verification methods. 27 | """ 28 | template_name = 'two_factor/profile/profile.html' 29 | 30 | def get_context_data(self, **kwargs): 31 | user = self.request.user 32 | 33 | try: 34 | backup_tokens = user.staticdevice_set.all()[0].token_set.count() 35 | 36 | except Exception: 37 | backup_tokens = 0 38 | 39 | context = { 40 | 'default_device': default_device(user), 41 | 'default_device_type': default_device(user).__class__.__name__, 42 | 'backup_tokens': backup_tokens, 43 | 'backup_phones': backup_phones(user), 44 | 'available_phone_methods': get_available_phone_methods(), 45 | } 46 | 47 | return context 48 | 49 | 50 | @method_decorator(never_cache, name='dispatch') 51 | class DisableView(FormView): 52 | """ 53 | View for disabling two-factor for a user's account. 54 | """ 55 | template_name = 'two_factor/profile/disable.html' 56 | success_url = lazy(resolve_url, str)(settings.LOGIN_REDIRECT_URL) 57 | form_class = DisableForm 58 | 59 | def dispatch(self, *args, **kwargs): 60 | # We call otp_required here because we want to use self.success_url as 61 | # the login_url. Using it as a class decorator would make it difficult 62 | # for users who wish to override this property 63 | fn = otp_required(super().dispatch, login_url=self.success_url, redirect_field_name=None) 64 | return fn(*args, **kwargs) 65 | 66 | def form_valid(self, form): 67 | for device in devices_for_user(self.request.user): 68 | device.delete() 69 | return redirect(self.success_url) 70 | --------------------------------------------------------------------------------