This commit is contained in:
Iliyan Angelov
2025-09-14 23:24:25 +03:00
commit c67067a2a4
71311 changed files with 6800714 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
from django.utils.translation import gettext_lazy as _
from allauth import app_settings as allauth_settings
from allauth.account.utils import user_email, user_username
from allauth.core import context
from allauth.mfa import app_settings
from allauth.utils import import_attribute
class DefaultMFAAdapter:
"""The adapter class allows you to override various functionality of the
``allauth.mfa`` app. To do so, point ``settings.MFA_ADAPTER`` to your own
class that derives from ``DefaultMFAAdapter`` and override the behavior by
altering the implementation of the methods according to your own need.
"""
error_messages = {
"unverified_email": _(
"You cannot activate two-factor authentication until you have verified your email address."
),
"add_email_blocked": _(
"You cannot add an email address to an account protected by two-factor authentication."
),
"incorrect_code": _("Incorrect code."),
}
"The error messages that can occur as part of MFA form handling."
def get_totp_label(self, user) -> str:
"""Returns the label used for representing the given user in a TOTP QR
code.
"""
label = user_email(user)
if not label:
label = user_username(user)
if not label:
label = str(user)
return label
def get_totp_issuer(self) -> str:
"""Returns the TOTP issuer name that will be contained in the TOTP QR
code.
"""
issuer = app_settings.TOTP_ISSUER
if not issuer:
if allauth_settings.SITES_ENABLED:
from django.contrib.sites.models import Site
issuer = Site.objects.get_current(context.request).name
else:
issuer = context.request.get_host()
return issuer
def encrypt(self, text: str) -> str:
"""Secrets such as the TOTP key are stored in the database. This
hook can be used to encrypt those so that they are not stored in the
clear in the database.
"""
return text
def decrypt(self, encrypted_text: str) -> str:
"""Counter part of ``encrypt()``."""
text = encrypted_text
return text
def get_adapter():
return import_attribute(app_settings.ADAPTER)()

View File

@@ -0,0 +1,48 @@
class AppSettings(object):
def __init__(self, prefix):
self.prefix = prefix
def _setting(self, name, dflt):
from allauth.utils import get_setting
return get_setting(self.prefix + name, dflt)
@property
def ADAPTER(self):
return self._setting("ADAPTER", "allauth.mfa.adapter.DefaultMFAAdapter")
@property
def RECOVERY_CODE_COUNT(self):
"""
The number of recovery codes.
"""
return self._setting("RECOVERY_CODE_COUNT", 10)
@property
def TOTP_PERIOD(self):
"""
The period that a TOTP code will be valid for, in seconds.
"""
return self._setting("TOTP_PERIOD", 30)
@property
def TOTP_DIGITS(self):
"""
The number of digits for TOTP codes
"""
return self._setting("TOTP_DIGITS", 6)
@property
def TOTP_ISSUER(self):
"""
The issuer.
"""
return self._setting("TOTP_ISSUER", "")
_app_settings = AppSettings("MFA_")
def __getattr__(name):
# See https://peps.python.org/pep-0562/
return getattr(_app_settings, name)

View File

@@ -0,0 +1,14 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class MFAConfig(AppConfig):
name = "allauth.mfa"
verbose_name = _("MFA")
default_auto_field = "django.db.models.BigAutoField"
def ready(self):
from allauth.account import signals as account_signals
from allauth.mfa import signals
account_signals._add_email.connect(signals.on_add_email)

View File

@@ -0,0 +1,51 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from allauth.account.models import EmailAddress
from allauth.mfa import totp
from allauth.mfa.adapter import get_adapter
from allauth.mfa.models import Authenticator
class AuthenticateForm(forms.Form):
code = forms.CharField(label=_("Code"))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
def clean_code(self):
code = self.cleaned_data["code"]
for auth in Authenticator.objects.filter(user=self.user):
if auth.wrap().validate_code(code):
self.authenticator = auth
return code
raise forms.ValidationError(get_adapter().error_messages["incorrect_code"])
class ActivateTOTPForm(forms.Form):
code = forms.CharField(label=_("Authenticator code"))
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
self.email_verified = not EmailAddress.objects.filter(
user=self.user, verified=False
).exists()
super().__init__(*args, **kwargs)
self.secret = totp.get_totp_secret(regenerate=not self.is_bound)
def clean_code(self):
try:
code = self.cleaned_data["code"]
if not self.email_verified:
raise forms.ValidationError(
get_adapter().error_messages["unverified_email"]
)
if not totp.validate_totp_code(self.secret, code):
raise forms.ValidationError(
get_adapter().error_messages["incorrect_code"]
)
return code
except forms.ValidationError as e:
self.secret = totp.get_totp_secret(regenerate=True)
raise e

View File

@@ -0,0 +1,51 @@
# Generated by Django 3.2.20 on 2023-08-19 14:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Authenticator",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"type",
models.CharField(
choices=[
("recovery_codes", "Recovery codes"),
("totp", "TOTP Authenticator"),
],
max_length=20,
),
),
("data", models.JSONField()),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("user", "type")},
},
),
]

View File

@@ -0,0 +1,36 @@
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class AuthenticatorManager(models.Manager):
def delete_dangling_recovery_codes(self, user):
qs = Authenticator.objects.filter(user=user)
if not qs.exclude(type=Authenticator.Type.RECOVERY_CODES).exists():
qs.delete()
class Authenticator(models.Model):
class Type(models.TextChoices):
RECOVERY_CODES = "recovery_codes", _("Recovery codes")
TOTP = "totp", _("TOTP Authenticator")
objects = AuthenticatorManager()
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
type = models.CharField(max_length=20, choices=Type.choices)
data = models.JSONField()
class Meta:
unique_together = (("user", "type"),)
def wrap(self):
from allauth.mfa.recovery_codes import RecoveryCodes
from allauth.mfa.totp import TOTP
return {
self.Type.TOTP: TOTP,
self.Type.RECOVERY_CODES: RecoveryCodes,
}[
self.type
](self)

View File

@@ -0,0 +1,108 @@
import binascii
import hmac
import os
import struct
from hashlib import sha1
from allauth.mfa import app_settings
from allauth.mfa.models import Authenticator
from allauth.mfa.utils import decrypt, encrypt
class RecoveryCodes:
def __init__(self, instance):
self.instance = instance
@classmethod
def activate(cls, user):
instance = Authenticator.objects.filter(
user=user, type=Authenticator.Type.RECOVERY_CODES
).first()
if instance:
return cls(instance)
instance = Authenticator(
user=user,
type=Authenticator.Type.RECOVERY_CODES,
data={
"seed": encrypt(cls.generate_seed()),
"used_mask": 0,
},
)
instance.save()
return cls(instance)
@classmethod
def generate_seed(self):
key = binascii.hexlify(os.urandom(20)).decode("ascii")
return key
def _get_migrated_codes(self):
codes = self.instance.data.get("migrated_codes")
if codes is not None:
return [decrypt(code) for code in codes]
def generate_codes(self):
migrated_codes = self._get_migrated_codes()
if migrated_codes is not None:
return migrated_codes
ret = []
seed = decrypt(self.instance.data["seed"])
h = hmac.new(key=seed.encode("ascii"), msg=None, digestmod=sha1)
for i in range(app_settings.RECOVERY_CODE_COUNT):
h.update((f"{i:3},").encode("utf-8"))
value = struct.unpack(">I", h.digest()[:4])[0]
value %= 10**8
fmt_value = f"{value:08}"
ret.append(fmt_value)
return ret
def _is_code_used(self, i):
used_mask = self.instance.data["used_mask"]
return bool(used_mask & (1 << i))
def _mark_code_used(self, i):
used_mask = self.instance.data["used_mask"]
used_mask |= 1 << i
self.instance.data["used_mask"] = used_mask
self.instance.save()
def get_unused_codes(self):
migrated_codes = self._get_migrated_codes()
if migrated_codes is not None:
return migrated_codes
ret = []
for i, code in enumerate(self.generate_codes()):
if self._is_code_used(i):
continue
ret.append(code)
return ret
def _validate_migrated_code(self, code):
migrated_codes = self._get_migrated_codes()
if migrated_codes is None:
return None
try:
idx = migrated_codes.index(code)
except ValueError:
return False
else:
migrated_codes = self.instance.data["migrated_codes"]
migrated_codes.pop(idx)
self.instance.data["migrated_codes"] = migrated_codes
self.instance.save()
return True
def validate_code(self, code):
ret = self._validate_migrated_code(code)
if ret is not None:
return ret
for i, c in enumerate(self.generate_codes()):
if self._is_code_used(i):
continue
if code == c:
self._mark_code_used(i)
return True
return False

View File

@@ -0,0 +1,10 @@
from django.forms import ValidationError
from allauth.mfa.adapter import get_adapter
from allauth.mfa.utils import is_mfa_enabled
def on_add_email(sender, email, user, **kwargs):
if is_mfa_enabled(user):
adapter = get_adapter()
raise ValidationError(adapter.error_messages["add_email_blocked"])

View File

@@ -0,0 +1,15 @@
from django.http import HttpResponseRedirect
from django.urls import reverse
from allauth.account.stages import LoginStage
from allauth.mfa.utils import is_mfa_enabled
class AuthenticateStage(LoginStage):
key = "mfa_authenticate"
def handle(self):
response, cont = None, True
if is_mfa_enabled(self.login.user):
response = HttpResponseRedirect(reverse("mfa_authenticate"))
return response, cont

View File

@@ -0,0 +1,29 @@
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from allauth.mfa import recovery_codes, totp
@pytest.fixture
def user_with_totp(user):
totp.TOTP.activate(user, totp.generate_totp_secret())
return user
@pytest.fixture
def user_with_recovery_codes(user):
recovery_codes.RecoveryCodes.activate(user)
return user
@pytest.fixture
def totp_validation_bypass():
@contextmanager
def f():
with patch("allauth.mfa.totp.validate_totp_code") as m:
m.return_value = True
yield
return f

View File

@@ -0,0 +1,35 @@
from allauth.mfa import app_settings
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes import RecoveryCodes
def test_flow(user):
rc = RecoveryCodes.activate(user)
codes = rc.generate_codes()
assert len(set(codes)) == app_settings.RECOVERY_CODE_COUNT
for i in range(app_settings.RECOVERY_CODE_COUNT):
assert not rc._is_code_used(i)
idx = 3
assert rc.validate_code(codes[idx])
for i in range(app_settings.RECOVERY_CODE_COUNT):
assert rc._is_code_used(i) == (i == idx)
assert not rc.validate_code(codes[idx])
unused_codes = rc.get_unused_codes()
assert codes[idx] not in unused_codes
assert len(unused_codes) == app_settings.RECOVERY_CODE_COUNT - 1
def test_migrated_codes(db, user):
auth = Authenticator(user=user, data={"migrated_codes": ["abc", "def"]})
rc = RecoveryCodes(auth)
assert rc.generate_codes() == ["abc", "def"]
assert rc.get_unused_codes() == ["abc", "def"]
assert not rc.validate_code("bad")
assert rc.validate_code("abc")
auth.refresh_from_db()
rc = RecoveryCodes(auth)
assert rc.generate_codes() == ["def"]
assert rc.get_unused_codes() == ["def"]
rc.validate_code("def")
assert rc.instance.data["migrated_codes"] == []

View File

@@ -0,0 +1,178 @@
from django.conf import settings
from django.urls import reverse
import pytest
from allauth.account.models import EmailAddress
from allauth.mfa import app_settings
from allauth.mfa.adapter import get_adapter
from allauth.mfa.models import Authenticator
@pytest.mark.parametrize(
"url_name",
(
"mfa_activate_totp",
"mfa_index",
"mfa_deactivate_totp",
),
)
def test_login_required_views(client, url_name):
resp = client.get(reverse(url_name))
assert resp.status_code == 302
assert resp["location"].startswith(reverse("account_login"))
def test_activate_totp_with_incorrect_code(auth_client, reauthentication_bypass):
with reauthentication_bypass():
resp = auth_client.get(reverse("mfa_activate_totp"))
resp = auth_client.post(
reverse("mfa_activate_totp"),
{
"code": "123",
},
)
assert resp.context["form"].errors == {
"code": [get_adapter().error_messages["incorrect_code"]]
}
def test_activate_totp_with_unverified_email(
auth_client, user, totp_validation_bypass, reauthentication_bypass
):
EmailAddress.objects.filter(user=user).update(verified=False)
with reauthentication_bypass():
resp = auth_client.get(reverse("mfa_activate_totp"))
with totp_validation_bypass():
resp = auth_client.post(
reverse("mfa_activate_totp"),
{
"code": "123",
},
)
assert resp.context["form"].errors == {
"code": [get_adapter().error_messages["unverified_email"]],
}
def test_activate_totp_success(
auth_client, totp_validation_bypass, user, reauthentication_bypass
):
with reauthentication_bypass():
resp = auth_client.get(reverse("mfa_activate_totp"))
with totp_validation_bypass():
resp = auth_client.post(
reverse("mfa_activate_totp"),
{
"code": "123",
},
)
assert resp["location"] == reverse("mfa_view_recovery_codes")
assert Authenticator.objects.filter(
user=user, type=Authenticator.Type.TOTP
).exists()
assert Authenticator.objects.filter(
user=user, type=Authenticator.Type.RECOVERY_CODES
).exists()
def test_index(auth_client, user_with_totp):
resp = auth_client.get(reverse("mfa_index"))
assert "authenticators" in resp.context
def test_deactivate_totp_success(auth_client, user_with_totp, user_password):
resp = auth_client.post(reverse("mfa_deactivate_totp"))
assert resp.status_code == 302
assert resp["location"].startswith(reverse("account_reauthenticate"))
resp = auth_client.post(resp["location"], {"password": user_password})
assert resp.status_code == 302
resp = auth_client.post(reverse("mfa_deactivate_totp"))
assert resp.status_code == 302
assert resp["location"] == reverse("mfa_index")
def test_user_without_totp_deactivate_totp(auth_client):
resp = auth_client.get(reverse("mfa_deactivate_totp"))
assert resp.status_code == 404
def test_user_with_totp_activate_totp(
auth_client, user_with_totp, reauthentication_bypass
):
with reauthentication_bypass():
resp = auth_client.get(reverse("mfa_activate_totp"))
assert resp.status_code == 302
assert resp["location"] == reverse("mfa_deactivate_totp")
def test_totp_login(client, user_with_totp, user_password, totp_validation_bypass):
resp = client.post(
reverse("account_login"),
{"login": user_with_totp.username, "password": user_password},
)
assert resp.status_code == 302
assert resp["location"] == reverse("mfa_authenticate")
resp = client.get(reverse("mfa_authenticate"))
assert resp.context["request"].user.is_anonymous
resp = client.post(reverse("mfa_authenticate"), {"code": "123"})
assert resp.context["form"].errors == {
"code": [get_adapter().error_messages["incorrect_code"]]
}
with totp_validation_bypass():
resp = client.post(
reverse("mfa_authenticate"),
{"code": "123"},
)
assert resp.status_code == 302
assert resp["location"] == settings.LOGIN_REDIRECT_URL
def test_download_recovery_codes(auth_client, user_with_recovery_codes, user_password):
resp = auth_client.get(reverse("mfa_download_recovery_codes"))
assert resp["location"].startswith(reverse("account_reauthenticate"))
resp = auth_client.post(resp["location"], {"password": user_password})
assert resp.status_code == 302
resp = auth_client.get(resp["location"])
assert resp["content-disposition"] == 'attachment; filename="recovery-codes.txt"'
def test_view_recovery_codes(auth_client, user_with_recovery_codes, user_password):
resp = auth_client.get(reverse("mfa_view_recovery_codes"))
assert resp["location"].startswith(reverse("account_reauthenticate"))
resp = auth_client.post(resp["location"], {"password": user_password})
assert resp.status_code == 302
resp = auth_client.get(resp["location"])
assert len(resp.context["unused_codes"]) == app_settings.RECOVERY_CODE_COUNT
def test_generate_recovery_codes(auth_client, user_with_recovery_codes, user_password):
rc = Authenticator.objects.get(
user=user_with_recovery_codes, type=Authenticator.Type.RECOVERY_CODES
).wrap()
prev_code = rc.get_unused_codes()[0]
resp = auth_client.get(reverse("mfa_generate_recovery_codes"))
assert resp["location"].startswith(reverse("account_reauthenticate"))
resp = auth_client.post(resp["location"], {"password": user_password})
assert resp.status_code == 302
resp = auth_client.post(resp["location"])
assert resp["location"] == reverse("mfa_view_recovery_codes")
rc = Authenticator.objects.get(
user=user_with_recovery_codes, type=Authenticator.Type.RECOVERY_CODES
).wrap()
assert not rc.validate_code(prev_code)
def test_add_email_not_allowed(auth_client, user_with_totp):
resp = auth_client.post(
reverse("account_email"),
{"action_add": "", "email": "change-to@this.org"},
)
assert resp.status_code == 200
assert resp.context["form"].errors == {
"email": [
"You cannot add an email address to an account protected by two-factor authentication."
]
}

View File

@@ -0,0 +1,109 @@
import base64
import hashlib
import hmac
import secrets
import struct
import time
from io import BytesIO
from urllib.parse import quote
from django.utils.http import urlencode
import qrcode
from qrcode.image.svg import SvgPathImage
from allauth.core import context
from allauth.mfa import app_settings
from allauth.mfa.models import Authenticator
from allauth.mfa.utils import decrypt, encrypt
SECRET_SESSION_KEY = "mfa.totp.secret"
def generate_totp_secret(length=20):
random_bytes = secrets.token_bytes(length)
return base64.b32encode(random_bytes).decode("utf-8")
def get_totp_secret(regenerate=False):
secret = None
if not regenerate:
secret = context.request.session.get(SECRET_SESSION_KEY)
if not secret:
secret = context.request.session[SECRET_SESSION_KEY] = generate_totp_secret()
return secret
def hotp_counter_from_time():
current_time = int(time.time()) # Get the current Unix timestamp
return current_time // app_settings.TOTP_PERIOD
def hotp_value(secret, counter):
# Convert the counter to a byte array using big-endian encoding
counter_bytes = struct.pack(">Q", counter)
secret_enc = base64.b32decode(secret.encode("ascii"), casefold=True)
# Calculate the HMAC-SHA1 hash using the secret and counter
hmac_result = hmac.new(secret_enc, counter_bytes, hashlib.sha1).digest()
# Get the last 4 bits of the HMAC result to determine the offset
offset = hmac_result[-1] & 0x0F
# Extract an 31-bit slice from the HMAC result starting at the offset + 1 bit
truncated_hash = bytearray(hmac_result[offset : offset + 4])
truncated_hash[0] = truncated_hash[0] & 0x7F
# Convert the truncated hash to an integer value
value = struct.unpack(">I", truncated_hash)[0]
# Apply modulo to get a value within the specified number of digits
value %= 10**app_settings.TOTP_DIGITS
return value
def build_totp_url(label, issuer, secret):
params = {
"secret": secret,
# This is the default
# "algorithm": "SHA1",
"issuer": issuer,
}
if app_settings.TOTP_DIGITS != 6:
params["digits"] = app_settings.TOTP_DIGITS
if app_settings.TOTP_PERIOD != 30:
params["period"] = app_settings.TOTP_PERIOD
return f"otpauth://totp/{quote(label)}?{urlencode(params)}"
def build_totp_svg(url):
img = qrcode.make(url, image_factory=SvgPathImage)
buf = BytesIO()
img.save(buf)
return buf.getvalue().decode("utf8")
def format_hotp_value(value):
return f"{value:0{app_settings.TOTP_DIGITS}}"
def validate_totp_code(secret, code):
value = hotp_value(secret, hotp_counter_from_time())
return code == format_hotp_value(value)
class TOTP:
def __init__(self, instance):
self.instance = instance
@classmethod
def activate(cls, user, secret):
instance = Authenticator(
user=user, type=Authenticator.Type.TOTP, data={"secret": encrypt(secret)}
)
instance.save()
return cls(instance)
def deactivate(self):
self.instance.delete()
Authenticator.objects.delete_dangling_recovery_codes(self.instance.user)
def validate_code(self, code):
secret = decrypt(self.instance.data["secret"])
return validate_totp_code(secret, code)

View File

@@ -0,0 +1,36 @@
from django.urls import include, path
from allauth.mfa import views
urlpatterns = [
path("", views.index, name="mfa_index"),
path("authenticate/", views.authenticate, name="mfa_authenticate"),
path(
"totp/",
include(
[
path("activate/", views.activate_totp, name="mfa_activate_totp"),
path("deactivate/", views.deactivate_totp, name="mfa_deactivate_totp"),
]
),
),
path(
"recovery-codes/",
include(
[
path("", views.view_recovery_codes, name="mfa_view_recovery_codes"),
path(
"generate/",
views.generate_recovery_codes,
name="mfa_generate_recovery_codes",
),
path(
"download/",
views.download_recovery_codes,
name="mfa_download_recovery_codes",
),
]
),
),
]

View File

@@ -0,0 +1,19 @@
from allauth.mfa.adapter import get_adapter
from allauth.mfa.models import Authenticator
def encrypt(text):
return get_adapter().encrypt(text)
def decrypt(encrypted_text):
return get_adapter().decrypt(encrypted_text)
def is_mfa_enabled(user, types=None):
if user.is_anonymous:
return False
qs = Authenticator.objects.filter(user=user)
if types is not None:
qs = qs.filter(type__in=types)
return qs.exists()

View File

@@ -0,0 +1,218 @@
from django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView
from django.views.generic.edit import FormView
from allauth.account import app_settings as account_settings
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.decorators import reauthentication_required
from allauth.account.stages import LoginStageController
from allauth.mfa import app_settings, totp
from allauth.mfa.adapter import get_adapter
from allauth.mfa.forms import ActivateTOTPForm, AuthenticateForm
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes import RecoveryCodes
from allauth.mfa.stages import AuthenticateStage
from allauth.mfa.utils import is_mfa_enabled
class AuthenticateView(FormView):
form_class = AuthenticateForm
template_name = "mfa/authenticate." + account_settings.TEMPLATE_EXTENSION
def dispatch(self, request, *args, **kwargs):
self.stage = LoginStageController.enter(request, AuthenticateStage.key)
if not self.stage or not is_mfa_enabled(
self.stage.login.user, [Authenticator.Type.TOTP]
):
return HttpResponseRedirect(reverse("account_login"))
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
ret = super().get_form_kwargs()
ret["user"] = self.stage.login.user
return ret
def form_valid(self, form):
return self.stage.exit()
authenticate = AuthenticateView.as_view()
@method_decorator(login_required, name="dispatch")
class IndexView(TemplateView):
template_name = "mfa/index." + account_settings.TEMPLATE_EXTENSION
def get_context_data(self, **kwargs):
ret = super().get_context_data(**kwargs)
authenticators = {
auth.type: auth.wrap()
for auth in Authenticator.objects.filter(user=self.request.user)
}
ret["authenticators"] = authenticators
return ret
index = IndexView.as_view()
@method_decorator(reauthentication_required, name="dispatch")
class ActivateTOTPView(FormView):
form_class = ActivateTOTPForm
template_name = "mfa/totp/activate_form." + account_settings.TEMPLATE_EXTENSION
success_url = reverse_lazy("mfa_view_recovery_codes")
def dispatch(self, request, *args, **kwargs):
if is_mfa_enabled(request.user, [Authenticator.Type.TOTP]):
return HttpResponseRedirect(reverse("mfa_deactivate_totp"))
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ret = super().get_context_data(**kwargs)
adapter = get_adapter()
totp_url = totp.build_totp_url(
adapter.get_totp_label(self.request.user),
adapter.get_totp_issuer(),
ret["form"].secret,
)
totp_svg = totp.build_totp_svg(totp_url)
ret.update(
{
"totp_svg": totp_svg,
"totp_url": totp_url,
}
)
return ret
def get_form_kwargs(self):
ret = super().get_form_kwargs()
ret["user"] = self.request.user
return ret
def form_valid(self, form):
totp.TOTP.activate(self.request.user, form.secret)
RecoveryCodes.activate(self.request.user)
adapter = get_account_adapter(self.request)
adapter.add_message(
self.request, messages.SUCCESS, "mfa/messages/totp_activated.txt"
)
return super().form_valid(form)
activate_totp = ActivateTOTPView.as_view()
@method_decorator(login_required, name="dispatch")
class DeactivateTOTPView(FormView):
form_class = forms.Form
template_name = "mfa/totp/deactivate_form." + account_settings.TEMPLATE_EXTENSION
success_url = reverse_lazy("mfa_index")
def dispatch(self, request, *args, **kwargs):
self.authenticator = get_object_or_404(
Authenticator,
user=self.request.user,
type=Authenticator.Type.TOTP,
)
if not is_mfa_enabled(request.user, [Authenticator.Type.TOTP]):
return HttpResponseRedirect(reverse("mfa_activate_totp"))
return self._dispatch(request, *args, **kwargs)
@method_decorator(reauthentication_required)
def _dispatch(self, request, *args, **kwargs):
"""There's no point to reauthenticate when MFA is not enabled, so the
`is_mfa_enabled` chheck needs to go first, which is why we cannot slap a
`reauthentication_required` decorator on the `dispatch` directly.
"""
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
self.authenticator.wrap().deactivate()
adapter = get_account_adapter(self.request)
adapter.add_message(
self.request, messages.SUCCESS, "mfa/messages/totp_deactivated.txt"
)
return super().form_valid(form)
deactivate_totp = DeactivateTOTPView.as_view()
@method_decorator(reauthentication_required, name="dispatch")
class GenerateRecoveryCodesView(FormView):
form_class = forms.Form
template_name = "mfa/recovery_codes/generate." + account_settings.TEMPLATE_EXTENSION
success_url = reverse_lazy("mfa_view_recovery_codes")
def form_valid(self, form):
Authenticator.objects.filter(
user=self.request.user, type=Authenticator.Type.RECOVERY_CODES
).delete()
RecoveryCodes.activate(self.request.user)
adapter = get_account_adapter(self.request)
adapter.add_message(
self.request, messages.SUCCESS, "mfa/messages/recovery_codes_generated.txt"
)
return super().form_valid(form)
generate_recovery_codes = GenerateRecoveryCodesView.as_view()
@method_decorator(reauthentication_required, name="dispatch")
class DownloadRecoveryCodesView(TemplateView):
template_name = "mfa/recovery_codes/download.txt"
content_type = "text/plain"
def dispatch(self, request, *args, **kwargs):
self.authenticator = get_object_or_404(
Authenticator,
user=self.request.user,
type=Authenticator.Type.RECOVERY_CODES,
)
self.unused_codes = self.authenticator.wrap().get_unused_codes()
if not self.unused_codes:
return Http404()
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ret = super().get_context_data(**kwargs)
ret["unused_codes"] = self.unused_codes
return ret
def render_to_response(self, context, **response_kwargs):
response = super().render_to_response(context, **response_kwargs)
response["Content-Disposition"] = 'attachment; filename="recovery-codes.txt"'
return response
download_recovery_codes = DownloadRecoveryCodesView.as_view()
@method_decorator(reauthentication_required, name="dispatch")
class ViewRecoveryCodesView(TemplateView):
template_name = "mfa/recovery_codes/index." + account_settings.TEMPLATE_EXTENSION
def get_context_data(self, **kwargs):
ret = super().get_context_data(**kwargs)
authenticator = get_object_or_404(
Authenticator,
user=self.request.user,
type=Authenticator.Type.RECOVERY_CODES,
)
ret.update(
{
"unused_codes": authenticator.wrap().get_unused_codes(),
"total_count": app_settings.RECOVERY_CODE_COUNT,
}
)
return ret
view_recovery_codes = ViewRecoveryCodesView.as_view()