├── 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 |
10 | {% csrf_token %} 11 | {{ form.non_field_errors }} 12 | {{ form.otp_token.label }}: 13 | {{ form.otp_token }} 14 | 15 | 18 |
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 |
14 | {% csrf_token %} 15 | {{ form.as_p }} 16 | 19 |
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 |
30 | {% csrf_token %} 31 | {{ form.non_field_errors }} 32 | {{ form.otp_token.label }}: {{ form.otp_token }} 33 | 34 | 37 |
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 | 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 |
24 | {% csrf_token %} 25 | 28 |
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 | --------------------------------------------------------------------------------