first commit

This commit is contained in:
pacnpal
2024-10-28 17:09:57 -04:00
commit 1339baec59
9993 changed files with 1182741 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
from io import BytesIO
from typing import Dict
from urllib.parse import quote
from django.utils.http import urlencode
from django.utils.translation import gettext, gettext_lazy as _
from allauth import app_settings as allauth_settings
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.utils import (
user_display,
user_email,
user_pk_to_url_str,
user_username,
)
from allauth.core import context
from allauth.core.internal.adapter import BaseAdapter
from allauth.mfa import app_settings
from allauth.mfa.models import Authenticator
from allauth.utils import import_attribute
class DefaultMFAAdapter(BaseAdapter):
"""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 needs.
"""
error_messages = {
"add_email_blocked": _(
"You cannot add an email address to an account protected by two-factor authentication."
),
"cannot_delete_authenticator": _(
"You cannot deactivate two-factor authentication."
),
"cannot_generate_recovery_codes": _(
"You cannot generate recovery codes without having two-factor authentication enabled."
),
"incorrect_code": _("Incorrect code."),
"unverified_email": _(
"You cannot activate two-factor authentication until you have verified your email address."
),
}
"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.
"""
return self._get_user_identifier(user)
def _get_user_identifier(self, user) -> str:
"""Human-palatable identifier for a user account. It is intended only
for display.
"""
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:
issuer = self._get_site_name()
return issuer
def build_totp_url(self, user, secret: str) -> str:
label = self.get_totp_label(user)
issuer = self.get_totp_issuer()
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(self, url: str) -> str:
import qrcode
from qrcode.image.svg import SvgPathImage
img = qrcode.make(url, image_factory=SvgPathImage)
buf = BytesIO()
img.save(buf)
return buf.getvalue().decode("utf8")
def _get_site_name(self) -> str:
if allauth_settings.SITES_ENABLED:
from django.contrib.sites.models import Site
return Site.objects.get_current(context.request).name
else:
return context.request.get_host()
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 can_delete_authenticator(self, authenticator: Authenticator) -> bool:
return True
def send_notification_mail(self, *args, **kwargs):
return get_account_adapter().send_notification_mail(*args, **kwargs)
def is_mfa_enabled(self, user, types=None) -> bool:
"""
Returns ``True`` if (and only if) the user has 2FA enabled.
"""
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()
def generate_authenticator_name(self, user, type: Authenticator.Type) -> str:
"""
Generate a human friendly name for the key. Used to prefill the "Add
key" form.
"""
n = Authenticator.objects.filter(user=user, type=type).count()
if n == 0:
return gettext("Master key")
elif n == 1:
return gettext("Backup key")
return gettext("Key nr. {number}").format(number=n + 1)
def get_public_key_credential_rp_entity(self) -> Dict[str, str]:
name = self._get_site_name()
return {
"id": context.request.get_host().partition(":")[0],
"name": name,
}
def get_public_key_credential_user_entity(self, user) -> dict:
return {
"id": user_pk_to_url_str(user).encode("utf8"),
"display_name": user_display(user),
"name": self._get_user_identifier(user),
}
def get_adapter() -> DefaultMFAAdapter:
return import_attribute(app_settings.ADAPTER)()

View File

@@ -0,0 +1,10 @@
from django.contrib import admin
from allauth.mfa.models import Authenticator
@admin.register(Authenticator)
class AuthenticatorAdmin(admin.ModelAdmin):
raw_id_fields = ("user",)
list_display = ("user", "type", "created_at", "last_used_at")
list_filter = ("type", "created_at", "last_used_at")

View File

@@ -0,0 +1,88 @@
class AppSettings:
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 FORMS(self):
return self._setting("FORMS", {})
@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", "")
@property
def TOTP_INSECURE_BYPASS_CODE(self):
"""
Don't use this on production. Useful for development & E2E tests only.
"""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
code = self._setting("TOTP_INSECURE_BYPASS_CODE", None)
if (not settings.DEBUG) and code:
raise ImproperlyConfigured(
"MFA_TOTP_INSECURE_BYPASS_CODE is for testing purposes only"
)
return code
@property
def SUPPORTED_TYPES(self):
dflt = ["recovery_codes", "totp"]
return self._setting("SUPPORTED_TYPES", dflt)
@property
def WEBAUTHN_ALLOW_INSECURE_ORIGIN(self):
return self._setting("WEBAUTHN_ALLOW_INSECURE_ORIGIN", False)
@property
def PASSKEY_LOGIN_ENABLED(self) -> bool:
return "webauthn" in self.SUPPORTED_TYPES and self._setting(
"PASSKEY_LOGIN_ENABLED", False
)
@property
def PASSKEY_SIGNUP_ENABLED(self) -> bool:
return "webauthn" in self.SUPPORTED_TYPES and self._setting(
"PASSKEY_SIGNUP_ENABLED", False
)
_app_settings = AppSettings("MFA_")
def __getattr__(name):
# See https://peps.python.org/pep-0562/
return getattr(_app_settings, name)

View File

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

View File

@@ -0,0 +1,47 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from allauth.core import context
from allauth.mfa.adapter import get_adapter
from allauth.mfa.base.internal.flows import (
check_rate_limit,
post_authentication,
)
from allauth.mfa.models import Authenticator
class BaseAuthenticateForm(forms.Form):
code = forms.CharField(
label=_("Code"),
widget=forms.TextInput(
attrs={"placeholder": _("Code"), "autocomplete": "one-time-code"},
),
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
def clean_code(self):
clear_rl = check_rate_limit(self.user)
code = self.cleaned_data["code"]
for auth in Authenticator.objects.filter(user=self.user).exclude(
# WebAuthn cannot validate manual codes.
type=Authenticator.Type.WEBAUTHN
):
if auth.wrap().validate_code(code):
self.authenticator = auth
clear_rl()
return code
raise get_adapter().validation_error("incorrect_code")
class AuthenticateForm(BaseAuthenticateForm):
def save(self):
post_authentication(context.request, self.authenticator)
class ReauthenticateForm(BaseAuthenticateForm):
def save(self):
post_authentication(context.request, self.authenticator, reauthenticated=True)

View File

@@ -0,0 +1,58 @@
from typing import Callable, Optional
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.internal.flows.login import record_authentication
from allauth.core import context, ratelimit
from allauth.mfa import signals
from allauth.mfa.models import Authenticator
def delete_dangling_recovery_codes(user) -> Optional[Authenticator]:
deleted_authenticator = None
qs = Authenticator.objects.filter(user=user)
if not qs.exclude(type=Authenticator.Type.RECOVERY_CODES).exists():
deleted_authenticator = qs.first()
qs.delete()
return deleted_authenticator
def delete_and_cleanup(request, authenticator) -> None:
authenticator.delete()
rc_auth = delete_dangling_recovery_codes(authenticator.user)
for auth in [authenticator, rc_auth]:
if auth:
signals.authenticator_removed.send(
sender=Authenticator,
request=request,
user=request.user,
authenticator=auth,
)
def post_authentication(
request,
authenticator: Authenticator,
reauthenticated: bool = False,
passwordless: bool = False,
) -> None:
authenticator.record_usage()
extra_data = {
"id": authenticator.pk,
"type": authenticator.type,
}
if reauthenticated:
extra_data["reauthenticated"] = True
if passwordless:
extra_data["passwordless"] = True
record_authentication(request, "mfa", **extra_data)
def check_rate_limit(user) -> Callable[[], None]:
key = f"mfa-auth-user-{str(user.pk)}"
if not ratelimit.consume(
context.request,
action="login_failed",
key=key,
):
raise get_account_adapter().validation_error("too_many_login_attempts")
return lambda: ratelimit.clear(context.request, action="login_failed", key=key)

View File

@@ -0,0 +1,66 @@
from unittest.mock import ANY
from django.urls import reverse
import pytest
from pytest_django.asserts import assertTemplateUsed
from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY
from allauth.mfa.models import Authenticator
def test_reauthentication(auth_client, user_with_recovery_codes):
resp = auth_client.get(reverse("mfa_view_recovery_codes"))
assert resp.status_code == 302
assert resp["location"].startswith(reverse("account_reauthenticate"))
resp = auth_client.get(reverse("mfa_reauthenticate"))
assertTemplateUsed(resp, "mfa/reauthenticate.html")
authenticator = Authenticator.objects.get(
user=user_with_recovery_codes, type=Authenticator.Type.RECOVERY_CODES
)
unused_code = authenticator.wrap().get_unused_codes()[0]
resp = auth_client.post(reverse("mfa_reauthenticate"), data={"code": unused_code})
assert resp.status_code == 302
resp = auth_client.get(reverse("mfa_view_recovery_codes"))
assert resp.status_code == 200
assertTemplateUsed(resp, "mfa/recovery_codes/index.html")
methods = auth_client.session[AUTHENTICATION_METHODS_SESSION_KEY]
assert methods[-1] == {
"method": "mfa",
"type": "recovery_codes",
"id": authenticator.pk,
"at": ANY,
"reauthenticated": True,
}
@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_index(auth_client, user_with_totp):
resp = auth_client.get(reverse("mfa_index"))
assert "authenticators" in resp.context
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,12 @@
from typing import List, Union
from django.urls import URLPattern, URLResolver, path
from allauth.mfa.base import views
urlpatterns: List[Union[URLPattern, URLResolver]] = [
path("", views.index, name="mfa_index"),
path("authenticate/", views.authenticate, name="mfa_authenticate"),
path("reauthenticate/", views.reauthenticate, name="mfa_reauthenticate"),
]

View File

@@ -0,0 +1,152 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView
from allauth.account import app_settings as account_settings
from allauth.account.internal.decorators import login_stage_required
from allauth.account.views import BaseReauthenticateView
from allauth.mfa import app_settings
from allauth.mfa.base.forms import AuthenticateForm, ReauthenticateForm
from allauth.mfa.models import Authenticator
from allauth.mfa.stages import AuthenticateStage
from allauth.mfa.utils import is_mfa_enabled
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
from allauth.mfa.webauthn.internal.flows import auth as webauthn_auth
from allauth.utils import get_form_class
@method_decorator(
login_stage_required(stage=AuthenticateStage.key, redirect_urlname="account_login"),
name="dispatch",
)
class AuthenticateView(TemplateView):
form_class = AuthenticateForm
webauthn_form_class = AuthenticateWebAuthnForm
template_name = "mfa/authenticate." + account_settings.TEMPLATE_EXTENSION
def dispatch(self, request, *args, **kwargs):
self.stage = request._login_stage
if not is_mfa_enabled(
self.stage.login.user,
[Authenticator.Type.TOTP, Authenticator.Type.WEBAUTHN],
):
return HttpResponseRedirect(reverse("account_login"))
self.form = self._build_forms()
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
if self.form.is_valid():
return self.form_valid(self.form)
else:
return self.form_invalid(self.form)
def _build_forms(self):
posted_form = None
AuthenticateFormClass = self.get_form_class()
AuthenticateWebAuthnFormClass = self.get_webauthn_form_class()
user = self.stage.login.user
support_webauthn = "webauthn" in app_settings.SUPPORTED_TYPES
if self.request.method == "POST":
if "code" in self.request.POST:
posted_form = self.auth_form = AuthenticateFormClass(
user=user, data=self.request.POST
)
self.webauthn_form = (
AuthenticateWebAuthnFormClass(user=user)
if support_webauthn
else None
)
else:
self.auth_form = (
AuthenticateFormClass(user=user) if support_webauthn else None
)
posted_form = self.webauthn_form = AuthenticateWebAuthnFormClass(
user=user, data=self.request.POST
)
else:
self.auth_form = AuthenticateFormClass(user=user)
self.webauthn_form = (
AuthenticateWebAuthnFormClass(user=user) if support_webauthn else None
)
return posted_form
def get_form_class(self):
return get_form_class(app_settings.FORMS, "authenticate", self.form_class)
def get_webauthn_form_class(self):
return get_form_class(
app_settings.FORMS, "authenticate_webauthn", self.webauthn_form_class
)
def form_valid(self, form):
form.save()
return self.stage.exit()
def form_invalid(self, form):
return super().get(self.request)
def get_context_data(self, **kwargs):
ret = super().get_context_data()
ret.update(
{
"form": self.auth_form,
"MFA_SUPPORTED_TYPES": app_settings.SUPPORTED_TYPES,
}
)
if self.webauthn_form:
request_options = webauthn_auth.begin_authentication(self.stage.login.user)
ret.update(
{
"webauthn_form": self.webauthn_form,
"js_data": {"request_options": request_options},
}
)
return ret
authenticate = AuthenticateView.as_view()
@method_decorator(login_required, name="dispatch")
class ReauthenticateView(BaseReauthenticateView):
form_class = ReauthenticateForm
template_name = "mfa/reauthenticate." + account_settings.TEMPLATE_EXTENSION
def get_form_kwargs(self):
ret = super().get_form_kwargs()
ret["user"] = self.request.user
return ret
def get_form_class(self):
return get_form_class(app_settings.FORMS, "reauthenticate", self.form_class)
def form_valid(self, form):
form.save()
return super().form_valid(form)
reauthenticate = ReauthenticateView.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 = {}
for auth in Authenticator.objects.filter(user=self.request.user):
if auth.type == Authenticator.Type.WEBAUTHN:
auths = authenticators.setdefault(auth.type, [])
auths.append(auth.wrap())
else:
authenticators[auth.type] = auth.wrap()
ret["authenticators"] = authenticators
ret["MFA_SUPPORTED_TYPES"] = app_settings.SUPPORTED_TYPES
ret["is_mfa_enabled"] = is_mfa_enabled(self.request.user)
return ret
index = IndexView.as_view()

View File

@@ -0,0 +1,41 @@
from django.core.checks import Critical, register
@register()
def settings_check(app_configs, **kwargs):
from allauth.account import app_settings as account_settings
from allauth.mfa import app_settings
from allauth.mfa.models import Authenticator
ret = []
if app_settings.PASSKEY_SIGNUP_ENABLED:
if Authenticator.Type.WEBAUTHN not in app_settings.SUPPORTED_TYPES:
ret.append(
Critical(
msg="MFA_PASSKEY_SIGNUP_ENABLED requires MFA_SUPPORTED_TYPES to include 'webauthn'"
)
)
if not account_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED:
# The fact that a signup is passkey based is stored in the session,
# which gets lost when using link based verification.
ret.append(
Critical(
msg="MFA_PASSKEY_SIGNUP_ENABLED requires ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED"
)
)
if not account_settings.EMAIL_REQUIRED:
ret.append(
Critical(
msg="MFA_PASSKEY_SIGNUP_ENABLED requires ACCOUNT_EMAIL_REQUIRED"
)
)
if (
account_settings.EMAIL_VERIFICATION
!= account_settings.EmailVerificationMethod.MANDATORY
):
ret.append(
Critical(
msg="MFA_PASSKEY_SIGNUP_ENABLED requires ACCOUNT_EMAIL_VERIFICIATION = 'mandatory'"
)
)
return ret

View File

@@ -0,0 +1,42 @@
from functools import wraps
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.urls import reverse
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.models import EmailAddress
from allauth.mfa.adapter import get_adapter
def validate_can_add_authenticator(user):
"""
If we would allow users to enable 2FA with unverified email address,
that would allow for an attacker to signup, not verify and prevent the real
owner of the account from ever regaining access.
"""
email_verified = not EmailAddress.objects.filter(user=user, verified=False).exists()
if not email_verified:
raise get_adapter().validation_error("unverified_email")
def redirect_if_add_not_allowed(function=None):
def decorator(view_func):
@wraps(view_func)
def _wrapper_view(request, *args, **kwargs):
if request.user.is_authenticated: # allow for this to go before reauth
try:
validate_can_add_authenticator(request.user)
except ValidationError as e:
for message in e.messages:
adapter = get_account_adapter()
adapter.add_message(request, messages.ERROR, message=message)
return HttpResponseRedirect(reverse("mfa_index"))
return view_func(request, *args, **kwargs)
return _wrapper_view
if function:
return decorator(function)
return decorator

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,23 @@
# Generated by Django 3.2.22 on 2023-11-06 12:04
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("mfa", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="authenticator",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="authenticator",
name="last_used_at",
field=models.DateTimeField(null=True),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 3.2.20 on 2023-09-27 11:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("mfa", "0002_authenticator_timestamps"),
]
operations = [
migrations.AlterField(
model_name="authenticator",
name="type",
field=models.CharField(
choices=[
("recovery_codes", "Recovery codes"),
("totp", "TOTP Authenticator"),
("webauthn", "WebAuthn"),
],
max_length=20,
),
),
migrations.AlterUniqueTogether(
name="authenticator",
unique_together=set(),
),
migrations.AddConstraint(
model_name="authenticator",
constraint=models.UniqueConstraint(
condition=models.Q(("type__in", ("totp", "recovery_codes"))),
fields=("user", "type"),
name="unique_authenticator_type",
),
),
]

View File

@@ -0,0 +1,68 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db import models
from django.db.models import Q
from django.db.models.constraints import UniqueConstraint
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from allauth import app_settings as allauth_settings
if not allauth_settings.MFA_ENABLED:
raise ImproperlyConfigured(
"allauth.mfa not installed, yet its models are imported."
)
class AuthenticatorManager(models.Manager):
pass
class Authenticator(models.Model):
class Type(models.TextChoices):
RECOVERY_CODES = "recovery_codes", _("Recovery codes")
TOTP = "totp", _("TOTP Authenticator")
WEBAUTHN = "webauthn", _("WebAuthn")
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()
created_at = models.DateTimeField(default=timezone.now)
last_used_at = models.DateTimeField(null=True)
class Meta:
constraints = [
UniqueConstraint(
fields=["user", "type"],
name="unique_authenticator_type",
condition=Q(
type__in=(
"totp",
"recovery_codes",
)
),
)
]
def __str__(self):
if self.type == self.Type.WEBAUTHN:
return self.wrap().name
return self.get_type_display()
def wrap(self):
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
from allauth.mfa.totp.internal.auth import TOTP
from allauth.mfa.webauthn.internal.auth import WebAuthn
return {
self.Type.TOTP: TOTP,
self.Type.RECOVERY_CODES: RecoveryCodes,
self.Type.WEBAUTHN: WebAuthn,
}[self.type](self)
def record_usage(self) -> None:
self.last_used_at = timezone.now()
self.save(update_fields=["last_used_at"])

View File

@@ -0,0 +1,16 @@
from django import forms
from allauth.mfa.adapter import get_adapter
from allauth.mfa.recovery_codes.internal import flows
class GenerateRecoveryCodesForm(forms.Form):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
if not flows.can_generate_recovery_codes(self.user):
raise get_adapter().validation_error("cannot_generate_recovery_codes")
return cleaned_data

View File

@@ -0,0 +1,111 @@
import binascii
import hmac
import os
import struct
from hashlib import sha1
from typing import List, Optional
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: Authenticator) -> None:
self.instance = instance
@classmethod
def activate(cls, user) -> "RecoveryCodes":
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) -> str:
key = binascii.hexlify(os.urandom(20)).decode("ascii")
return key
def _get_migrated_codes(self) -> Optional[List[str]]:
codes = self.instance.data.get("migrated_codes")
if codes is not None:
return [decrypt(code) for code in codes]
return None
def generate_codes(self) -> List[str]:
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: int) -> bool:
used_mask = self.instance.data["used_mask"]
return bool(used_mask & (1 << i))
def _mark_code_used(self, i: int) -> None:
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) -> List[str]:
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: str) -> Optional[bool]:
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"]
assert isinstance(migrated_codes, list)
migrated_codes.pop(idx)
self.instance.data["migrated_codes"] = migrated_codes
self.instance.save()
return True
def validate_code(self, code: str) -> bool:
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,77 @@
from typing import Optional
from django.contrib import messages
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.internal.flows.reauthentication import (
raise_if_reauthentication_required,
)
from allauth.mfa import app_settings, signals
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
def can_generate_recovery_codes(user) -> bool:
return (
Authenticator.objects.filter(user=user)
.exclude(type=Authenticator.Type.RECOVERY_CODES)
.exists()
)
def generate_recovery_codes(request) -> Authenticator:
raise_if_reauthentication_required(request)
Authenticator.objects.filter(
user=request.user, type=Authenticator.Type.RECOVERY_CODES
).delete()
rc_auth = RecoveryCodes.activate(request.user)
authenticator = rc_auth.instance
adapter = get_account_adapter(request)
adapter.add_message(
request, messages.SUCCESS, "mfa/messages/recovery_codes_generated.txt"
)
signals.authenticator_reset.send(
sender=Authenticator,
request=request,
user=request.user,
authenticator=authenticator,
)
adapter.send_notification_mail("mfa/email/recovery_codes_generated", request.user)
return authenticator
def view_recovery_codes(request) -> Optional[Authenticator]:
authenticator = Authenticator.objects.filter(
user=request.user,
type=Authenticator.Type.RECOVERY_CODES,
).first()
if not authenticator:
return None
raise_if_reauthentication_required(request)
return authenticator
def auto_generate_recovery_codes(request) -> Optional[Authenticator]:
"""Automatically (implicitly) setup recovery codes when another
authenticator is setup for. As this is part of setting up another (primary)
authenticator, we do not send a notification email in this case.
"""
if Authenticator.Type.RECOVERY_CODES not in app_settings.SUPPORTED_TYPES:
return None
has_rc = Authenticator.objects.filter(
user=request.user, type=Authenticator.Type.RECOVERY_CODES
).exists()
if has_rc:
return None
rc = RecoveryCodes.activate(request.user)
signals.authenticator_added.send(
sender=Authenticator,
request=request,
user=request.user,
authenticator=rc.instance,
)
adapter = get_account_adapter(request)
adapter.add_message(
request, messages.SUCCESS, "mfa/messages/recovery_codes_generated.txt"
)
return rc.instance

View File

@@ -0,0 +1,35 @@
from allauth.mfa import app_settings
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.auth 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,100 @@
from unittest.mock import ANY
from django.conf import settings
from django.urls import reverse
from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY
from allauth.mfa import app_settings
from allauth.mfa.adapter import get_adapter
from allauth.mfa.models import Authenticator
def test_generate_recovery_codes_require_other_authenticator(
auth_client, user, settings, reauthentication_bypass
):
with reauthentication_bypass():
resp = auth_client.post(reverse("mfa_generate_recovery_codes"))
assert resp.context["form"].errors == {
"__all__": [
"You cannot generate recovery codes without having two-factor authentication enabled."
]
}
assert not Authenticator.objects.filter(user=user).exists()
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, settings, mailoutbox
):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
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)
assert len(mailoutbox) == 1
assert "New Recovery Codes Generated" in mailoutbox[0].subject
assert "A new set of" in mailoutbox[0].body
def test_recovery_codes_login(
client, user_with_totp, user_with_recovery_codes, user_password
):
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"]]
}
rc = Authenticator.objects.get(
user=user_with_recovery_codes, type=Authenticator.Type.RECOVERY_CODES
)
resp = client.post(
reverse("mfa_authenticate"),
{"code": rc.wrap().get_unused_codes()[0]},
)
assert resp.status_code == 302
assert resp["location"] == settings.LOGIN_REDIRECT_URL
assert client.session[AUTHENTICATION_METHODS_SESSION_KEY] == [
{"method": "password", "at": ANY, "username": user_with_totp.username},
{
"method": "mfa",
"at": ANY,
"id": ANY,
"type": Authenticator.Type.RECOVERY_CODES,
},
]

View File

@@ -0,0 +1,20 @@
from typing import List, Union
from django.urls import URLPattern, URLResolver, path
from allauth.mfa.recovery_codes import views
urlpatterns: List[Union[URLPattern, URLResolver]] = [
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,98 @@
from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.urls import 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.decorators import reauthentication_required
from allauth.mfa import app_settings
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
from allauth.mfa.recovery_codes.internal import flows
from allauth.utils import get_form_class
@method_decorator(reauthentication_required, name="dispatch")
class GenerateRecoveryCodesView(FormView):
form_class = GenerateRecoveryCodesForm
template_name = "mfa/recovery_codes/generate." + account_settings.TEMPLATE_EXTENSION
success_url = reverse_lazy("mfa_view_recovery_codes")
def form_valid(self, form):
flows.generate_recovery_codes(self.request)
return super().form_valid(form)
def get_context_data(self, **kwargs):
ret = super().get_context_data(**kwargs)
unused_codes = []
authenticator = Authenticator.objects.filter(
user=self.request.user, type=Authenticator.Type.RECOVERY_CODES
).first()
if authenticator:
unused_codes = authenticator.wrap().get_unused_codes()
ret["unused_code_count"] = len(unused_codes)
return ret
def get_form_kwargs(self):
ret = super().get_form_kwargs()
ret["user"] = self.request.user
return ret
def get_form_class(self):
return get_form_class(
app_settings.FORMS, "generate_recovery_codes", self.form_class
)
generate_recovery_codes = GenerateRecoveryCodesView.as_view()
@method_decorator(login_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 = flows.view_recovery_codes(self.request)
if not self.authenticator:
raise Http404()
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(login_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 = flows.view_recovery_codes(self.request)
if not authenticator:
raise Http404()
ret.update(
{
"unused_codes": authenticator.wrap().get_unused_codes(),
"total_count": app_settings.RECOVERY_CODE_COUNT,
}
)
return ret
view_recovery_codes = ViewRecoveryCodesView.as_view()

View File

@@ -0,0 +1,23 @@
from django.dispatch import Signal
from allauth.mfa.adapter import get_adapter
from allauth.mfa.utils import is_mfa_enabled
# Emitted when an authenticator is added.
# Arguments: request, user, authenticator
authenticator_added = Signal()
# Emitted when an authenticator is removed.
# Arguments: request, user, authenticator
authenticator_removed = Signal()
# Emitted when an authenticator is reset (e.g. recovery codes regenerated).
# Arguments: request, user, authenticator
authenticator_reset = Signal()
def on_add_email(sender, email, user, **kwargs):
if is_mfa_enabled(user):
adapter = get_adapter()
raise adapter.validation_error("add_email_blocked")

View File

@@ -0,0 +1,26 @@
from allauth.account.stages import LoginStage
from allauth.core.internal.httpkit import headed_redirect_response
from allauth.mfa.models import Authenticator
from allauth.mfa.utils import is_mfa_enabled
from allauth.mfa.webauthn.internal.flows import did_use_passwordless_login
class AuthenticateStage(LoginStage):
# NOTE: Duplicated in `allauth.headless.constants.Flow.MFA_AUTHENTICATE`.
key = "mfa_authenticate"
urlname = "mfa_authenticate"
def handle(self):
response, cont = None, True
if self._should_handle(self.request):
response = headed_redirect_response("mfa_authenticate")
return response, cont
def _should_handle(self, request) -> bool:
if not is_mfa_enabled(
self.login.user, [Authenticator.Type.TOTP, Authenticator.Type.WEBAUTHN]
):
return False
if did_use_passwordless_login(request):
return False
return True

View File

@@ -0,0 +1,261 @@
// https://github.com/github/webauthn-json
'use strict';
(() => {
const __defProp = Object.defineProperty
const __export = (target, all) => {
for (const name in all) { __defProp(target, name, { get: all[name], enumerable: true }) }
}
const __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
const fulfilled = (value) => {
try {
step(generator.next(value))
} catch (e) {
reject(e)
}
}
const rejected = (value) => {
try {
step(generator.throw(value))
} catch (e) {
reject(e)
}
}
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected)
step((generator = generator.apply(__this, __arguments)).next())
})
}
// src/webauthn-json/index.ts
const webauthn_json_exports = {}
__export(webauthn_json_exports, {
create: () => create,
get: () => get,
schema: () => schema,
supported: () => supported
})
// src/webauthn-json/base64url.ts
function base64urlToBuffer (baseurl64String) {
const padding = '=='.slice(0, (4 - baseurl64String.length % 4) % 4)
const base64String = baseurl64String.replace(/-/g, '+').replace(/_/g, '/') + padding
const str = atob(base64String)
const buffer = new ArrayBuffer(str.length)
const byteView = new Uint8Array(buffer)
for (let i = 0; i < str.length; i++) {
byteView[i] = str.charCodeAt(i)
}
return buffer
}
function bufferToBase64url (buffer) {
const byteView = new Uint8Array(buffer)
let str = ''
for (const charCode of byteView) {
str += String.fromCharCode(charCode)
}
const base64String = btoa(str)
const base64urlString = base64String.replace(/\+/g, '-').replace(
/\//g,
'_'
).replace(/=/g, '')
return base64urlString
}
// src/webauthn-json/convert.ts
const copyValue = 'copy'
const convertValue = 'convert'
function convert (conversionFn, schema2, input) {
if (schema2 === copyValue) {
return input
}
if (schema2 === convertValue) {
return conversionFn(input)
}
if (schema2 instanceof Array) {
return input.map((v) => convert(conversionFn, schema2[0], v))
}
if (schema2 instanceof Object) {
const output = {}
for (const [key, schemaField] of Object.entries(schema2)) {
if (schemaField.derive) {
const v = schemaField.derive(input)
if (v !== void 0) {
input[key] = v
}
}
if (!(key in input)) {
if (schemaField.required) {
throw new Error(`Missing key: ${key}`)
}
continue
}
if (input[key] == null) {
output[key] = null
continue
}
output[key] = convert(
conversionFn,
schemaField.schema,
input[key]
)
}
return output
}
}
function derived (schema2, derive) {
return {
required: true,
schema: schema2,
derive
}
}
function required (schema2) {
return {
required: true,
schema: schema2
}
}
function optional (schema2) {
return {
required: false,
schema: schema2
}
}
// src/webauthn-json/basic/schema.ts
const publicKeyCredentialDescriptorSchema = {
type: required(copyValue),
id: required(convertValue),
transports: optional(copyValue)
}
const simplifiedExtensionsSchema = {
appid: optional(copyValue),
appidExclude: optional(copyValue),
credProps: optional(copyValue)
}
const simplifiedClientExtensionResultsSchema = {
appid: optional(copyValue),
appidExclude: optional(copyValue),
credProps: optional(copyValue)
}
const credentialCreationOptions = {
publicKey: required({
rp: required(copyValue),
user: required({
id: required(convertValue),
name: required(copyValue),
displayName: required(copyValue)
}),
challenge: required(convertValue),
pubKeyCredParams: required(copyValue),
timeout: optional(copyValue),
excludeCredentials: optional([publicKeyCredentialDescriptorSchema]),
authenticatorSelection: optional(copyValue),
attestation: optional(copyValue),
extensions: optional(simplifiedExtensionsSchema)
}),
signal: optional(copyValue)
}
const publicKeyCredentialWithAttestation = {
type: required(copyValue),
id: required(copyValue),
rawId: required(convertValue),
authenticatorAttachment: optional(copyValue),
response: required({
clientDataJSON: required(convertValue),
attestationObject: required(convertValue),
transports: derived(
copyValue,
(response) => {
let _a
return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || []
}
)
}),
clientExtensionResults: derived(
simplifiedClientExtensionResultsSchema,
(pkc) => pkc.getClientExtensionResults()
)
}
const credentialRequestOptions = {
mediation: optional(copyValue),
publicKey: required({
challenge: required(convertValue),
timeout: optional(copyValue),
rpId: optional(copyValue),
allowCredentials: optional([publicKeyCredentialDescriptorSchema]),
userVerification: optional(copyValue),
extensions: optional(simplifiedExtensionsSchema)
}),
signal: optional(copyValue)
}
const publicKeyCredentialWithAssertion = {
type: required(copyValue),
id: required(copyValue),
rawId: required(convertValue),
authenticatorAttachment: optional(copyValue),
response: required({
clientDataJSON: required(convertValue),
authenticatorData: required(convertValue),
signature: required(convertValue),
userHandle: required(convertValue)
}),
clientExtensionResults: derived(
simplifiedClientExtensionResultsSchema,
(pkc) => pkc.getClientExtensionResults()
)
}
var schema = {
credentialCreationOptions,
publicKeyCredentialWithAttestation,
credentialRequestOptions,
publicKeyCredentialWithAssertion
}
// src/webauthn-json/basic/api.ts
function createRequestFromJSON (requestJSON) {
return convert(base64urlToBuffer, credentialCreationOptions, requestJSON)
}
function createResponseToJSON (credential) {
return convert(
bufferToBase64url,
publicKeyCredentialWithAttestation,
credential
)
}
function create (requestJSON) {
return __async(this, null, function * () {
const credential = yield navigator.credentials.create(
createRequestFromJSON(requestJSON)
)
return createResponseToJSON(credential)
})
}
function getRequestFromJSON (requestJSON) {
return convert(base64urlToBuffer, credentialRequestOptions, requestJSON)
}
function getResponseToJSON (credential) {
return convert(
bufferToBase64url,
publicKeyCredentialWithAssertion,
credential
)
}
function get (requestJSON) {
return __async(this, null, function * () {
const credential = yield navigator.credentials.get(
getRequestFromJSON(requestJSON)
)
return getResponseToJSON(credential)
})
}
// src/webauthn-json/basic/supported.ts
function supported () {
return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential)
}
// src/webauthn-json/browser-global.ts
globalThis.webauthnJSON = webauthn_json_exports
})()
// # sourceMappingURL=webauthn-json.browser-global.js.map

View File

@@ -0,0 +1,101 @@
(function () {
const allauth = window.allauth = window.allauth || {}
const webauthnJSON = window.webauthnJSON
function dispatchError (exception) {
const event = new CustomEvent('allauth.error', { detail: { tags: ['mfa', 'webauthn'], exception }, cancelable: true })
document.dispatchEvent(event)
if (!event.defaultPrevented) {
console.error(exception)
}
}
async function createCredentials (credentials, passwordless) {
credentials = JSON.parse(JSON.stringify(credentials))
const sel = credentials.publicKey.authenticatorSelection
if (passwordless != null) {
sel.residentKey = passwordless ? 'required' : 'discouraged'
sel.requireResidentKey = passwordless
sel.userVerification = passwordless ? 'required' : 'discouraged'
}
return await webauthnJSON.create(credentials)
}
function signupForm (o) {
const signupBtn = document.getElementById(o.ids.signup)
return addOrSignupForm(o, signupBtn, null)
}
function addForm (o) {
const addBtn = document.getElementById(o.ids.add)
const passwordlessCb = o.ids.passwordless ? document.getElementById(o.ids.passwordless) : null
const passwordlessFn = () => passwordlessCb ? passwordlessCb.checked : false
return addOrSignupForm(o, addBtn, passwordlessFn)
}
function addOrSignupForm (o, actionBtn, passwordlessFn) {
const credentialInput = document.getElementById(o.ids.credential)
const form = credentialInput.closest('form')
actionBtn.addEventListener('click', async function () {
const passwordless = passwordlessFn ? passwordlessFn() : undefined
try {
const credential = await createCredentials(o.data.creation_options, passwordless)
credentialInput.value = JSON.stringify(credential)
form.submit()
} catch (e) {
dispatchError(e)
}
})
}
function loginForm (o) {
const loginBtn = document.getElementById(o.ids.login)
const form = loginBtn.form
const credentialInput = document.getElementById(o.ids.credential)
loginBtn.addEventListener('click', async function (e) {
e.preventDefault()
try {
const response = await fetch(form.action, {
method: 'GET',
headers: {
Accept: 'application/json'
}
})
if (!response.ok) {
throw new Error('Unable to fetch passkey data from server.')
}
const data = await response.json()
const credential = await webauthnJSON.get(data.request_options)
credentialInput.value = JSON.stringify(credential)
form.submit()
} catch (e) {
dispatchError(e)
}
})
}
function authenticateForm (o) {
const authenticateBtn = document.getElementById(o.ids.authenticate)
const credentialInput = document.getElementById(o.ids.credential)
const form = credentialInput.closest('form')
authenticateBtn.addEventListener('click', async function (e) {
e.preventDefault()
try {
const credential = await webauthnJSON.get(o.data.request_options)
credentialInput.value = JSON.stringify(credential)
form.submit()
} catch (e) {
dispatchError(e)
}
})
}
allauth.webauthn = {
forms: {
addForm,
authenticateForm,
loginForm,
signupForm
}
}
})()

View File

@@ -0,0 +1,40 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from allauth.mfa.adapter import get_adapter
from allauth.mfa.internal.flows.add import validate_can_add_authenticator
from allauth.mfa.totp.internal import auth
class ActivateTOTPForm(forms.Form):
code = forms.CharField(
label=_("Authenticator code"),
widget=forms.TextInput(
attrs={"placeholder": _("Code"), "autocomplete": "one-time-code"},
),
)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.secret = auth.get_totp_secret(regenerate=not self.is_bound)
def clean_code(self):
validate_can_add_authenticator(self.user)
code = self.cleaned_data["code"]
if not auth.validate_totp_code(self.secret, code):
raise get_adapter().validation_error("incorrect_code")
return code
class DeactivateTOTPForm(forms.Form):
def __init__(self, *args, **kwargs):
self.authenticator = kwargs.pop("authenticator")
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
adapter = get_adapter()
if not adapter.can_delete_authenticator(self.authenticator):
raise adapter.validation_error("cannot_delete_authenticator")
return cleaned_data

View File

@@ -0,0 +1,102 @@
import base64
import hashlib
import hmac
import secrets
import struct
import time
from django.core.cache import cache
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: int = 20) -> str:
random_bytes = secrets.token_bytes(length)
return base64.b32encode(random_bytes).decode("utf-8")
def get_totp_secret(regenerate: bool = False) -> str:
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() -> int:
current_time = int(time.time()) # Get the current Unix timestamp
return current_time // app_settings.TOTP_PERIOD
def hotp_value(secret: str, counter: int) -> int:
# 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 format_hotp_value(value: int) -> str:
return f"{value:0{app_settings.TOTP_DIGITS}}"
def _is_insecure_bypass(code: str) -> bool:
return bool(code and app_settings.TOTP_INSECURE_BYPASS_CODE == code)
def validate_totp_code(secret: str, code: str) -> bool:
if _is_insecure_bypass(code):
return True
value = hotp_value(secret, hotp_counter_from_time())
return code == format_hotp_value(value)
class TOTP:
def __init__(self, instance: Authenticator) -> None:
self.instance = instance
@classmethod
def activate(cls, user, secret: str) -> "TOTP":
instance = Authenticator(
user=user, type=Authenticator.Type.TOTP, data={"secret": encrypt(secret)}
)
instance.save()
return cls(instance)
def validate_code(self, code: str) -> bool:
if _is_insecure_bypass(code):
return True
if self._is_code_used(code):
return False
secret = decrypt(self.instance.data["secret"])
valid = validate_totp_code(secret, code)
if valid:
self._mark_code_used(code)
return valid
def _get_used_cache_key(self, code: str) -> str:
return f"allauth.mfa.totp.used?user={self.instance.user_id}&code={code}"
def _is_code_used(self, code: str) -> bool:
return cache.get(self._get_used_cache_key(code)) == "y"
def _mark_code_used(self, code: str) -> None:
cache.set(self._get_used_cache_key(code), "y", timeout=app_settings.TOTP_PERIOD)

View File

@@ -0,0 +1,39 @@
from typing import Optional, Tuple
from django.contrib import messages
from allauth.account.adapter import get_adapter as get_account_adapter
from allauth.account.internal.flows.reauthentication import (
raise_if_reauthentication_required,
)
from allauth.mfa import signals
from allauth.mfa.base.internal.flows import delete_and_cleanup
from allauth.mfa.models import Authenticator
from allauth.mfa.recovery_codes.internal.flows import (
auto_generate_recovery_codes,
)
from allauth.mfa.totp.internal.auth import TOTP
def activate_totp(request, form) -> Tuple[Authenticator, Optional[Authenticator]]:
raise_if_reauthentication_required(request)
totp_auth = TOTP.activate(request.user, form.secret).instance
signals.authenticator_added.send(
sender=Authenticator,
request=request,
user=request.user,
authenticator=totp_auth,
)
adapter = get_account_adapter(request)
adapter.add_message(request, messages.SUCCESS, "mfa/messages/totp_activated.txt")
adapter.send_notification_mail("mfa/email/totp_activated", request.user)
rc_auth = auto_generate_recovery_codes(request)
return totp_auth, rc_auth
def deactivate_totp(request, authenticator: Authenticator) -> None:
raise_if_reauthentication_required(request)
delete_and_cleanup(request, authenticator)
adapter = get_account_adapter(request)
adapter.add_message(request, messages.SUCCESS, "mfa/messages/totp_deactivated.txt")
adapter.send_notification_mail("mfa/email/totp_deactivated", request.user)

View File

@@ -0,0 +1,241 @@
import time
from unittest.mock import ANY, patch
from django.conf import settings
from django.core.cache import cache
from django.test import Client
from django.urls import reverse
import pytest
from pytest_django.asserts import assertTemplateUsed
from allauth.account import app_settings
from allauth.account.authentication import AUTHENTICATION_METHODS_SESSION_KEY
from allauth.mfa.adapter import get_adapter
from allauth.mfa.models import Authenticator
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"]]
}
@pytest.mark.parametrize("email_verified", [False])
@pytest.mark.parametrize("method", ["get", "post"])
def test_activate_totp_with_unverified_email(
auth_client, user, totp_validation_bypass, reauthentication_bypass, method
):
with reauthentication_bypass():
if method == "get":
resp = auth_client.get(reverse("mfa_activate_totp"))
else:
resp = auth_client.post(reverse("mfa_activate_totp"), {"code": "123"})
assert resp["location"] == reverse("mfa_index")
def test_activate_totp_success(
auth_client,
totp_validation_bypass,
user,
reauthentication_bypass,
settings,
mailoutbox,
):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
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()
assert len(mailoutbox) == 1
assert "Authenticator App Activated" in mailoutbox[0].subject
assert "Authenticator app activated." in mailoutbox[0].body
def test_deactivate_totp_success(
auth_client, user_with_totp, user_password, settings, mailoutbox
):
settings.ACCOUNT_EMAIL_NOTIFICATIONS = True
resp = auth_client.get(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")
assert len(mailoutbox) == 1
assert "Authenticator App Deactivated" in mailoutbox[0].subject
assert "Authenticator app deactivated." in mailoutbox[0].body
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
assert client.session[AUTHENTICATION_METHODS_SESSION_KEY] == [
{"method": "password", "at": ANY, "username": user_with_totp.username},
{"method": "mfa", "at": ANY, "id": ANY, "type": Authenticator.Type.TOTP},
]
def test_totp_login_rate_limit(
settings, enable_cache, user_with_totp, user_password, client
):
settings.ACCOUNT_LOGIN_ATTEMPTS_LIMIT = 3
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")
for i in range(5):
is_locked = i >= 3
resp = client.post(
reverse("mfa_authenticate"),
{
"code": "wrong",
},
)
assert resp.context["form"].errors == {
"code": [
(
"Too many failed login attempts. Try again later."
if is_locked
else "Incorrect code."
)
]
}
def test_cannot_deactivate_totp(auth_client, user_with_totp, user_password):
with patch(
"allauth.mfa.adapter.DefaultMFAAdapter.can_delete_authenticator"
) as cda_mock:
cda_mock.return_value = False
resp = auth_client.get(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.get(reverse("mfa_deactivate_totp"))
# When we GET, the form validation error is already on screen
assert resp.context["form"].errors == {
"__all__": [get_adapter().error_messages["cannot_delete_authenticator"]],
}
# And, when we POST anyway, it does not work
resp = auth_client.post(reverse("mfa_deactivate_totp"))
assert resp.status_code == 200
assert resp.context["form"].errors == {
"__all__": [get_adapter().error_messages["cannot_delete_authenticator"]],
}
def test_totp_code_reuse(
user_with_totp, user_password, totp_validation_bypass, enable_cache
):
for code, time_lapse, expect_success in [
# First use of code, SUCCESS
("123", False, True),
# Second use, no time elapsed: FAIL
("123", False, False),
# Different code, no time elapsed: SUCCESS
("456", False, True),
# Again, previous code, no time elapsed: FAIL
("123", False, False),
# Previous code, but time elapsed: SUCCESS
("123", True, True),
]:
if time_lapse:
cache.clear()
client = Client()
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")
# Note that this bypass only bypasses the actual code check, not the
# re-use check we're testing here.
with totp_validation_bypass():
resp = client.post(
reverse("mfa_authenticate"),
{"code": code},
)
if expect_success:
assert resp.status_code == 302
assert resp["location"] == settings.LOGIN_REDIRECT_URL
else:
assert resp.status_code == 200
assert resp.context["form"].errors == {
"code": [get_adapter().error_messages["incorrect_code"]]
}
def test_totp_stage_expires(client, user_with_totp, user_password):
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.status_code == 200
assertTemplateUsed(resp, "mfa/authenticate.html")
with patch(
"allauth.account.internal.stagekit.time.time",
return_value=time.time() + 1.1 * app_settings.LOGIN_TIMEOUT,
):
resp = client.get(reverse("mfa_authenticate"))
assert resp.status_code == 302
assert resp["location"] == reverse("account_login")

View File

@@ -0,0 +1,11 @@
from typing import List, Union
from django.urls import URLPattern, URLResolver, path
from allauth.mfa.totp import views
urlpatterns: List[Union[URLPattern, URLResolver]] = [
path("activate/", views.activate_totp, name="mfa_activate_totp"),
path("deactivate/", views.deactivate_totp, name="mfa_deactivate_totp"),
]

View File

@@ -0,0 +1,116 @@
import base64
from django.contrib.auth.decorators import login_required
from django.http import 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.edit import FormView
from allauth.account import app_settings as account_settings
from allauth.account.decorators import reauthentication_required
from allauth.mfa import app_settings
from allauth.mfa.adapter import get_adapter
from allauth.mfa.internal.flows.add import redirect_if_add_not_allowed
from allauth.mfa.models import Authenticator
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
from allauth.mfa.totp.internal import flows
from allauth.mfa.utils import is_mfa_enabled
from allauth.utils import get_form_class
@method_decorator(redirect_if_add_not_allowed, name="dispatch")
@method_decorator(reauthentication_required, name="dispatch")
class ActivateTOTPView(FormView):
form_class = ActivateTOTPForm
template_name = "mfa/totp/activate_form." + account_settings.TEMPLATE_EXTENSION
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 = adapter.build_totp_url(
self.request.user,
ret["form"].secret,
)
totp_svg = adapter.build_totp_svg(totp_url)
base64_data = base64.b64encode(totp_svg.encode("utf8")).decode("utf-8")
totp_data_uri = f"data:image/svg+xml;base64,{base64_data}"
ret.update(
{
"totp_svg": totp_svg,
"totp_svg_data_uri": totp_data_uri,
"totp_url": totp_url,
}
)
return ret
def get_form_kwargs(self):
ret = super().get_form_kwargs()
ret["user"] = self.request.user
return ret
def get_form_class(self):
return get_form_class(app_settings.FORMS, "activate_totp", self.form_class)
def get_success_url(self):
if self.did_generate_recovery_codes:
return reverse("mfa_view_recovery_codes")
return reverse("mfa_index")
def form_valid(self, form):
totp_auth, rc_auth = flows.activate_totp(self.request, form)
self.did_generate_recovery_codes = bool(rc_auth)
return super().form_valid(form)
activate_totp = ActivateTOTPView.as_view()
@method_decorator(login_required, name="dispatch")
class DeactivateTOTPView(FormView):
form_class = DeactivateTOTPForm
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` check 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 get_form_kwargs(self):
ret = super().get_form_kwargs()
ret["authenticator"] = self.authenticator
# The deactivation form does not require input, yet, can generate
# validation errors in case deactivation is not allowed. We want to
# immediately present such errors even before the user actually posts
# the form, which is why we put an empty data payload in here.
ret.setdefault("data", {})
return ret
def get_form_class(self):
return get_form_class(app_settings.FORMS, "deactivate_totp", self.form_class)
def form_valid(self, form):
flows.deactivate_totp(self.request, self.authenticator)
return super().form_valid(form)
deactivate_totp = DeactivateTOTPView.as_view()

View File

@@ -0,0 +1,21 @@
from typing import List, Union
from django.urls import URLPattern, URLResolver, include, path
from allauth.mfa import app_settings
urlpatterns: List[Union[URLPattern, URLResolver]] = [
path("", include("allauth.mfa.base.urls"))
]
if "totp" in app_settings.SUPPORTED_TYPES:
urlpatterns.append(path("totp/", include("allauth.mfa.totp.urls")))
if "recovery_codes" in app_settings.SUPPORTED_TYPES:
urlpatterns.append(
path("recovery-codes/", include("allauth.mfa.recovery_codes.urls"))
)
if "webauthn" in app_settings.SUPPORTED_TYPES:
urlpatterns.append(path("webauthn/", include("allauth.mfa.webauthn.urls")))

View File

@@ -0,0 +1,13 @@
from allauth.mfa.adapter import get_adapter
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):
return get_adapter().is_mfa_enabled(user, types=types)

View File

@@ -0,0 +1,130 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from allauth.core import context
from allauth.mfa import app_settings
from allauth.mfa.adapter import get_adapter
from allauth.mfa.base.internal.flows import (
check_rate_limit,
post_authentication,
)
from allauth.mfa.models import Authenticator
from allauth.mfa.webauthn.internal import auth, flows
class _BaseAddWebAuthnForm(forms.Form):
name = forms.CharField(required=False)
credential = forms.JSONField(required=True, widget=forms.HiddenInput)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
initial = kwargs.setdefault("initial", {})
initial.setdefault(
"name",
get_adapter().generate_authenticator_name(
self.user, Authenticator.Type.WEBAUTHN
),
)
super().__init__(*args, **kwargs)
def clean_name(self):
"""
We don't want to make `name` a required field, as the WebAuthn
ceremony happens before posting the resulting credential, and we don't
want to reject a valid credential because of a missing name -- it might
be resident already. So, gracefully plug in a name.
"""
name = self.cleaned_data["name"]
if not name:
name = get_adapter().generate_authenticator_name(
self.user, Authenticator.Type.WEBAUTHN
)
return name
def clean(self):
cleaned_data = super().clean()
credential = cleaned_data.get("credential")
if credential:
# Explicitly parse JSON payload -- otherwise, register_complete()
# crashes with some random TypeError and we don't want to do
# Pokemon-style exception handling.
auth.parse_registration_response(credential)
auth.complete_registration(credential)
return cleaned_data
class AddWebAuthnForm(_BaseAddWebAuthnForm):
if app_settings.PASSKEY_LOGIN_ENABLED:
passwordless = forms.BooleanField(
label=_("Passwordless"),
required=False,
help_text=_(
"Enabling passwordless operation allows you to sign in using just this key, but imposes additional requirements such as biometrics or PIN protection."
),
)
class SignupWebAuthnForm(_BaseAddWebAuthnForm):
pass
class AuthenticateWebAuthnForm(forms.Form):
credential = forms.JSONField(required=True, widget=forms.HiddenInput)
reauthenticated = False
passwordless = False
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
def clean_credential(self):
credential = self.cleaned_data["credential"]
# Explicitly parse JSON payload -- otherwise, authenticate_complete()
# crashes with some random TypeError and we don't want to do
# Pokemon-style exception handling.
auth.parse_authentication_response(credential)
user = self.user
if user is None:
user = auth.extract_user_from_response(credential)
clear_rl = check_rate_limit(user)
authenticator = auth.complete_authentication(user, credential)
clear_rl()
return authenticator
def save(self):
authenticator = self.cleaned_data["credential"]
post_authentication(
context.request,
authenticator,
reauthenticated=self.reauthenticated,
passwordless=self.passwordless,
)
class LoginWebAuthnForm(AuthenticateWebAuthnForm):
reauthenticated = False
passwordless = True
def __init__(self, *args, **kwargs):
super().__init__(*args, user=None, **kwargs)
class ReauthenticateWebAuthnForm(AuthenticateWebAuthnForm):
reauthenticated = True
passwordless = False
class EditWebAuthnForm(forms.Form):
name = forms.CharField(required=True)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance")
initial = kwargs.setdefault("initial", {})
initial.setdefault("name", self.instance.wrap().name)
super().__init__(*args, **kwargs)
def save(self) -> Authenticator:
flows.rename_authenticator(
context.request, self.instance, self.cleaned_data["name"]
)
return self.instance

Some files were not shown because too many files have changed in this diff Show More