├── tests
├── __init__.py
├── templates
│ └── base.html
├── adapter.py
├── forms.py
├── urls.py
├── settings.py
└── test_allauth_2fa.py
├── allauth_2fa
├── py.typed
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── allauth_2fa_migrate.py
├── __init__.py
├── ruff.toml
├── templates
│ └── allauth_2fa
│ │ ├── authenticate.html
│ │ ├── remove.html
│ │ ├── setup.html
│ │ └── backup_tokens.html
├── urls.py
├── mixins.py
├── app_settings.py
├── utils.py
├── adapter.py
├── forms.py
├── middleware.py
└── views.py
├── docs
├── changelog.rst
├── index.rst
├── Makefile
├── make.bat
├── advanced.rst
├── configuration.rst
├── conf.py
└── installation.rst
├── pytest.ini
├── MANIFEST.in
├── .gitignore
├── manage.py
├── LICENSE
├── .pre-commit-config.yaml
├── tox.ini
├── pyproject.toml
├── .github
└── workflows
│ └── ci.yml
├── README.rst
└── CHANGELOG.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/allauth_2fa/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/allauth_2fa/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/allauth_2fa/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../CHANGELOG.rst
2 |
--------------------------------------------------------------------------------
/tests/templates/base.html:
--------------------------------------------------------------------------------
1 | {% block content %}{% endblock %}
2 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | DJANGO_SETTINGS_MODULE = tests.settings
3 |
--------------------------------------------------------------------------------
/allauth_2fa/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | __version__ = "0.12.0"
4 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst
2 | include LICENSE
3 | recursive-include allauth_2fa/templates *
4 |
--------------------------------------------------------------------------------
/tests/adapter.py:
--------------------------------------------------------------------------------
1 | from allauth_2fa.adapter import OTPAdapter
2 |
3 |
4 | class CustomAdapter(OTPAdapter):
5 | pass
6 |
--------------------------------------------------------------------------------
/allauth_2fa/ruff.toml:
--------------------------------------------------------------------------------
1 | extend = "../pyproject.toml"
2 |
3 | [lint.isort]
4 | required-imports = [
5 | "from __future__ import annotations",
6 | ]
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | __pycache__/
3 | *.sw*
4 | db.sqlite
5 | .idea/
6 |
7 | # Distributions.
8 | .eggs/
9 | build/
10 | dist/
11 | django_allauth_2fa.egg-info/
12 |
13 | # Testing
14 | .coverage
15 | htmlcov
16 | .tox
17 |
18 | # Docs
19 | _build
20 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 |
4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
5 |
6 | if __name__ == "__main__":
7 | from django.core import management
8 |
9 | management.execute_from_command_line()
10 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. include:: ../README.rst
2 |
3 | .. toctree::
4 | :maxdepth: 2
5 | :caption: Contents:
6 |
7 | installation
8 | configuration
9 | advanced
10 | changelog
11 |
12 |
13 | Indices and tables
14 | ==================
15 |
16 | * :ref:`genindex`
17 | * :ref:`modindex`
18 | * :ref:`search`
19 |
--------------------------------------------------------------------------------
/tests/forms.py:
--------------------------------------------------------------------------------
1 | from allauth_2fa.forms import TOTPAuthenticateForm
2 | from allauth_2fa.forms import TOTPDeviceForm
3 | from allauth_2fa.forms import TOTPDeviceRemoveForm
4 |
5 |
6 | class CustomTOTPAuthenticateForm(TOTPAuthenticateForm):
7 | pass
8 |
9 |
10 | class CustomTOTPDeviceForm(TOTPDeviceForm):
11 | pass
12 |
13 |
14 | class CustomTOTPDeviceRemoveForm(TOTPDeviceRemoveForm):
15 | pass
16 |
--------------------------------------------------------------------------------
/allauth_2fa/templates/allauth_2fa/authenticate.html:
--------------------------------------------------------------------------------
1 | {% extends "account/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 |
6 | {% trans "Two-Factor Authentication" %}
7 |
8 |
9 |
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/allauth_2fa/templates/allauth_2fa/remove.html:
--------------------------------------------------------------------------------
1 | {% extends "account/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 |
6 | {% trans "Disable Two-Factor Authentication" %}
7 |
8 |
9 | {% if form.otp_token %}
10 | {% trans "Please enter a valid authentication token to disable two-factor authentication:" %}
11 | {% endif %}
12 |
13 |
20 |
21 | {% endblock %}
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2016-2021 Percipient Networks, LLC
2 | Copyright 2021-2022 Valohai
3 |
4 | Licensed under the Apache License, Version 2.0 (the "License");
5 | you may not use this file except in compliance with the License.
6 | You may obtain a copy of the License at
7 |
8 | http://www.apache.org/licenses/LICENSE-2.0
9 |
10 | Unless required by applicable law or agreed to in writing, software
11 | distributed under the License is distributed on an "AS IS" BASIS,
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | See the License for the specific language governing permissions and
14 | limitations under the License.
15 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | ci:
2 | autofix_prs: false
3 | autoupdate_schedule: quarterly
4 | default_language_version:
5 | python: python3
6 | repos:
7 | - repo: https://github.com/astral-sh/ruff-pre-commit
8 | rev: v0.12.8
9 | hooks:
10 | - id: ruff
11 | args: [--fix, --exit-non-zero-on-fix]
12 | - id: ruff-format
13 | - repo: https://github.com/pre-commit/pre-commit-hooks
14 | rev: v6.0.0
15 | hooks:
16 | - id: check-ast
17 | - id: check-merge-conflict
18 | - id: detect-private-key
19 | - repo: https://github.com/adamchainz/django-upgrade
20 | rev: 1.25.0
21 | hooks:
22 | - id: django-upgrade
23 | args:
24 | - --target-version=3.2
25 |
--------------------------------------------------------------------------------
/allauth_2fa/urls.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django.urls import path
4 |
5 | from allauth_2fa import views
6 |
7 | urlpatterns = [
8 | path(
9 | "authenticate/",
10 | views.TwoFactorAuthenticate.as_view(),
11 | name="two-factor-authenticate",
12 | ),
13 | path(
14 | "setup/",
15 | views.TwoFactorSetup.as_view(),
16 | name="two-factor-setup",
17 | ),
18 | path(
19 | "backup-tokens/",
20 | views.TwoFactorBackupTokens.as_view(),
21 | name="two-factor-backup-tokens",
22 | ),
23 | path(
24 | "remove/",
25 | views.TwoFactorRemove.as_view(),
26 | name="two-factor-remove",
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.decorators import login_required
2 | from django.http import HttpResponse
3 | from django.urls import include
4 | from django.urls import path
5 |
6 |
7 | def blank_view(request):
8 | return HttpResponse("HELLO WORLD!
")
9 |
10 |
11 | @login_required
12 | def login_required_view(request):
13 | return HttpResponse(f"Hello, {request.user}
")
14 |
15 |
16 | urlpatterns = [
17 | # Include the allauth and 2FA urls from their respective packages.
18 | path("accounts/2fa/", include("allauth_2fa.urls")),
19 | path("accounts/", include("allauth.urls")),
20 | # A view without a name.
21 | path("unnamed-view", blank_view),
22 | path("login-required-view", login_required_view, name="login-required-view"),
23 | ]
24 |
--------------------------------------------------------------------------------
/allauth_2fa/templates/allauth_2fa/setup.html:
--------------------------------------------------------------------------------
1 | {% extends "account/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 |
6 | {% trans "Setup Two-Factor Authentication" %}
7 |
8 |
9 |
10 | {% trans 'Step 1' %}:
11 |
12 |
13 |
14 | {% trans 'Scan the QR code below with a token generator of your choice (for instance Google Authenticator).' %}
15 |
16 |
17 |
18 |
19 | Secret: {{ secret }}
20 |
21 |
22 | {% trans 'Step 2' %}:
23 |
24 |
25 |
26 | {% trans 'Input a token generated by the app:' %}
27 |
28 |
29 |
38 | {% endblock %}
39 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SPHINXPROJ = django-allauth-2fa
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 | .PHONY: help Makefile
16 |
17 | livehtml:
18 | sphinx-autobuild --open-browser --watch=../allauth_2fa --watch=. "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
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 | echo @$(SPHINXBUILD) $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
24 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
25 |
--------------------------------------------------------------------------------
/allauth_2fa/templates/allauth_2fa/backup_tokens.html:
--------------------------------------------------------------------------------
1 | {% extends "account/base.html" %}
2 | {% load i18n %}
3 |
4 | {% block content %}
5 |
6 | {% trans "Two-Factor Authentication Backup Tokens" %}
7 |
8 |
9 | {% if backup_tokens %}
10 | {% if reveal_tokens %}
11 |
12 | {% for token in backup_tokens %}
13 | - {{ token.token }}
14 | {% endfor %}
15 |
16 | {% else %}
17 | {% trans 'Backup tokens have been generated, but are not revealed here for security reasons. Press the button below to generate new ones.' %}
18 | {% endif %}
19 | {% else %}
20 | {% trans 'No tokens. Press the button below to generate some.' %}
21 | {% endif %}
22 |
23 |
29 |
30 | Disable Two Factor
31 |
32 | {% endblock %}
33 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | # Tox (http://tox.testrun.org/) is a tool for running tests
2 | # in multiple virtualenvs. This configuration file will run the
3 | # test suite on all supported python versions. To use it, "pip install tox"
4 | # and then run "tox" from this directory.
5 |
6 | [tox]
7 | isolated_build = True
8 | envlist =
9 | py{38,39,310,311,312}-django{41,42}
10 |
11 | # Django main requires Python 3.12 or higher
12 | py{312}-django{main}
13 | skip_missing_interpreters = True
14 | requires =
15 | pip>=20.0
16 |
17 | [gh-actions]
18 | python =
19 | 3.8: py38
20 | 3.9: py39
21 | 3.10: py310
22 | 3.11: py311
23 | 3.12: py312
24 |
25 | [testenv]
26 | commands =
27 | pytest --cov . --cov-report html --cov-report term-missing --cov-report xml:{envdir}/coverage-{envname}.xml
28 | deps =
29 | .[test]
30 | django41: Django>=4.1,<4.2
31 | django42: Django>=4.2,<4.3
32 | djangomain: https://codeload.github.com/django/django/zip/main
33 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 | set SPHINXPROJ=django-allauth-2fa
13 |
14 | if "%1" == "" goto help
15 |
16 | %SPHINXBUILD% >NUL 2>NUL
17 | if errorlevel 9009 (
18 | echo.
19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
20 | echo.installed, then set the SPHINXBUILD environment variable to point
21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
22 | echo.may add the Sphinx directory to PATH.
23 | echo.
24 | echo.If you don't have Sphinx installed, grab it from
25 | echo.http://sphinx-doc.org/
26 | exit /b 1
27 | )
28 |
29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
30 | goto end
31 |
32 | :help
33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
34 |
35 | :end
36 | popd
37 |
--------------------------------------------------------------------------------
/allauth_2fa/mixins.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from django.contrib.auth.mixins import AccessMixin
4 | from django.http import HttpRequest
5 | from django.http import HttpResponse
6 | from django.http import HttpResponseRedirect
7 | from django.urls import reverse_lazy
8 |
9 | from allauth_2fa.utils import user_has_valid_totp_device
10 |
11 |
12 | class ValidTOTPDeviceRequiredMixin(AccessMixin):
13 | no_valid_totp_device_url = reverse_lazy("two-factor-setup")
14 |
15 | def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
16 | if not request.user.is_authenticated:
17 | return self.handle_no_permission()
18 | if not user_has_valid_totp_device(request.user):
19 | return self.handle_missing_totp_device()
20 | return super().dispatch(request, *args, **kwargs)
21 |
22 | def handle_missing_totp_device(self) -> HttpResponse:
23 | return HttpResponseRedirect(self.no_valid_totp_device_url)
24 |
--------------------------------------------------------------------------------
/docs/advanced.rst:
--------------------------------------------------------------------------------
1 | Advanced Configuration
2 | ----------------------
3 |
4 | Forcing a User to Use 2FA
5 | '''''''''''''''''''''''''
6 |
7 | A ``User`` can be forced to use 2FA based on any requirements (e.g. superusers
8 | or being in a particular group). This is implemented by subclassing the
9 | ``allauth_2fa.middleware.BaseRequire2FAMiddleware`` and implementing the
10 | ``require_2fa`` method on it. This middleware needs to be added to your
11 | ``MIDDLEWARE_CLASSES`` setting.
12 |
13 | For example, to require superusers to use 2FA:
14 |
15 | .. code-block:: python
16 |
17 | from allauth_2fa.middleware import BaseRequire2FAMiddleware
18 |
19 | class RequireSuperuser2FAMiddleware(BaseRequire2FAMiddleware):
20 | def require_2fa(self, request):
21 | # Superusers are required to have 2FA.
22 | return request.user.is_superuser
23 |
24 | If the user doesn't have 2FA enabled, then they will be redirected to the 2FA
25 | configuration page and will not be allowed to access (most) other pages.
26 |
--------------------------------------------------------------------------------
/docs/configuration.rst:
--------------------------------------------------------------------------------
1 | Configuration
2 | =============
3 |
4 | ``ALLAUTH_2FA_TEMPLATE_EXTENSION``
5 | ----------------------------------
6 |
7 | Allows you to override the extension for the templates used by the internal
8 | views of django-allauth-2fa.
9 |
10 | Defaults to ``ACCOUNT_TEMPLATE_EXTENSION`` from allauth, which is ``html`` by
11 | default.
12 |
13 | This can be used to allow a different template engine for 2FA views.
14 |
15 | ``ALLAUTH_2FA_ALWAYS_REVEAL_BACKUP_TOKENS``
16 | -------------------------------------------
17 |
18 | Whether to always show the remaining backup tokens on the
19 | Backup Tokens view, or only when they're freshly generated.
20 |
21 | Defaults to ``True``.
22 |
23 | ``ALLAUTH_2FA_REMOVE_SUCCESS_URL``
24 | -----------------------------------
25 |
26 | The URL name to redirect to after removing a 2FA device.
27 |
28 | ``ALLAUTH_2FA_SETUP_SUCCESS_URL``
29 | ----------------------------------
30 |
31 | The URL name to redirect to after setting up a 2FA device.
32 |
33 | ``ALLAUTH_2FA_FORMS``
34 | ----------------------------------
35 |
36 | Used to override forms, for example:
37 | ``{'authenticate': 'myapp.forms.TOTPAuthenticateForm'}``
38 |
39 | Possible keys (and default values):
40 |
41 | * ``authenticate``: :class:`allauth_2fa.forms.TOTPAuthenticateForm`
42 | * ``setup``: :class:`allauth_2fa.forms.TOTPDeviceForm`
43 | * ``remove``: :class:`allauth_2fa.forms.TOTPDeviceRemoveForm`
44 |
45 | ``ALLAUTH_2FA_BACKUP_TOKENS_NUMBER``
46 | ----------------------------------
47 |
48 | Sets the number of generated backup tokens.
49 |
50 | Defaults to ``3``.
51 |
--------------------------------------------------------------------------------
/allauth_2fa/management/commands/allauth_2fa_migrate.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import base64
4 |
5 | from allauth.mfa.adapter import get_adapter
6 | from allauth.mfa.models import Authenticator
7 | from django.core.management.base import BaseCommand
8 | from django_otp.plugins.otp_static.models import StaticDevice
9 | from django_otp.plugins.otp_totp.models import TOTPDevice
10 |
11 |
12 | class Command(BaseCommand):
13 | def handle(self, **options):
14 | adapter = get_adapter()
15 | authenticators = []
16 | for totp in TOTPDevice.objects.filter(confirmed=True).iterator():
17 | recovery_codes = set()
18 | for sdevice in StaticDevice.objects.filter(
19 | confirmed=True,
20 | user_id=totp.user_id,
21 | ).iterator():
22 | recovery_codes.update(sdevice.token_set.values_list("token", flat=True))
23 | secret = base64.b32encode(bytes.fromhex(totp.key)).decode("ascii")
24 | totp_authenticator = Authenticator(
25 | user_id=totp.user_id,
26 | type=Authenticator.Type.TOTP,
27 | data={"secret": adapter.encrypt(secret)},
28 | )
29 | authenticators.append(totp_authenticator)
30 | authenticators.append(
31 | Authenticator(
32 | user_id=totp.user_id,
33 | type=Authenticator.Type.RECOVERY_CODES,
34 | data={
35 | "migrated_codes": [adapter.encrypt(c) for c in recovery_codes],
36 | },
37 | ),
38 | )
39 | Authenticator.objects.bulk_create(authenticators)
40 | self.stdout.write(f"Created {len(authenticators)} Authenticators")
41 |
--------------------------------------------------------------------------------
/allauth_2fa/app_settings.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from allauth.account import app_settings as allauth_settings
4 | from django.conf import settings
5 |
6 |
7 | class _AppSettings:
8 | @property
9 | def TEMPLATE_EXTENSION(self) -> str:
10 | return getattr(
11 | settings,
12 | "ALLAUTH_2FA_TEMPLATE_EXTENSION",
13 | allauth_settings.TEMPLATE_EXTENSION,
14 | )
15 |
16 | @property
17 | def ALWAYS_REVEAL_BACKUP_TOKENS(self) -> bool:
18 | return bool(getattr(settings, "ALLAUTH_2FA_ALWAYS_REVEAL_BACKUP_TOKENS", True))
19 |
20 | @property
21 | def REMOVE_SUCCESS_URL(self) -> str:
22 | return getattr(
23 | settings,
24 | "ALLAUTH_2FA_REMOVE_SUCCESS_URL",
25 | "two-factor-setup",
26 | )
27 |
28 | @property
29 | def SETUP_SUCCESS_URL(self) -> str:
30 | return getattr(
31 | settings,
32 | "ALLAUTH_2FA_SETUP_SUCCESS_URL",
33 | "two-factor-backup-tokens",
34 | )
35 |
36 | @property
37 | def FORMS(self) -> dict:
38 | return getattr(settings, "ALLAUTH_2FA_FORMS", {})
39 |
40 | @property
41 | def REQUIRE_OTP_ON_DEVICE_REMOVAL(self) -> bool:
42 | return getattr(
43 | settings,
44 | "ALLAUTH_2FA_REQUIRE_OTP_ON_DEVICE_REMOVAL",
45 | True,
46 | )
47 |
48 | @property
49 | def BACKUP_TOKENS_NUMBER(self) -> int:
50 | return getattr(
51 | settings,
52 | "ALLAUTH_2FA_BACKUP_TOKENS_NUMBER",
53 | 3,
54 | )
55 |
56 |
57 | _app_settings = _AppSettings()
58 |
59 |
60 | def __getattr__(name: str):
61 | # See https://peps.python.org/pep-0562/ :)
62 | return getattr(_app_settings, name)
63 |
--------------------------------------------------------------------------------
/allauth_2fa/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from base64 import b32encode
4 | from io import BytesIO
5 | from urllib.parse import quote
6 | from urllib.parse import urlencode
7 |
8 | import qrcode
9 | from django.http import HttpRequest
10 | from django_otp.models import Device
11 | from qrcode.image.svg import SvgPathImage
12 |
13 |
14 | def get_device_base32_secret(device: Device) -> str:
15 | return b32encode(device.bin_key).decode("utf-8")
16 |
17 |
18 | def generate_totp_config_svg(device: Device, issuer: str, label: str) -> bytes:
19 | params = {
20 | "secret": get_device_base32_secret(device),
21 | "algorithm": "SHA1",
22 | "digits": device.digits,
23 | "period": device.step,
24 | "issuer": issuer,
25 | }
26 |
27 | otpauth_url = f"otpauth://totp/{quote(label)}?{urlencode(params)}"
28 |
29 | img = qrcode.make(otpauth_url, image_factory=SvgPathImage)
30 | io = BytesIO()
31 | img.save(io)
32 | return io.getvalue()
33 |
34 |
35 | def user_has_valid_totp_device(user) -> bool:
36 | if not user.is_authenticated:
37 | return False
38 | return user.totpdevice_set.filter(confirmed=True).exists()
39 |
40 |
41 | def get_next_query_string(request: HttpRequest) -> str | None:
42 | """
43 | Get the query string (including the prefix `?`) to
44 | redirect to after a successful POST.
45 |
46 | If a query string can't be determined, returns None.
47 | """
48 | # If the view function smells like a class-based view,
49 | # we can interrogate it.
50 | try:
51 | view = request.resolver_match.func.view_class()
52 | redirect_field_name = view.redirect_field_name
53 | except AttributeError:
54 | # Interrogation failed :(
55 | return None
56 |
57 | view.request = request
58 | query_params = request.GET.copy()
59 | success_url = view.get_success_url()
60 | if success_url:
61 | query_params[redirect_field_name] = success_url
62 | if query_params:
63 | return f"?{urlencode(query_params)}"
64 | return None
65 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | DEBUG = True
2 | SECRET_KEY = "not_empty"
3 | SITE_ID = 1
4 | ALLOWED_HOSTS = ["*"]
5 |
6 | DATABASES = {
7 | "default": {
8 | "ENGINE": "django.db.backends.sqlite3",
9 | "NAME": "db.sqlite",
10 | "TEST": {
11 | "NAME": ":memory:",
12 | },
13 | },
14 | }
15 |
16 | ROOT_URLCONF = "tests.urls"
17 | LOGIN_REDIRECT_URL = "/accounts/password/change/"
18 |
19 | TEMPLATES = [
20 | {
21 | "BACKEND": "django.template.backends.django.DjangoTemplates",
22 | "DIRS": [],
23 | "APP_DIRS": True,
24 | "OPTIONS": {
25 | "context_processors": [
26 | "django.template.context_processors.debug",
27 | "django.template.context_processors.request",
28 | "django.contrib.auth.context_processors.auth",
29 | "django.contrib.messages.context_processors.messages",
30 | ],
31 | },
32 | },
33 | ]
34 |
35 | EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
36 |
37 | INSTALLED_APPS = (
38 | # Required by allauth.
39 | "django.contrib.sites",
40 | # Configure Django auth package.
41 | "django.contrib.auth",
42 | "django.contrib.contenttypes",
43 | "django.contrib.sessions",
44 | # Enable allauth.
45 | "allauth",
46 | "allauth.account",
47 | "allauth.mfa", # For testing the migration.
48 | # Required to render the default template for 'account_login'.
49 | "allauth.socialaccount",
50 | # Configure the django-otp package.
51 | "django_otp",
52 | "django_otp.plugins.otp_totp",
53 | "django_otp.plugins.otp_static",
54 | # Enable two-factor auth.
55 | "allauth_2fa",
56 | # Test app.
57 | "tests",
58 | )
59 |
60 | try:
61 | import django_extensions # noqa: F401
62 |
63 | INSTALLED_APPS += ("django_extensions",)
64 | except ImportError:
65 | pass
66 |
67 | MIDDLEWARE = (
68 | # Configure Django auth package.
69 | "django.contrib.sessions.middleware.SessionMiddleware",
70 | "django.contrib.auth.middleware.AuthenticationMiddleware",
71 | # Configure the django-otp package.
72 | "django_otp.middleware.OTPMiddleware",
73 | # Reset login flow middleware.
74 | "allauth_2fa.middleware.AllauthTwoFactorMiddleware",
75 | # Allauth account middleware.
76 | "allauth.account.middleware.AccountMiddleware",
77 | )
78 |
79 |
80 | AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",)
81 | PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",)
82 |
83 | # Enable two-factor auth.
84 | ACCOUNT_ADAPTER = "allauth_2fa.adapter.OTPAdapter"
85 |
86 | STATIC_URL = "/static/"
87 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "django-allauth-2fa"
7 | dynamic = ["version"]
8 | description = "Adds two factor authentication to django-allauth"
9 | readme = "README.rst"
10 | license = "Apache-2.0"
11 | requires-python = ">=3.8"
12 | authors = [
13 | { name = "Víðir Valberg Guðmundsson", email = "valberg@orn.li" },
14 | ]
15 |
16 | keywords = [
17 | "2fa",
18 | "allauth",
19 | "auth",
20 | "authentication",
21 | "django",
22 | "factor",
23 | "otp",
24 | "two",
25 | ]
26 | classifiers = [
27 | "Development Status :: 4 - Beta",
28 | "Environment :: Web Environment",
29 | "Framework :: Django",
30 | "Framework :: Django :: 4.1",
31 | "Framework :: Django :: 4.2",
32 | "Intended Audience :: Developers",
33 | "License :: OSI Approved :: Apache Software License",
34 | "Programming Language :: Python",
35 | "Programming Language :: Python :: 3.8",
36 | "Programming Language :: Python :: 3.9",
37 | "Programming Language :: Python :: 3.10",
38 | "Programming Language :: Python :: 3.11",
39 | "Topic :: Internet",
40 | "Topic :: Software Development :: Libraries :: Python Modules",
41 | ]
42 |
43 | dependencies = [
44 | # Pinned to due to backward-incompatible template changes in 0.58.0.
45 | "django-allauth>=0.53.0,<0.58.0",
46 | "django-otp>=1.2.0",
47 | "django>=4.1",
48 | "qrcode>=5.3",
49 | ]
50 |
51 | [project.urls]
52 | Homepage = "https://github.com/valohai/django-allauth-2fa"
53 |
54 | [project.optional-dependencies]
55 | test = [
56 | "pytest-cov==4.1.0",
57 | "pytest-django==4.5.2",
58 | ]
59 | docs = [
60 | "Sphinx==1.6.5",
61 | "sphinx-autobuild==0.7.1", # To auto-build docs as they're edited.
62 | "sphinx-rtd-theme==0.2.4", # The Read the Docs theme for Sphinx.
63 | ]
64 |
65 | [tool.hatch.envs.default]
66 | features = ["test"]
67 |
68 | [tool.hatch.version]
69 | path = "allauth_2fa/__init__.py"
70 |
71 | [tool.hatch.build.targets.sdist]
72 | include = [
73 | "/allauth_2fa/**/*.py",
74 | "/allauth_2fa/**/*.html",
75 | "/allauth_2fa/py.typed",
76 | ]
77 |
78 | [tool.hatch.build.targets.wheel]
79 | include = [
80 | "/allauth_2fa/**/*.py",
81 | "/allauth_2fa/**/*.html",
82 | "/allauth_2fa/py.typed",
83 | ]
84 |
85 | [tool.ruff]
86 | target-version = "py38"
87 |
88 | [tool.ruff.lint]
89 | exclude = [
90 | ".git",
91 | ".tox",
92 | "__pycache__",
93 | ]
94 | select = [
95 | "B",
96 | "COM",
97 | "E",
98 | "F",
99 | "I",
100 | "S",
101 | "UP",
102 | "W",
103 | ]
104 |
105 | [tool.ruff.lint.isort]
106 | force-single-line = true
107 |
108 | [tool.ruff.lint.per-file-ignores]
109 | "tests/**" = ["S101", "S105"]
110 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - main
6 | tags:
7 | - 'v*'
8 | pull_request:
9 | branches:
10 | - main
11 | jobs:
12 | Lint:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v4
16 | - uses: actions/setup-python@v5
17 | with:
18 | python-version: "3.11"
19 | - uses: pre-commit/action@v3.0.1
20 | Test:
21 | runs-on: ubuntu-24.04
22 | strategy:
23 | matrix:
24 | include:
25 | - python-version: "3.8"
26 | - python-version: "3.9"
27 | - python-version: "3.10"
28 | - python-version: "3.11"
29 | - python-version: "3.12"
30 | - python-version: "3.12"
31 | djangomain: "djangomain"
32 |
33 | steps:
34 | - uses: actions/checkout@v4
35 | - name: "Set up Python ${{ matrix.python-version }}"
36 | uses: actions/setup-python@v5
37 | with:
38 | python-version: "${{ matrix.python-version }}"
39 | cache: pip
40 | cache-dependency-path: |
41 | **/requirements*.txt
42 | **/setup.cfg
43 | - run: pip install -U pip wheel setuptools
44 | - run: pip install -U tox tox-gh-actions
45 | - if: ${{ matrix.djangomain != 'djangomain' }}
46 | name: "Run tox targets for ${{ matrix.python-version }}"
47 | env:
48 | TOX_SKIP_ENV: ".*djangomain.*"
49 | run: "python -m tox"
50 | - if: ${{ matrix.djangomain == 'djangomain' }}
51 | name: "Run tox targets for ${{ matrix.python-version }} for django main"
52 | env:
53 | TOX_SKIP_ENV: ".*django[^m].*"
54 | run: "python -m tox"
55 | - run: |
56 | mkdir /tmp/coverage
57 | find .tox -type f -name 'coverage*xml' -exec mv '{}' /tmp/coverage ';'
58 | - uses: codecov/codecov-action@v5
59 | with:
60 | directory: /tmp/coverage
61 | Build:
62 | runs-on: ubuntu-24.04
63 | steps:
64 | - uses: actions/checkout@v4
65 | - uses: actions/setup-python@v5
66 | with:
67 | python-version: "3.11"
68 | cache: pip
69 | cache-dependency-path: |
70 | **/requirements*.txt
71 | **/setup.cfg
72 | - run: pip install -U build
73 | - run: python -m build
74 | - uses: actions/upload-artifact@v4
75 | with:
76 | name: dist
77 | path: dist/
78 | Publish:
79 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
80 | needs:
81 | - Build
82 | name: Upload release to PyPI
83 | runs-on: ubuntu-latest
84 | environment:
85 | name: release
86 | url: https://pypi.org/p/django-allauth-2fa/
87 | permissions:
88 | id-token: write
89 | steps:
90 | - uses: actions/download-artifact@v5
91 | with:
92 | name: dist
93 | path: dist/
94 | - name: Publish package distributions to PyPI
95 | uses: pypa/gh-action-pypi-publish@release/v1
96 | with:
97 | verbose: true
98 | print-hash: true
99 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -- General configuration ------------------------------------------------
2 | # Add any Sphinx extension module names here, as strings.
3 | extensions = [
4 | "sphinx.ext.autodoc",
5 | "sphinx.ext.intersphinx",
6 | "sphinx.ext.viewcode",
7 | ]
8 |
9 | # Add any paths that contain templates here, relative to this directory.
10 | templates_path = ["_templates"]
11 |
12 | # The suffix(es) of source filenames.
13 | source_suffix = ".rst"
14 |
15 | # The master toctree document.
16 | master_doc = "index"
17 |
18 | # General information about the project.
19 | project = "django-allauth-2fa"
20 | copyright = "2017, Víðir Valberg Guðmundsson, Percipient Networks"
21 | author = "Víðir Valberg Guðmundsson, Percipient Networks"
22 |
23 | # The version info for the project you're documenting, acts as replacement for
24 | # |version| and |release|, also used in various other places throughout the
25 | # built documents.
26 | #
27 | # The short X.Y version.
28 | version = "0.4.3"
29 | # The full version, including alpha/beta/rc tags.
30 | release = "0.4.3"
31 |
32 | # The language for content autogenerated by Sphinx. Refer to documentation
33 | # for a list of supported languages.
34 | language = None
35 |
36 | # List of patterns, relative to source directory, that match files and
37 | # directories to ignore when looking for source files.
38 | # This patterns also effect to html_static_path and html_extra_path
39 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
40 |
41 | # The name of the Pygments (syntax highlighting) style to use.
42 | pygments_style = "sphinx"
43 |
44 | # If true, `todo` and `todoList` produce output, else they produce nothing.
45 | todo_include_todos = False
46 |
47 |
48 | # -- Options for HTML output ----------------------------------------------
49 | html_theme = "sphinx_rtd_theme"
50 |
51 |
52 | # -- Options for HTMLHelp output ------------------------------------------
53 |
54 | # Output file base name for HTML help builder.
55 | htmlhelp_basename = "django-allauth-2fadoc"
56 |
57 |
58 | # -- Options for LaTeX output ---------------------------------------------
59 |
60 | latex_elements = {}
61 |
62 | # Grouping the document tree into LaTeX files.
63 | latex_documents = [
64 | (
65 | master_doc,
66 | "django-allauth-2fa.tex",
67 | "django-allauth-2fa Documentation",
68 | "Víðir Valberg Guðmundsson, Percipient Networks",
69 | "manual",
70 | ),
71 | ]
72 |
73 |
74 | # -- Options for manual page output ---------------------------------------
75 |
76 | man_pages = [
77 | (master_doc, "django-allauth-2fa", "django-allauth-2fa Documentation", [author], 1),
78 | ]
79 |
80 |
81 | # -- Options for Texinfo output -------------------------------------------
82 |
83 | texinfo_documents = [
84 | (
85 | master_doc,
86 | "django-allauth-2fa",
87 | "django-allauth-2fa Documentation",
88 | author,
89 | "django-allauth-2fa",
90 | "One line description of project.",
91 | "Miscellaneous",
92 | ),
93 | ]
94 |
95 |
96 | # Example configuration for intersphinx: refer to the Python standard library.
97 | intersphinx_mapping = {"https://docs.python.org/": None}
98 |
--------------------------------------------------------------------------------
/docs/installation.rst:
--------------------------------------------------------------------------------
1 | Installation
2 | ------------
3 |
4 | Install `django-allauth-2fa` with pip (note that this will install Django,
5 | django-allauth, django-otp, qrcode and all of their requirements):
6 |
7 | .. _django-otp: https://bitbucket.org/psagers/django-otp/
8 | .. _qrcode: https://github.com/lincolnloop/python-qrcode
9 |
10 | .. code-block:: bash
11 |
12 | pip install django-allauth-2fa
13 |
14 | After all the pre-requisities are installed, django-allauth and django-otp must
15 | be configured in your Django settings file. (Please check the
16 | `django-allauth documentation`_ and `django-otp documentation`_ for more
17 | in-depth steps on their configuration.)
18 |
19 | .. _django-allauth documentation: https://django-allauth.readthedocs.io/en/latest/installation.html
20 | .. _django-otp documentation: https://django-otp-official.readthedocs.io/en/latest/overview.html#installation
21 |
22 | .. code-block:: python
23 |
24 | INSTALLED_APPS = (
25 | # Required by allauth.
26 | 'django.contrib.sites',
27 |
28 | # Configure Django auth package.
29 | 'django.contrib.auth',
30 | 'django.contrib.contenttypes',
31 | 'django.contrib.sessions',
32 |
33 | # Enable allauth.
34 | 'allauth',
35 | 'allauth.account',
36 |
37 | # Configure the django-otp package.
38 | 'django_otp',
39 | 'django_otp.plugins.otp_totp',
40 | 'django_otp.plugins.otp_static',
41 |
42 | # Enable two-factor auth.
43 | 'allauth_2fa',
44 | )
45 |
46 | MIDDLEWARE_CLASSES = (
47 | # Configure Django auth package.
48 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
49 |
50 | # Configure the django-otp package. Note this must be after the
51 | # AuthenticationMiddleware.
52 | 'django_otp.middleware.OTPMiddleware',
53 |
54 | # Reset login flow middleware. If this middleware is included, the login
55 | # flow is reset if another page is loaded between login and successfully
56 | # entering two-factor credentials.
57 | 'allauth_2fa.middleware.AllauthTwoFactorMiddleware',
58 | )
59 |
60 | # Set the allauth adapter to be the 2FA adapter.
61 | ACCOUNT_ADAPTER = 'allauth_2fa.adapter.OTPAdapter'
62 |
63 | # Configure your default site. See
64 | # https://docs.djangoproject.com/en/dev/ref/settings/#sites.
65 | SITE_ID = 1
66 |
67 | After the above is configure, you must run migrations.
68 |
69 | .. code-block:: bash
70 |
71 | python manage.py migrate
72 |
73 | Finally, you must include the django-allauth-2fa URLs:
74 |
75 | .. code-block:: python
76 |
77 | from django.urls import include, path
78 |
79 | urlpatterns = [
80 | # Include the allauth and 2FA urls from their respective packages.
81 | path('accounts/two-factor/', include('allauth_2fa.urls')),
82 | path('accounts/', include('allauth.urls')),
83 | ]
84 |
85 | .. warning::
86 |
87 | Any login view that is *not* provided by django-allauth will bypass the
88 | allauth workflow (including two-factor authentication). The Django admin
89 | site includes an additional login view (usually available at
90 | ``/admin/login``).
91 |
92 | The easiest way to fix this is to wrap it in ``staff_member_required`` decorator
93 | and disallow access to the admin site to all, except logged in staff members
94 | through allauth workflow.
95 | (the code only works if you use the standard admin site, if you have a
96 | custom admin site you'll need to customize this more):
97 |
98 | .. code-block:: python
99 |
100 | from django.contrib import admin
101 | from django.contrib.admin.views.decorators import staff_member_required
102 |
103 | # Ensure users go through the allauth workflow when logging into admin.
104 | admin.site.login = staff_member_required(admin.site.login, login_url='/accounts/login')
105 | # Run the standard admin set-up.
106 | admin.autodiscover()
107 |
--------------------------------------------------------------------------------
/allauth_2fa/adapter.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from allauth.account.adapter import DefaultAccountAdapter
4 | from allauth.socialaccount.models import SocialLogin
5 | from django.core.exceptions import PermissionDenied
6 | from django.http import HttpRequest
7 | from django.http import HttpResponse
8 | from django.http import HttpResponseRedirect
9 | from django.urls import reverse
10 |
11 | from allauth_2fa.utils import get_next_query_string
12 | from allauth_2fa.utils import user_has_valid_totp_device
13 |
14 | try:
15 | from allauth.core.exceptions import ImmediateHttpResponse
16 | except ImportError:
17 | from allauth.exceptions import ImmediateHttpResponse
18 |
19 |
20 | class OTPAdapter(DefaultAccountAdapter):
21 | def has_2fa_enabled(self, user) -> bool:
22 | """Returns True if the user has 2FA configured."""
23 | return user_has_valid_totp_device(user)
24 |
25 | def pre_login(
26 | self,
27 | request: HttpRequest,
28 | user,
29 | **kwargs,
30 | ) -> HttpResponse | None:
31 | response = super().pre_login(request, user, **kwargs)
32 | if response:
33 | return response
34 |
35 | # Require two-factor authentication if it has been configured.
36 | if self.has_2fa_enabled(user):
37 | self.stash_pending_login(request, user, kwargs)
38 | redirect_url = self.get_2fa_authenticate_url(request)
39 | raise ImmediateHttpResponse(response=HttpResponseRedirect(redirect_url))
40 |
41 | # Otherwise defer to the original allauth adapter.
42 | return super().login(request, user)
43 |
44 | def get_2fa_authenticate_url(self, request: HttpRequest) -> str:
45 | """
46 | Get the URL to redirect to for finishing 2FA authentication.
47 | """
48 | redirect_url = reverse("two-factor-authenticate")
49 |
50 | # Add "next" parameter to the URL if possible.
51 | query_string = get_next_query_string(request)
52 | if query_string:
53 | redirect_url += query_string
54 |
55 | return redirect_url
56 |
57 | def stash_pending_login(
58 | self,
59 | request: HttpRequest,
60 | user,
61 | login_kwargs: dict,
62 | ) -> None:
63 | """Here, we're going to stash the pending login so that it can be
64 | resumed at a later point. The `login_kwargs` contain meta information on
65 | the login which we need to store.
66 |
67 | The `login_kwargs` passed is a dictionary, and we're going to store that
68 | in the session. While doing so, we need to mutate it a bit to ensure
69 | serializability. Mutating a dictionary passed to us is not something
70 | the caller expects, so we're going to make a shallow copy to prevent the
71 | caller from being impacted. Shallow is fine, as we're only setting new
72 | keys and not altering existing values.
73 | """
74 | # Cast to string for the case when this is not a JSON serializable
75 | # object, e.g. a UUID.
76 | request.session["allauth_2fa_user_id"] = str(user.id)
77 | login_kwargs = login_kwargs.copy()
78 | signal_kwargs = login_kwargs.get("signal_kwargs")
79 | if signal_kwargs:
80 | sociallogin = signal_kwargs.get("sociallogin")
81 | if sociallogin:
82 | signal_kwargs = signal_kwargs.copy()
83 | signal_kwargs["sociallogin"] = sociallogin.serialize()
84 | login_kwargs["signal_kwargs"] = signal_kwargs
85 | request.session["allauth_2fa_login"] = login_kwargs
86 |
87 | def unstash_pending_login_kwargs(self, request: HttpRequest) -> dict:
88 | login_kwargs = request.session.pop("allauth_2fa_login", None)
89 | if login_kwargs is None:
90 | raise PermissionDenied()
91 | signal_kwargs = login_kwargs.get("signal_kwargs")
92 | if signal_kwargs:
93 | sociallogin = signal_kwargs.get("sociallogin")
94 | if sociallogin:
95 | signal_kwargs["sociallogin"] = SocialLogin.deserialize(sociallogin)
96 | return login_kwargs
97 |
--------------------------------------------------------------------------------
/allauth_2fa/forms.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import contextlib
4 |
5 | from django import forms
6 | from django.core.exceptions import ImproperlyConfigured
7 | from django.core.exceptions import ObjectDoesNotExist
8 | from django.utils.translation import gettext_lazy as _
9 | from django_otp.forms import OTPAuthenticationFormMixin
10 | from django_otp.plugins.otp_totp.models import TOTPDevice
11 |
12 | from allauth_2fa import app_settings
13 |
14 | DEFAULT_TOKEN_WIDGET_ATTRS = {
15 | "autofocus": "autofocus",
16 | "autocomplete": "off",
17 | "inputmode": "numeric",
18 | }
19 |
20 |
21 | class _TokenToOTPTokenMixin:
22 | def __init__(self, **kwargs):
23 | super().__init__(**kwargs)
24 |
25 | if "token" in self.fields or "token" in self.data:
26 | self._raise_token_exception()
27 |
28 | @property
29 | def token(self):
30 | self._raise_token_exception()
31 |
32 | def _raise_token_exception(self):
33 | raise ImproperlyConfigured(
34 | f"The field `token` in {self} has been renamed to `otp_token`.",
35 | )
36 |
37 |
38 | class TOTPAuthenticateForm(OTPAuthenticationFormMixin, forms.Form):
39 | otp_token = forms.CharField(
40 | label=_("Token"),
41 | )
42 |
43 | def __init__(self, user, **kwargs):
44 | super().__init__(**kwargs)
45 | self.fields["otp_token"].widget.attrs.update(DEFAULT_TOKEN_WIDGET_ATTRS)
46 | self.user = user
47 |
48 | def clean(self) -> dict:
49 | self.clean_otp(self.user)
50 | return self.cleaned_data
51 |
52 |
53 | class TOTPDeviceForm(_TokenToOTPTokenMixin, forms.Form):
54 | otp_token = forms.CharField(
55 | label=_("Token"),
56 | )
57 |
58 | def __init__(self, user, metadata=None, **kwargs):
59 | super().__init__(**kwargs)
60 | self.fields["otp_token"].widget.attrs.update(DEFAULT_TOKEN_WIDGET_ATTRS)
61 | self.user = user
62 | self.metadata = metadata or {}
63 |
64 | def clean_otp_token(self):
65 | token = self.cleaned_data.get("otp_token")
66 |
67 | # Find the unconfirmed device and attempt to verify the token.
68 | self.device = self.user.totpdevice_set.filter(confirmed=False).first()
69 | if not self.device.verify_token(token):
70 | raise forms.ValidationError(_("The entered token is not valid"))
71 |
72 | return token
73 |
74 | def save(self) -> TOTPDevice:
75 | # The device was found to be valid, delete other confirmed devices and
76 | # confirm the new device.
77 | self.user.totpdevice_set.filter(confirmed=True).delete()
78 | self.device.confirmed = True
79 | self.device.save()
80 |
81 | return self.device
82 |
83 |
84 | class TOTPDeviceRemoveForm(
85 | _TokenToOTPTokenMixin,
86 | OTPAuthenticationFormMixin,
87 | forms.Form,
88 | ):
89 | def __init__(self, user, **kwargs):
90 | super().__init__(**kwargs)
91 |
92 | self.user = user
93 | # user has to enter OTP token to remove device
94 | # if REQUIRE_OTP_ON_DEVICE_REMOVAL is True
95 | if app_settings.REQUIRE_OTP_ON_DEVICE_REMOVAL:
96 | self.fields["otp_token"] = forms.CharField(label=_("Token"), required=True)
97 | self.fields["otp_token"].widget.attrs.update(DEFAULT_TOKEN_WIDGET_ATTRS)
98 |
99 | def clean(self):
100 | # clean OTP token if REQUIRE_OTP_ON_DEVICE_REMOVAL is True
101 | if app_settings.REQUIRE_OTP_ON_DEVICE_REMOVAL:
102 | self.clean_otp(self.user)
103 | return self.cleaned_data
104 |
105 | def save(self) -> None:
106 | with contextlib.suppress(ObjectDoesNotExist):
107 | # Delete any backup tokens and their related static device.
108 | static_device = self.user.staticdevice_set.get(name="backup")
109 | static_device.token_set.all().delete()
110 | static_device.delete()
111 |
112 | # Delete TOTP device.
113 | device = TOTPDevice.objects.get(user=self.user)
114 | device.delete()
115 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Welcome to django-allauth-2fa!
2 | ==============================
3 |
4 | .. image:: https://github.com/valohai/django-allauth-2fa/actions/workflows/ci.yml/badge.svg
5 | :target: https://github.com/valohai/django-allauth-2fa/actions/workflows/ci.yml
6 |
7 | .. image:: https://codecov.io/gh/valohai/django-allauth-2fa/branch/main/graph/badge.svg
8 | :target: https://codecov.io/gh/valohai/django-allauth-2fa
9 |
10 | .. image:: https://readthedocs.org/projects/django-allauth-2fa/badge/?version=latest
11 | :target: https://django-allauth-2fa.readthedocs.io/
12 |
13 | django-allauth-2fa adds `two-factor authentication`_ to
14 | versions of `django-allauth`_ older than 0.58.0.
15 |
16 | For newer versions, you should use django-allauth's `built-in MFA support`_.
17 | Please see `issue #189`_ for more information.
18 |
19 | django-allauth is a set of `Django`_ applications which help with
20 | authentication, registration, and other account management tasks.
21 |
22 | Source code
23 | http://github.com/percipient/django-allauth-2fa
24 | Documentation
25 | https://django-allauth-2fa.readthedocs.io/
26 |
27 | .. _two-factor authentication: https://en.wikipedia.org/wiki/Multi-factor_authentication
28 | .. _django-allauth: https://github.com/pennersr/django-allauth
29 | .. _Django: https://www.djangoproject.com/
30 | .. _built-in MFA support: https://docs.allauth.org/en/latest/mfa/introduction.html
31 | .. _issue #189: https://github.com/valohai/django-allauth-2fa/issues/189
32 |
33 | Features
34 | --------
35 |
36 | * Adds `two-factor authentication`_ views and workflow to `django-allauth`_.
37 | * Supports Authenticator apps via a QR code when enabling 2FA.
38 | * Supports single-use back-up codes.
39 |
40 | Compatibility
41 | -------------
42 |
43 | django-allauth-2fa is _not_ compatible with django-allauth versions newer than
44 | 0.58.0.
45 |
46 | django-allauth has a built-in MFA implementation since version 0.56.0,
47 | which is likely preferable to this one.
48 |
49 | django-allauth-2fa attempts to maintain compatibility with supported versions of
50 | Django, django-allauth, and django-otp.
51 |
52 | Current versions supported together is:
53 |
54 | ======== ============== ============== ========================
55 | Django django-allauth django-otp Python
56 | ======== ============== ============== ========================
57 | 4.1 0.57.2 1.2 3.8, 3.9, 3.10, 3.11
58 | 4.2 0.57.2 1.2 3.8, 3.9, 3.10, 3.11
59 | ======== ============== ============== ========================
60 |
61 | Contributing
62 | ------------
63 |
64 | django-allauth-2fa was initially created by
65 | `Víðir Valberg Guðmundsson (@valberg)`_, was maintained by
66 | `Percipient Networks`_ for many years, and finally by
67 | `Valohai`_.
68 |
69 | Please feel free to contribute if you find django-allauth-2fa useful,
70 | but do note that you should likely be using allauth.mfa instead.
71 |
72 | #. Check for open issues or open a fresh issue to start a discussion
73 | around a feature idea or a bug.
74 | #. If you feel uncomfortable or uncertain about an issue or your changes,
75 | feel free to email support@percipientnetworks.com and we will happily help you.
76 | #. Fork `the repository`_ on GitHub to start making your changes to the
77 | **main** branch (or branch off of it).
78 | #. Write a test which shows that the bug was fixed or that the feature
79 | works as expected.
80 | #. Send a pull request and bug the maintainer until it gets merged and
81 | published.
82 |
83 | Start contributing
84 | ''''''''''''''''''
85 | Start by cloning the project with:
86 |
87 | .. code-block:: bash
88 |
89 | git clone https://github.com/valohai/django-allauth-2fa.git
90 |
91 | The project uses `hatch`_ for building and package management.
92 | If you don't have hatch installed, you can do so by running:
93 |
94 | .. code-block:: bash
95 |
96 | pip install hatch
97 |
98 | Setup you virtual environment with hatch:
99 |
100 | .. code-block:: bash
101 |
102 | hatch env create
103 |
104 | Running tests
105 | '''''''''''''
106 |
107 | Tests can be run using `pytest `_
108 |
109 | .. code-block:: bash
110 |
111 | hatch run pytest
112 |
113 | Running the test project
114 | ''''''''''''''''''''''''
115 |
116 | The test project can also be used as a minimal example using the following:
117 |
118 | .. code-block:: bash
119 |
120 | hatch run python manage.py migrate
121 | hatch run python manage.py runserver
122 |
123 | .. _Víðir Valberg Guðmundsson (@valberg): https://github.com/valberg
124 | .. _Percipient Networks: https://www.strongarm.io
125 | .. _Valohai: https://valohai.com/
126 | .. _the repository: http://github.com/valohai/django-allauth-2fa
127 | .. _hatch: https://hatch.pypa.io/
128 |
--------------------------------------------------------------------------------
/allauth_2fa/middleware.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from allauth.account.adapter import get_adapter
4 | from django.conf import settings
5 | from django.contrib import messages
6 | from django.http import HttpRequest
7 | from django.http import HttpResponse
8 | from django.shortcuts import redirect
9 | from django.urls import resolve
10 | from django.utils.deprecation import MiddlewareMixin
11 |
12 |
13 | class AllauthTwoFactorMiddleware(MiddlewareMixin):
14 | """
15 | Reset the login flow if another page is loaded halfway through the login.
16 | (I.e. if the user has logged in with a username/password, but not yet
17 | entered their two-factor credentials.) This makes sure a user does not stay
18 | half logged in by mistake.
19 |
20 | """
21 |
22 | def process_request(self, request: HttpRequest) -> None:
23 | match = resolve(request.path)
24 | if not match.url_name or not match.url_name.startswith(
25 | "two-factor-authenticate",
26 | ):
27 | try:
28 | del request.session["allauth_2fa_user_id"]
29 | except KeyError:
30 | pass
31 |
32 |
33 | class BaseRequire2FAMiddleware(MiddlewareMixin):
34 | """
35 | Ensure that particular users have two-factor authentication enabled before
36 | they have access to the rest of the app.
37 |
38 | If they don't have 2FA enabled, they will be redirected to the 2FA
39 | enrollment page and not be allowed to access other pages.
40 | """
41 |
42 | # List of URL names that the user should still be allowed to access.
43 | allowed_pages = [
44 | # They should still be able to log out or change password.
45 | "account_change_password",
46 | "account_logout",
47 | "account_reset_password",
48 | # URLs required to set up two-factor
49 | "two-factor-setup",
50 | ]
51 | # The message to the user if they don't have 2FA enabled and must enable it.
52 | require_2fa_message = (
53 | "You must enable two-factor authentication before doing anything else."
54 | )
55 |
56 | def on_require_2fa(self, request: HttpRequest) -> HttpResponse:
57 | """
58 | If the current request requires 2FA and the user does not have it
59 | enabled, this is executed. The result of this is returned from the
60 | middleware.
61 | """
62 | # See allauth.account.adapter.DefaultAccountAdapter.add_message.
63 | if "django.contrib.messages" in settings.INSTALLED_APPS:
64 | # If there is already a pending message related to two-factor (likely
65 | # created by a redirect view), simply update the message text.
66 | storage = messages.get_messages(request)
67 | tag = "2fa_required"
68 | for m in storage:
69 | if m.extra_tags == tag:
70 | m.message = self.require_2fa_message
71 | break
72 | # Otherwise, create a new message.
73 | else:
74 | messages.error(request, self.require_2fa_message, extra_tags=tag)
75 | # Mark the storage as not processed so they'll be shown to the user.
76 | storage.used = False
77 |
78 | # Redirect user to two-factor setup page.
79 | return redirect("two-factor-setup")
80 |
81 | def require_2fa(self, request: HttpRequest) -> bool:
82 | """
83 | Check if this request is required to have 2FA before accessing the app.
84 |
85 | This should return True if this request requires 2FA.
86 |
87 | You can access anything on the request, but generally request.user will
88 | be most interesting here.
89 | """
90 | raise NotImplementedError("You must implement require_2fa.")
91 |
92 | def is_allowed_page(self, request: HttpRequest) -> bool:
93 | return request.resolver_match.url_name in self.allowed_pages
94 |
95 | def process_view(
96 | self,
97 | request: HttpRequest,
98 | view_func,
99 | view_args,
100 | view_kwargs,
101 | ) -> HttpResponse | None:
102 | # The user is not logged in, do nothing.
103 | if request.user.is_anonymous:
104 | return None
105 |
106 | # If this doesn't require 2FA, then stop processing.
107 | if not self.require_2fa(request):
108 | return None
109 |
110 | # If the user is on one of the allowed pages, do nothing.
111 | if self.is_allowed_page(request):
112 | return None
113 |
114 | # User already has two-factor configured, do nothing.
115 | if get_adapter(request).has_2fa_enabled(request.user):
116 | return None
117 |
118 | # The request required 2FA but it isn't configured!
119 | return self.on_require_2fa(request)
120 |
--------------------------------------------------------------------------------
/allauth_2fa/views.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from base64 import b64encode
4 |
5 | from allauth.account.adapter import get_adapter
6 | from allauth.utils import get_form_class
7 | from django.contrib.auth import get_user_model
8 | from django.contrib.auth.mixins import LoginRequiredMixin
9 | from django.contrib.sites.shortcuts import get_current_site
10 | from django.http import HttpResponseRedirect
11 | from django.shortcuts import redirect
12 | from django.urls import reverse_lazy
13 | from django.utils.encoding import force_str
14 | from django.views.generic import FormView
15 | from django.views.generic import TemplateView
16 | from django_otp.plugins.otp_static.models import StaticToken
17 | from django_otp.plugins.otp_totp.models import TOTPDevice
18 |
19 | from allauth_2fa import app_settings
20 | from allauth_2fa.forms import TOTPAuthenticateForm
21 | from allauth_2fa.forms import TOTPDeviceForm
22 | from allauth_2fa.forms import TOTPDeviceRemoveForm
23 | from allauth_2fa.mixins import ValidTOTPDeviceRequiredMixin
24 | from allauth_2fa.utils import generate_totp_config_svg
25 | from allauth_2fa.utils import get_device_base32_secret
26 | from allauth_2fa.utils import user_has_valid_totp_device
27 |
28 |
29 | class TwoFactorAuthenticate(FormView):
30 | template_name = f"allauth_2fa/authenticate.{app_settings.TEMPLATE_EXTENSION}"
31 | form_class = TOTPAuthenticateForm
32 |
33 | def dispatch(self, request, *args, **kwargs):
34 | # If the user is not about to enter their two-factor credentials,
35 | # redirect to the login page (they shouldn't be here!). This includes
36 | # anonymous users.
37 | if "allauth_2fa_user_id" not in request.session:
38 | # Don't use the redirect_to_login here since we don't actually want
39 | # to include the next parameter.
40 | return redirect("account_login")
41 | return super().dispatch(request, *args, **kwargs)
42 |
43 | def get_form_class(self):
44 | return get_form_class(app_settings.FORMS, "authenticate", self.form_class)
45 |
46 | def get_form_kwargs(self):
47 | kwargs = super().get_form_kwargs()
48 | user_id = self.request.session["allauth_2fa_user_id"]
49 | kwargs["user"] = get_user_model().objects.get(id=user_id)
50 | return kwargs
51 |
52 | def form_valid(self, form):
53 | """
54 | The allauth 2fa login flow is now done (the user logged in successfully
55 | with 2FA), continue the logic from allauth.account.utils.perform_login
56 | since it was interrupted earlier.
57 |
58 | """
59 | adapter = get_adapter(self.request)
60 | # 2fa kicked in at `pre_login()`, so we need to continue from there.
61 | login_kwargs = adapter.unstash_pending_login_kwargs(self.request)
62 | adapter.login(self.request, form.user)
63 | return adapter.post_login(self.request, form.user, **login_kwargs)
64 |
65 |
66 | class TwoFactorSetup(LoginRequiredMixin, FormView):
67 | template_name = f"allauth_2fa/setup.{app_settings.TEMPLATE_EXTENSION}"
68 | form_class = TOTPDeviceForm
69 | success_url = reverse_lazy(app_settings.SETUP_SUCCESS_URL)
70 |
71 | def dispatch(self, request, *args, **kwargs):
72 | # If the user has 2FA setup already, redirect them to the backup tokens.
73 | if user_has_valid_totp_device(request.user):
74 | return HttpResponseRedirect(self.get_success_url())
75 |
76 | return super().dispatch(request, *args, **kwargs)
77 |
78 | def _new_device(self):
79 | """
80 | Replace any unconfirmed TOTPDevices with a new one for confirmation.
81 |
82 | This needs to be done whenever a GET request to the page is received OR
83 | if the confirmation of the device fails.
84 | """
85 | self.request.user.totpdevice_set.filter(confirmed=False).delete()
86 | self.device = TOTPDevice.objects.create(user=self.request.user, confirmed=False)
87 |
88 | def get(self, request, *args, **kwargs):
89 | # Whenever this page is loaded, create a new device (this ensures a
90 | # user's QR code isn't shown multiple times).
91 | self._new_device()
92 | return super().get(request, *args, **kwargs)
93 |
94 | def get_qr_code_kwargs(self) -> dict[str, str]:
95 | """
96 | Get the configuration for generating a QR code.
97 |
98 | The fields required are:
99 | - `label`: identifies which account a key is associated with. Contains an
100 | account name, preferably prefixed by an issuer name and a colon, e.g.
101 | `issuer: account`.
102 | - `issuer`: indicates the provider or service this account is associated with.
103 | """
104 |
105 | issuer = get_current_site(self.request).name
106 |
107 | return {
108 | "issuer": issuer,
109 | "label": f"{issuer}: {self.request.user.get_username()}",
110 | }
111 |
112 | def get_qr_code_data_uri(self):
113 | svg_data = generate_totp_config_svg(
114 | device=self.device,
115 | **self.get_qr_code_kwargs(),
116 | )
117 | return f"data:image/svg+xml;base64,{force_str(b64encode(svg_data))}"
118 |
119 | def get_context_data(self, **kwargs):
120 | context = super().get_context_data(**kwargs)
121 | context["qr_code_url"] = self.get_qr_code_data_uri()
122 | context["secret"] = get_device_base32_secret(self.device)
123 | return context
124 |
125 | def get_form_class(self):
126 | return get_form_class(app_settings.FORMS, "setup", self.form_class)
127 |
128 | def get_form_kwargs(self):
129 | kwargs = super().get_form_kwargs()
130 | kwargs["user"] = self.request.user
131 | return kwargs
132 |
133 | def form_valid(self, form):
134 | # Confirm the device.
135 | form.save()
136 | return super().form_valid(form)
137 |
138 | def form_invalid(self, form):
139 | # If the confirmation code was wrong, generate a new device.
140 | self._new_device()
141 | return super().form_invalid(form)
142 |
143 |
144 | class TwoFactorRemove(ValidTOTPDeviceRequiredMixin, FormView):
145 | template_name = f"allauth_2fa/remove.{app_settings.TEMPLATE_EXTENSION}"
146 | form_class = TOTPDeviceRemoveForm
147 | success_url = reverse_lazy(app_settings.REMOVE_SUCCESS_URL)
148 |
149 | def get_form_class(self):
150 | return get_form_class(app_settings.FORMS, "remove", self.form_class)
151 |
152 | def form_valid(self, form):
153 | form.save()
154 | return super().form_valid(form)
155 |
156 | def get_form_kwargs(self):
157 | kwargs = super().get_form_kwargs()
158 | kwargs["user"] = self.request.user
159 | return kwargs
160 |
161 |
162 | class TwoFactorBackupTokens(ValidTOTPDeviceRequiredMixin, TemplateView):
163 | template_name = f"allauth_2fa/backup_tokens.{app_settings.TEMPLATE_EXTENSION}"
164 | # This can be overridden in a subclass to True,
165 | # to have that particular view always reveal the tokens.
166 | reveal_tokens = bool(app_settings.ALWAYS_REVEAL_BACKUP_TOKENS)
167 |
168 | def get_context_data(self, **kwargs):
169 | context = super().get_context_data(**kwargs)
170 | static_device, _ = self.request.user.staticdevice_set.get_or_create(
171 | name="backup",
172 | )
173 |
174 | if static_device:
175 | context["backup_tokens"] = static_device.token_set.all()
176 | context["reveal_tokens"] = self.reveal_tokens
177 |
178 | return context
179 |
180 | def post(self, request, *args, **kwargs):
181 | static_device, _ = request.user.staticdevice_set.get_or_create(name="backup")
182 | static_device.token_set.all().delete()
183 | for _ in range(app_settings.BACKUP_TOKENS_NUMBER):
184 | static_device.token_set.create(token=StaticToken.random_token())
185 | self.reveal_tokens = True
186 | return self.get(request, *args, **kwargs)
187 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | .. :changelog:
2 |
3 | Changelog
4 | #########
5 |
6 | 0.12.0 - January 2025
7 | =====================
8 |
9 | Note
10 | ----
11 |
12 | This may be the last version of django-allauth-2fa under this stewardship;
13 | allauth contains its own ``allauth.mfa`` package that should be used instead
14 | for versions of Allauth >= 0.58.0. The dependency range for ``allauth`` in this
15 | version has been updated accordingly; this release will conflict with newer versions
16 | of ``allauth`` on purpose.
17 |
18 | See https://github.com/valohai/django-allauth-2fa/issues/189 for discussion.
19 |
20 | You can use the experimental ``allauth_2fa_migrate`` management command to create
21 | ``allauth.mfa`` Authenticator objects from your ``allauth_2fa`` data before switching
22 | your production environment over to ``allauth.mfa``.
23 |
24 | Possibly breaking changes
25 | -------------------------
26 |
27 | * You can't write to `allauth_2fa.app_settings` variables anymore;
28 | instead modify the underlying `django.conf.settings` settings.
29 |
30 | New features and fixes
31 | ----------------------
32 |
33 | * Add flag to make the required entry of an OTP code for device removal optional (#169)
34 | * Add setting to allow generating a different number of backup tokens (#192)
35 | * Potential bugfix for ``redirect_field_name`` AttributeError (#196)
36 |
37 | 0.11.1 - July 13, 2023
38 | ======================
39 |
40 | Patch release to address a packaging issue where templates weren't included (#176, #177).
41 |
42 | 0.11.0 - July 4, 2023
43 | =====================
44 |
45 | We didn't wait one year from the last release for this on purpose, I swear!
46 |
47 | Minimum dependency versions
48 | ---------------------------
49 |
50 | * The minimum version of Python for this release is 3.7.
51 | * The minimum version of Django for this release is 3.2.
52 | * The minimum version of django-otp for this release is 1.1.x.
53 | * The minimum version of django-allauth for this release is 0.53.0.
54 |
55 | Possibly breaking changes
56 | -------------------------
57 |
58 | * The `token` field in forms is now `otp_token`; if you have subclassed forms,
59 | or are using custom templates, this may require adjustment.
60 |
61 | New features
62 | ------------
63 |
64 | * Allow customizing view success URLs via app_settings
65 | * Show secret on setup page to allow for non-QR-code devices
66 | * You can customize the QR code generation arguments in TwoFactorSetup (#156)
67 | * 2FA can be disabled using backup tokens (#155)
68 | * You can now override the forms used by the views using settings, like allauth does (#161)
69 |
70 | Infrastructure
71 | --------------
72 |
73 | * The package now has (partial) type annotations.
74 |
75 | 0.10.0 - July 4, 2022
76 | =====================
77 |
78 | If you're using a custom template for the 2FA token removal view,
79 | note that you will need to also display the ``token`` field beginning
80 | with this version (PR #135 in particular).
81 |
82 | The minimum version of django-otp was bumped to 0.6.x.
83 |
84 | * Update CI bits and bobs by @akx in #137
85 | * Drop support for django-otp 0.5.x by @akx in #138
86 | * Require user token to disable 2FA by @SchrodingersGat in #135
87 |
88 | 0.9 - April 11, 2022
89 | ====================
90 |
91 | This release dropped support for Python 3.5 and added support for Django 4.0.
92 |
93 | * Improves documentation for protection Django admin with 2FA. Contributed by @hailkomputer in #91.
94 | * Autocomplete on the token entry form is disabled. Contributed by @qvicksilver in #95.
95 | * Stop restricting a class of an adapter in `TwoFactorAuthenticate` by @illia-v in #96
96 | * Use same base template as upstream allauth by @ErwinJunge in #98
97 | * Redirect to next, when given via GET or POST by @ErwinJunge in #99
98 | * Allow TOTP removal when no backup device is present by @akx in #126
99 | * Fix for subclassed OTP adapter by @squio in #129
100 | * Replace Travis with GitHub Actions by @akx in #110
101 | * Drop EOL Python 3.5, modernize for 3.6+ by @akx in #106
102 | * Remove Django 1.11 from tox, and add Django 4.0b1. by @valberg in #118
103 | * Typo in docs by @beckedorf in #122
104 | * Add pre-commit, and run it. by @valberg in #121
105 | * Rename master -> main by @akx in #123
106 | * Declarative setup.cfg by @akx in #124
107 | * Use Py.test for tests + fix coverage reporting by @akx in #127
108 | * Require2FAMiddleware improvements by @akx in #107
109 | * Miscellaneous housekeeping by @akx in #130
110 |
111 | 0.8 February 3, 2020
112 | ====================
113 |
114 | * Drop support for Python 2.7 and Python 3.4.
115 | * Officially support Python 3.7 and 3.8.
116 | * Drop support for Django 2.0 and Django 2.1.
117 | * Officially support Django 3.0.
118 |
119 | 0.7 September 10, 2019
120 | ======================
121 |
122 | * Remove more code that was for Django < 1.11.
123 | * Officially support Django 2.0 and Django 2.1.
124 | * Officially support django-otp 0.7.
125 | * Do not include test code in distribution, fix from @akx, PR #67.
126 | * Support for more complex user IDs (e.g. UUIDs), fix from @chromakey, see issue
127 | #64 / PR #66.
128 | * The extension used by the 2FA templates is customizable. Originally in PR #69
129 | by @akx, split into PR #71.
130 | * The QR code is now included inline as an SVG instead of being a separate view.
131 | PR #74 by @akx.
132 | * A new mixin is included to enforce a user having 2FA enabled for particular
133 | views. Added in PR #73 by @akx.
134 | * Passing additional context to the ``TwoFactorBackupTokens`` was broken. This
135 | was fixed in PR #73 by @akx.
136 | * A configuration option (``ALLAUTH_2FA_ALWAYS_REVEAL_BACKUP_TOKENS``) was added
137 | to only show the static tokens once (during creation)> PR #75 by @akx.
138 |
139 | 0.6 February 13, 2018
140 | =====================
141 |
142 | * Drop support for Django < 1.11, these are no longer supported by
143 | django-allauth (as of 0.35.0).
144 |
145 | 0.5 December 21, 2017
146 | =====================
147 |
148 | * Avoid an exception if a user without any configured devices tries to view a QR
149 | code. This view now properly 404s.
150 | * Redirect users to configure 2FA is they attempt to configure backup tokens
151 | without enabling 2FA first.
152 | * Add base middleware to ensure particular users (e.g. superusers) have 2FA
153 | enabled.
154 | * Drop official support for Django 1.9 and 1.10, they're
155 | `no longer supported `_
156 | by the Django project.
157 | * Added Sphinx-generated documentation. A rendered version
158 | `is available at `_.
159 |
160 | 0.4.4 March 24, 2017
161 | ====================
162 |
163 | * Adds trailing slashes to the URL patterns. This is backwards compatible with
164 | the old URLs.
165 | * Properly support installing in Python 3 via PyPI.
166 |
167 | 0.4.3 January 18, 2017
168 | ======================
169 |
170 | * Adds support for forwarding ``GET`` parameters through the 2FA workflow. This
171 | fixes ``next`` not working when logging in using 2FA.
172 |
173 | 0.4.2 December 15, 2016
174 | =======================
175 |
176 | * Reverts the fix in 0.4.1 as this breaks custom adapters that inherit from
177 | ``OTPAdapter`` and *don't* override the ``login`` method.
178 |
179 | 0.4.1 December 14, 2016
180 | =======================
181 |
182 | * Fixed a bug when using a custom adapter that doesn't inherit from
183 | ``OTPAdapter`` and that overrides the ``login`` method.
184 |
185 | 0.4 November 7, 2016
186 | ====================
187 |
188 | * Properly continue the allauth login workflow after successful 2FA login, e.g.
189 | send allauth signals
190 | * Support using ``MIDDLEWARE`` setting with Django 1.10.
191 | * Support customer ``USERNAME_FIELD`` on the auth model.
192 |
193 | 0.3.2 October 26, 2016
194 | ======================
195 |
196 | * Fix an error when hitting the TwoFactorBackupTokens view as a non-anonymous
197 | user.
198 |
199 | 0.3.1 October 5, 2016
200 | =====================
201 |
202 | * Properly handle an ``AnonymousUser`` hitting the views.
203 |
204 | 0.3 October 5, 2016
205 | ===================
206 |
207 | * Support custom ``User`` models.
208 | * Fixed a bug where a user could end up half logged in if they didn't complete
209 | the two-factor login flow. A user's login flow will now be reset. Requires
210 | enabled the included middle: ``allauth_2fa.middleware.AllauthTwoFactorMiddleware``.
211 | * Disable autocomplete on the two-factor code input form.
212 | * Properly redirect anonymous users.
213 | * Minor simplifications of code (and inherit more code from django-otp).
214 | * Minor updates to documentation.
215 |
216 | 0.2 September 9, 2016
217 | =====================
218 |
219 | * Add tests / tox / Travis support.
220 | * Don't pin dependencies.
221 | * Officially support Django 1.10, drop support for Django 1.7.
222 |
223 | 0.1.4 May 2, 2016
224 | =================
225 |
226 | * Autofocus the token input field on forms.
227 |
228 | 0.1.3 January 20, 2016
229 | ======================
230 |
231 | * Fix deprecation notices for Django 1.10.
232 |
233 | 0.1.2 November 23, 2015
234 | =======================
235 |
236 | * Fixed an error when a user enters invalid input into the token form.
237 |
238 | 0.1.1 October 21, 2015
239 | ======================
240 |
241 | * Project reorganization and clean-up.
242 | * Added support for Microsoft Authenticator.
243 | * Support being installed via pip.
244 | * Pull more configuration from Django settings (success URL).
245 | * Support disabling two-factor for an account.
246 |
247 | 0.1 April 4, 2015
248 | =================
249 |
250 | * Initial version by Víðir Valberg Guðmundsson
251 |
--------------------------------------------------------------------------------
/tests/test_allauth_2fa.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Callable
4 | from unittest.mock import Mock
5 |
6 | import pytest
7 | from allauth.account.signals import user_logged_in
8 | from allauth.account.views import PasswordResetFromKeyView
9 | from django.conf import settings
10 | from django.contrib.auth import get_user_model
11 | from django.contrib.auth.models import AbstractUser
12 | from django.core.management import call_command
13 | from django.forms import BaseForm
14 | from django.test import override_settings
15 | from django.urls import reverse
16 | from django.urls import reverse_lazy
17 | from django.views.generic.edit import FormMixin
18 | from django_otp.oath import TOTP
19 | from django_otp.plugins.otp_static.models import StaticDevice
20 | from django_otp.plugins.otp_static.models import StaticToken
21 | from django_otp.plugins.otp_totp.models import TOTPDevice
22 | from pytest_django.asserts import assertRedirects
23 |
24 | from allauth_2fa import views
25 | from allauth_2fa.adapter import OTPAdapter
26 | from allauth_2fa.middleware import BaseRequire2FAMiddleware
27 |
28 | from . import forms as forms_overrides
29 |
30 | ADAPTER_CLASSES = [
31 | "allauth_2fa.adapter.OTPAdapter",
32 | "tests.adapter.CustomAdapter",
33 | ]
34 |
35 | TWO_FACTOR_AUTH_URL = reverse_lazy("two-factor-authenticate")
36 | TWO_FACTOR_BACKUP_TOKENS_URL = reverse_lazy("two-factor-backup-tokens")
37 | TWO_FACTOR_SETUP_URL = reverse_lazy("two-factor-setup")
38 | LOGIN_URL = reverse_lazy("account_login")
39 | JOHN_CREDENTIALS = {"login": "john", "password": "doe"}
40 |
41 |
42 | @pytest.fixture(autouse=True)
43 | def enable_db_access_for_all_tests(db):
44 | pass
45 |
46 |
47 | def pytest_generate_tests(metafunc):
48 | if "adapter" in metafunc.fixturenames:
49 | metafunc.parametrize("adapter", ADAPTER_CLASSES, indirect=True)
50 |
51 |
52 | def create_totp_and_static(user: AbstractUser) -> tuple[TOTPDevice, StaticDevice]:
53 | totp_model = user.totpdevice_set.create()
54 | static_model = user.staticdevice_set.create()
55 | static_model.token_set.create(token=StaticToken.random_token())
56 | return totp_model, static_model
57 |
58 |
59 | @pytest.fixture(autouse=True)
60 | def adapter(request, settings):
61 | settings.ACCOUNT_ADAPTER = request.param
62 | # Can be used to verify the class is correct:
63 | # assert get_adapter().__class__.__name__ == request.param.rpartition(".")[-1]
64 |
65 |
66 | @pytest.fixture()
67 | def john() -> AbstractUser:
68 | user = get_user_model().objects.create(username="john")
69 | user.set_password("doe")
70 | user.save()
71 | return user
72 |
73 |
74 | @pytest.fixture()
75 | def john_with_totp(john: AbstractUser) -> tuple[AbstractUser, TOTPDevice, StaticDevice]:
76 | totp_model, static_model = create_totp_and_static(john)
77 | return john, totp_model, static_model
78 |
79 |
80 | @pytest.fixture()
81 | def user_logged_in_count(request) -> Callable[[], int]:
82 | login_callback = Mock()
83 | user_logged_in.connect(login_callback)
84 | request.addfinalizer(lambda: user_logged_in.disconnect(login_callback))
85 |
86 | def get_login_count() -> int:
87 | return login_callback.call_count
88 |
89 | return get_login_count
90 |
91 |
92 | def login(client, *, expected_redirect_url: str | None = None, credentials=None):
93 | if credentials is None:
94 | credentials = JOHN_CREDENTIALS
95 | resp = client.post(LOGIN_URL, credentials)
96 | if expected_redirect_url:
97 | assertRedirects(resp, expected_redirect_url, fetch_redirect_response=False)
98 |
99 |
100 | def get_token_from_totp_device(totp_model) -> str:
101 | return TOTP(
102 | key=totp_model.bin_key,
103 | step=totp_model.step,
104 | t0=totp_model.t0,
105 | digits=totp_model.digits,
106 | ).token()
107 |
108 |
109 | def do_totp_authentication(
110 | client,
111 | totp_device: TOTPDevice,
112 | *,
113 | expected_redirect_url: str | None,
114 | auth_url: str = TWO_FACTOR_AUTH_URL,
115 | ):
116 | token = get_token_from_totp_device(totp_device)
117 | resp = client.post(auth_url, {"otp_token": token})
118 | if expected_redirect_url:
119 | assertRedirects(resp, expected_redirect_url, fetch_redirect_response=False)
120 |
121 |
122 | @pytest.mark.parametrize("token_state", ["none", "correct", "incorrect"])
123 | def test_setup_2fa(client, john, token_state):
124 | """Test that the setup view works."""
125 | assert not john.totpdevice_set.exists()
126 | client.force_login(john)
127 | resp = client.get(TWO_FACTOR_SETUP_URL)
128 |
129 | assert john.totpdevice_set.exists()
130 |
131 | if token_state == "correct":
132 | totp_device = john.totpdevice_set.first()
133 | totp_device.throttle_reset()
134 | form_data = {
135 | "otp_token": get_token_from_totp_device(totp_device),
136 | }
137 | elif token_state == "incorrect":
138 | form_data = {"otp_token": "hernekeitto"}
139 | else:
140 | form_data = {}
141 |
142 | client.post(TWO_FACTOR_SETUP_URL, form_data)
143 | assert resp.status_code == 200
144 |
145 | device_confirmed = john.totpdevice_set.first().confirmed
146 | assert device_confirmed == (token_state == "correct")
147 |
148 |
149 | def test_standard_login(client, john, user_logged_in_count):
150 | """Test login behavior when 2FA is not configured."""
151 | login(client, expected_redirect_url=settings.LOGIN_REDIRECT_URL)
152 |
153 | # Ensure the signal is received as expected.
154 | assert user_logged_in_count() == 1
155 |
156 |
157 | def test_2fa_login(client, john_with_totp, user_logged_in_count):
158 | """Test login behavior when 2FA is configured."""
159 | user, totp_device, static_device = john_with_totp
160 | login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
161 |
162 | # Ensure no signal is received yet.
163 | assert user_logged_in_count() == 0
164 |
165 | # Now ensure that logging in actually works.
166 | do_totp_authentication(
167 | client,
168 | totp_device=totp_device,
169 | expected_redirect_url=settings.LOGIN_REDIRECT_URL,
170 | )
171 |
172 | # Ensure the signal is received as expected.
173 | assert user_logged_in_count() == 1
174 |
175 |
176 | def test_invalid_2fa_login(client, john_with_totp):
177 | """Test login behavior when 2FA is configured and wrong code is given."""
178 | login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
179 |
180 | # Ensure that logging in does not work with invalid token
181 | resp = client.post(TWO_FACTOR_AUTH_URL, {"otp_token": "invalid"})
182 | assert resp.status_code == 200
183 |
184 | # Check the user did not get logged in
185 | url = reverse("login-required-view")
186 | resp = client.get(url)
187 | assertRedirects(resp, f"{LOGIN_URL}?next={url}")
188 |
189 |
190 | def test_2fa_redirect(client, john):
191 | """
192 | Going to the 2FA login page when not logged in (or when fully logged in)
193 | should redirect.
194 | """
195 |
196 | # Not logged in.
197 | resp = client.get(TWO_FACTOR_AUTH_URL)
198 | assertRedirects(resp, LOGIN_URL, fetch_redirect_response=False)
199 |
200 | login(client, expected_redirect_url=settings.LOGIN_REDIRECT_URL)
201 |
202 | resp = client.get(TWO_FACTOR_AUTH_URL)
203 | assertRedirects(resp, LOGIN_URL, fetch_redirect_response=False)
204 |
205 |
206 | @pytest.mark.parametrize("target_url", [LOGIN_URL, "/unnamed-view"])
207 | def test_2fa_reset_flow(client, john_with_totp, target_url):
208 | """
209 | Ensure the login flow is reset when navigating away before entering
210 | two-factor credentials.
211 | """
212 | login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
213 |
214 | # The user ID should be in the session.
215 | assert client.session.get("allauth_2fa_user_id")
216 |
217 | # Navigate to a different page.
218 | client.get(target_url)
219 |
220 | # The middleware should reset the login flow.
221 | assert not client.session.get("allauth_2fa_user_id")
222 |
223 | # Trying to continue with two-factor without logging in again will
224 | # redirect to login.
225 | resp = client.get(TWO_FACTOR_AUTH_URL)
226 |
227 | assertRedirects(resp, LOGIN_URL, fetch_redirect_response=False)
228 |
229 |
230 | @pytest.mark.parametrize("token_state", ["none", "correct", "static", "incorrect"])
231 | @pytest.mark.parametrize("require_token", [False, True])
232 | def test_2fa_removal(client, john_with_totp, token_state, require_token, settings):
233 | settings.ALLAUTH_2FA_REQUIRE_OTP_ON_DEVICE_REMOVAL = require_token
234 | """Removing 2FA should be possible with a correct token
235 | or without one if REQUIRE_OTP_ON_DEVICE_REMOVAL is False."""
236 | user, totp_device, static_device = john_with_totp
237 | login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
238 | do_totp_authentication(
239 | client,
240 | totp_device=totp_device,
241 | expected_redirect_url=settings.LOGIN_REDIRECT_URL,
242 | )
243 | assert user.totpdevice_set.exists()
244 |
245 | # Navigate to 2FA removal view
246 | client.get(reverse("two-factor-remove"))
247 |
248 | if token_state == "correct" and require_token:
249 | # reset throttling and get another token
250 | totp_device.throttle_reset()
251 | form_data = {"otp_token": get_token_from_totp_device(totp_device)}
252 | elif token_state == "static" and require_token:
253 | form_data = {"otp_token": static_device.token_set.first().token}
254 | elif token_state == "incorrect" and require_token:
255 | form_data = {"otp_token": "hernekeitto"}
256 | else:
257 | form_data = {}
258 |
259 | # ... and POST to confirm
260 | client.post(reverse("two-factor-remove"), form_data)
261 |
262 | was_removed = not user.totpdevice_set.exists()
263 | if require_token:
264 | # TOTP device should only be removed when the token is correct.
265 | assert was_removed == (token_state in ["correct", "static"])
266 | else:
267 | assert was_removed
268 |
269 |
270 | @pytest.mark.parametrize("next_via", ["get", "post"])
271 | def test_2fa_login_forwarding_get_parameters(client, john_with_totp, next_via: str):
272 | """
273 | Test that the 2FA workflow passes forward the GET parameters sent to the
274 | TwoFactorAuthenticate view.
275 | """
276 |
277 | # Add a next to unnamed-view.
278 | if next_via == "post":
279 | resp = client.post(
280 | f"{LOGIN_URL}?existing=param",
281 | {**JOHN_CREDENTIALS, "next": "unnamed-view"},
282 | follow=True,
283 | )
284 | else:
285 | resp = client.post(
286 | f"{LOGIN_URL}?existing=param&next=unnamed-view",
287 | JOHN_CREDENTIALS,
288 | follow=True,
289 | )
290 |
291 | # Ensure that the unnamed-view is still being forwarded to.
292 | assertRedirects(
293 | resp,
294 | f"{TWO_FACTOR_AUTH_URL}?existing=param&next=unnamed-view",
295 | fetch_redirect_response=False,
296 | )
297 |
298 |
299 | def test_anonymous(client):
300 | """
301 | Views should not be hittable via an AnonymousUser.
302 | """
303 | # The authentication page redirects to the login page.
304 | resp = client.get(TWO_FACTOR_AUTH_URL)
305 | assertRedirects(resp, LOGIN_URL, fetch_redirect_response=False)
306 |
307 | # Some pages redirect to the login page and then will redirect back.
308 | for url in [
309 | "two-factor-setup",
310 | "two-factor-backup-tokens",
311 | "two-factor-remove",
312 | ]:
313 | url = reverse(url)
314 | resp = client.get(url)
315 | assertRedirects(
316 | resp,
317 | f"{LOGIN_URL}?next={url}",
318 | fetch_redirect_response=False,
319 | )
320 |
321 |
322 | def test_not_configured_redirect(client, john):
323 | """Viewing backup codes or disabling 2FA should redirect if 2FA is not
324 | configured."""
325 | login(client, expected_redirect_url=settings.LOGIN_REDIRECT_URL)
326 |
327 | # The 2FA pages should redirect.
328 | for url_name in ["two-factor-backup-tokens", "two-factor-remove"]:
329 | resp = client.get(reverse(url_name))
330 | assertRedirects(resp, TWO_FACTOR_SETUP_URL, fetch_redirect_response=False)
331 |
332 |
333 | def test_backup_tokens_number(client, john_with_totp):
334 | """Tests that the configured number of tokens is sent."""
335 | user, totp_device, static_device = john_with_totp
336 | login(client, expected_redirect_url=TWO_FACTOR_AUTH_URL)
337 | do_totp_authentication(
338 | client,
339 | totp_device=totp_device,
340 | expected_redirect_url=settings.LOGIN_REDIRECT_URL,
341 | )
342 | resp = client.post(TWO_FACTOR_BACKUP_TOKENS_URL)
343 | assert len(resp.context_data["backup_tokens"]) == 3
344 |
345 | with override_settings(ALLAUTH_2FA_BACKUP_TOKENS_NUMBER=10):
346 | resp = client.post(TWO_FACTOR_BACKUP_TOKENS_URL)
347 | assert len(resp.context_data["backup_tokens"]) == 10
348 |
349 |
350 | class Require2FA(BaseRequire2FAMiddleware):
351 | def require_2fa(self, request):
352 | return True
353 |
354 |
355 | @pytest.mark.parametrize("with_messages", (False, True))
356 | def test_require_2fa_middleware(client, john, settings, with_messages):
357 | new_middleware = [
358 | # Add the middleware that requires 2FA.
359 | "tests.test_allauth_2fa.Require2FA",
360 | ]
361 | new_installed_apps = []
362 |
363 | if with_messages:
364 | new_middleware.append("django.contrib.messages.middleware.MessageMiddleware")
365 | new_installed_apps.append("django.contrib.messages")
366 |
367 | with override_settings(
368 | # Don't redirect to an "allowed" URL.
369 | LOGIN_REDIRECT_URL="/unnamed-view",
370 | MIDDLEWARE=settings.MIDDLEWARE + tuple(new_middleware),
371 | INSTALLED_APPS=settings.INSTALLED_APPS + tuple(new_installed_apps),
372 | ):
373 | resp = client.post(LOGIN_URL, JOHN_CREDENTIALS, follow=True)
374 | # The user is redirected to the 2FA setup page.
375 | # (In particular, see that the last redirect brought us to the 2FA setup page.)
376 | assert resp.redirect_chain[-1][0] == TWO_FACTOR_SETUP_URL
377 | # TODO: check messages?
378 |
379 |
380 | @pytest.mark.parametrize(
381 | ("settings_key", "custom_form_cls", "view_cls"),
382 | [
383 | (
384 | "authenticate",
385 | forms_overrides.CustomTOTPAuthenticateForm,
386 | views.TwoFactorAuthenticate,
387 | ),
388 | ("setup", forms_overrides.CustomTOTPDeviceForm, views.TwoFactorSetup),
389 | ("remove", forms_overrides.CustomTOTPDeviceRemoveForm, views.TwoFactorRemove),
390 | ],
391 | )
392 | def test_forms_override(
393 | settings,
394 | settings_key: str,
395 | custom_form_cls: type[BaseForm],
396 | view_cls: type[FormMixin[BaseForm]],
397 | ) -> None:
398 | view = view_cls()
399 | assert view.get_form_class() is view.form_class
400 | settings.ALLAUTH_2FA_FORMS = {
401 | settings_key: f"{custom_form_cls.__module__}.{custom_form_cls.__qualname__}",
402 | }
403 | assert view.get_form_class() is custom_form_cls
404 |
405 |
406 | @pytest.mark.parametrize("view_cls", [PasswordResetFromKeyView])
407 | def test_view_missing_attribute(request, view_cls) -> None:
408 | # Ensure we're testing a view that's missing the attribute.
409 | assert hasattr(view_cls(), "get_redirect_field_name") is False
410 |
411 | # Ensure the function doesn't fail when the attribute is missing.
412 | assert OTPAdapter().get_2fa_authenticate_url(request) is not None
413 |
414 |
415 | def test_migration_management_command():
416 | from allauth.mfa.models import Authenticator
417 |
418 | for x in range(10):
419 | user = get_user_model().objects.create(username=f"user{x}")
420 | create_totp_and_static(user)
421 | call_command("allauth_2fa_migrate")
422 | auth_qs = Authenticator.objects
423 | assert auth_qs.filter(type=Authenticator.Type.RECOVERY_CODES).count() == 10
424 | assert auth_qs.filter(type=Authenticator.Type.TOTP).count() == 10
425 |
--------------------------------------------------------------------------------