mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 04:31:09 -05:00
okay fine
This commit is contained in:
162
.venv/lib/python3.12/site-packages/allauth/mfa/adapter.py
Normal file
162
.venv/lib/python3.12/site-packages/allauth/mfa/adapter.py
Normal 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)()
|
||||
10
.venv/lib/python3.12/site-packages/allauth/mfa/admin.py
Normal file
10
.venv/lib/python3.12/site-packages/allauth/mfa/admin.py
Normal 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")
|
||||
@@ -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)
|
||||
19
.venv/lib/python3.12/site-packages/allauth/mfa/apps.py
Normal file
19
.venv/lib/python3.12/site-packages/allauth/mfa/apps.py
Normal 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)
|
||||
47
.venv/lib/python3.12/site-packages/allauth/mfa/base/forms.py
Normal file
47
.venv/lib/python3.12/site-packages/allauth/mfa/base/forms.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
12
.venv/lib/python3.12/site-packages/allauth/mfa/base/urls.py
Normal file
12
.venv/lib/python3.12/site-packages/allauth/mfa/base/urls.py
Normal 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"),
|
||||
]
|
||||
152
.venv/lib/python3.12/site-packages/allauth/mfa/base/views.py
Normal file
152
.venv/lib/python3.12/site-packages/allauth/mfa/base/views.py
Normal 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()
|
||||
41
.venv/lib/python3.12/site-packages/allauth/mfa/checks.py
Normal file
41
.venv/lib/python3.12/site-packages/allauth/mfa/checks.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
68
.venv/lib/python3.12/site-packages/allauth/mfa/models.py
Normal file
68
.venv/lib/python3.12/site-packages/allauth/mfa/models.py
Normal 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"])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
23
.venv/lib/python3.12/site-packages/allauth/mfa/signals.py
Normal file
23
.venv/lib/python3.12/site-packages/allauth/mfa/signals.py
Normal 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")
|
||||
26
.venv/lib/python3.12/site-packages/allauth/mfa/stages.py
Normal file
26
.venv/lib/python3.12/site-packages/allauth/mfa/stages.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})()
|
||||
40
.venv/lib/python3.12/site-packages/allauth/mfa/totp/forms.py
Normal file
40
.venv/lib/python3.12/site-packages/allauth/mfa/totp/forms.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
11
.venv/lib/python3.12/site-packages/allauth/mfa/totp/urls.py
Normal file
11
.venv/lib/python3.12/site-packages/allauth/mfa/totp/urls.py
Normal 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"),
|
||||
]
|
||||
116
.venv/lib/python3.12/site-packages/allauth/mfa/totp/views.py
Normal file
116
.venv/lib/python3.12/site-packages/allauth/mfa/totp/views.py
Normal 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()
|
||||
21
.venv/lib/python3.12/site-packages/allauth/mfa/urls.py
Normal file
21
.venv/lib/python3.12/site-packages/allauth/mfa/urls.py
Normal 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")))
|
||||
13
.venv/lib/python3.12/site-packages/allauth/mfa/utils.py
Normal file
13
.venv/lib/python3.12/site-packages/allauth/mfa/utils.py
Normal 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)
|
||||
130
.venv/lib/python3.12/site-packages/allauth/mfa/webauthn/forms.py
Normal file
130
.venv/lib/python3.12/site-packages/allauth/mfa/webauthn/forms.py
Normal 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
|
||||
@@ -0,0 +1,215 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import fido2.features
|
||||
from fido2.server import Fido2Server
|
||||
from fido2.utils import websafe_decode
|
||||
from fido2.webauthn import (
|
||||
AttestedCredentialData,
|
||||
AuthenticationResponse,
|
||||
AuthenticatorData,
|
||||
PublicKeyCredentialRpEntity,
|
||||
PublicKeyCredentialUserEntity,
|
||||
RegistrationResponse,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
from allauth.account.utils import url_str_to_user_pk
|
||||
from allauth.core import context
|
||||
from allauth.mfa import app_settings
|
||||
from allauth.mfa.adapter import get_adapter
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
|
||||
fido2.features.webauthn_json_mapping.enabled = True
|
||||
|
||||
|
||||
STATE_SESSION_KEY = "mfa.webauthn.state"
|
||||
EXTENSIONS = {"credProps": True}
|
||||
|
||||
|
||||
def build_user_payload(user) -> PublicKeyCredentialUserEntity:
|
||||
kwargs = get_adapter().get_public_key_credential_user_entity(user)
|
||||
return PublicKeyCredentialUserEntity(**kwargs)
|
||||
|
||||
|
||||
def get_state() -> Optional[Dict]:
|
||||
return context.request.session.get(STATE_SESSION_KEY)
|
||||
|
||||
|
||||
def set_state(state: Dict) -> None:
|
||||
context.request.session[STATE_SESSION_KEY] = state
|
||||
|
||||
|
||||
def clear_state() -> None:
|
||||
context.request.session.pop(STATE_SESSION_KEY, None)
|
||||
|
||||
|
||||
def get_server() -> Fido2Server:
|
||||
rp_kwargs = get_adapter().get_public_key_credential_rp_entity()
|
||||
rp = PublicKeyCredentialRpEntity(**rp_kwargs)
|
||||
verify_origin = None
|
||||
if app_settings.WEBAUTHN_ALLOW_INSECURE_ORIGIN:
|
||||
verify_origin = lambda o: True # noqa
|
||||
server = Fido2Server(rp, verify_origin=verify_origin)
|
||||
return server
|
||||
|
||||
|
||||
def parse_registration_response(response: Any) -> RegistrationResponse:
|
||||
try:
|
||||
return RegistrationResponse.from_dict(response)
|
||||
except TypeError:
|
||||
raise get_adapter().validation_error("incorrect_code")
|
||||
|
||||
|
||||
def begin_registration(user, passwordless: bool) -> Dict:
|
||||
server = get_server()
|
||||
credentials = get_credentials(user)
|
||||
registration_data, state = server.register_begin(
|
||||
user=build_user_payload(user),
|
||||
credentials=credentials,
|
||||
resident_key_requirement=(
|
||||
ResidentKeyRequirement.REQUIRED
|
||||
if passwordless
|
||||
else ResidentKeyRequirement.DISCOURAGED
|
||||
),
|
||||
user_verification=(
|
||||
UserVerificationRequirement.REQUIRED
|
||||
if passwordless
|
||||
else UserVerificationRequirement.DISCOURAGED
|
||||
),
|
||||
extensions=EXTENSIONS,
|
||||
)
|
||||
set_state(state)
|
||||
return dict(registration_data)
|
||||
|
||||
|
||||
def complete_registration(credential: Dict) -> AuthenticatorData:
|
||||
server = get_server()
|
||||
state = get_state()
|
||||
if not state:
|
||||
raise get_adapter().validation_error("incorrect_code")
|
||||
try:
|
||||
binding = server.register_complete(state, credential)
|
||||
except ValueError:
|
||||
# raise ValueError("Wrong challenge in response.")
|
||||
raise get_adapter().validation_error("incorrect_code")
|
||||
clear_state()
|
||||
return binding
|
||||
|
||||
|
||||
def get_credentials(user) -> List[AttestedCredentialData]:
|
||||
credentials: List[AttestedCredentialData] = []
|
||||
authenticators = Authenticator.objects.filter(
|
||||
user=user, type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
for authenticator in authenticators:
|
||||
credential_data = authenticator.wrap().authenticator_data.credential_data
|
||||
if credential_data:
|
||||
credentials.append(authenticator.wrap().authenticator_data.credential_data)
|
||||
return credentials
|
||||
|
||||
|
||||
def get_authenticator_by_credential_id(
|
||||
user, credential_id: bytes
|
||||
) -> Optional[Authenticator]:
|
||||
authenticators = Authenticator.objects.filter(
|
||||
user=user, type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
for authenticator in authenticators:
|
||||
if (
|
||||
credential_id
|
||||
== authenticator.wrap().authenticator_data.credential_data.credential_id
|
||||
):
|
||||
return authenticator
|
||||
return None
|
||||
|
||||
|
||||
def parse_authentication_response(response: Any) -> AuthenticationResponse:
|
||||
try:
|
||||
return AuthenticationResponse.from_dict(response)
|
||||
except TypeError:
|
||||
raise get_adapter().validation_error("incorrect_code")
|
||||
|
||||
|
||||
def begin_authentication(user=None) -> Dict:
|
||||
server = get_server()
|
||||
request_options, state = server.authenticate_begin(
|
||||
credentials=get_credentials(user) if user else [],
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
)
|
||||
set_state(state)
|
||||
return dict(request_options)
|
||||
|
||||
|
||||
def extract_user_from_response(response: Dict):
|
||||
try:
|
||||
user_handle = response.get("response", {}).get("userHandle")
|
||||
user_pk = url_str_to_user_pk(websafe_decode(user_handle).decode("utf8"))
|
||||
except (ValueError, TypeError, KeyError):
|
||||
raise get_adapter().validation_error("incorrect_code")
|
||||
user = get_user_model().objects.filter(pk=user_pk).first()
|
||||
if not user:
|
||||
raise get_adapter().validation_error("incorrect_code")
|
||||
return user
|
||||
|
||||
|
||||
def complete_authentication(user, response: Dict) -> Authenticator:
|
||||
credentials = get_credentials(user)
|
||||
server = get_server()
|
||||
state = get_state()
|
||||
if not state:
|
||||
raise get_adapter().validation_error("incorrect_code")
|
||||
try:
|
||||
binding = server.authenticate_complete(state, credentials, response)
|
||||
except ValueError as e:
|
||||
# ValueError: Unknown credential ID.
|
||||
raise get_adapter().validation_error("incorrect_code") from e
|
||||
clear_state()
|
||||
authenticator = get_authenticator_by_credential_id(user, binding.credential_id)
|
||||
if not authenticator:
|
||||
raise get_adapter().validation_error("incorrect_code")
|
||||
return authenticator
|
||||
|
||||
|
||||
class WebAuthn:
|
||||
def __init__(self, instance):
|
||||
self.instance = instance
|
||||
|
||||
@classmethod
|
||||
def add(cls, user, name: str, credential: dict) -> "WebAuthn":
|
||||
instance = Authenticator(
|
||||
user=user,
|
||||
type=Authenticator.Type.WEBAUTHN,
|
||||
data={
|
||||
"name": name,
|
||||
"credential": credential,
|
||||
},
|
||||
)
|
||||
instance.save()
|
||||
return cls(instance)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.instance.data["name"]
|
||||
|
||||
@name.setter
|
||||
def name(self, name: str):
|
||||
self.instance.data["name"] = name
|
||||
|
||||
@property
|
||||
def authenticator_data(self) -> AuthenticatorData:
|
||||
return parse_registration_response(
|
||||
self.instance.data["credential"]
|
||||
).response.attestation_object.auth_data
|
||||
|
||||
@property
|
||||
def is_passwordless(self) -> Optional[bool]:
|
||||
return (
|
||||
self.instance.data.get("credential", {})
|
||||
.get("clientExtensionResults", {})
|
||||
.get("credProps", {})
|
||||
.get("rk")
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
from typing import Iterable, Optional, Tuple
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest
|
||||
|
||||
from allauth.account.adapter import get_adapter as get_account_adapter
|
||||
from allauth.account.authentication import get_authentication_records
|
||||
from allauth.account.internal import flows
|
||||
from allauth.account.internal.flows.reauthentication import (
|
||||
raise_if_reauthentication_required,
|
||||
)
|
||||
from allauth.account.models import Login
|
||||
from allauth.mfa import signals
|
||||
from allauth.mfa.base.internal.flows import (
|
||||
delete_and_cleanup,
|
||||
post_authentication,
|
||||
)
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.recovery_codes.internal.flows import (
|
||||
auto_generate_recovery_codes,
|
||||
)
|
||||
from allauth.mfa.webauthn.internal import auth
|
||||
|
||||
|
||||
def begin_registration(
|
||||
request: HttpRequest, user, passwordless: bool, signup: bool = False
|
||||
) -> dict:
|
||||
if not signup:
|
||||
raise_if_reauthentication_required(request)
|
||||
creation_options = auth.begin_registration(user, passwordless)
|
||||
return creation_options
|
||||
|
||||
|
||||
def signup_authenticator(request, user, name: str, credential: dict) -> Authenticator:
|
||||
authenticator, rc_authenticator = _signup_or_add_authenticator(
|
||||
request, user, name, credential, signup=True
|
||||
)
|
||||
return authenticator
|
||||
|
||||
|
||||
def add_authenticator(
|
||||
request, name: str, credential: dict
|
||||
) -> Tuple[Authenticator, Optional[Authenticator]]:
|
||||
raise_if_reauthentication_required(request)
|
||||
return _signup_or_add_authenticator(
|
||||
request,
|
||||
user=request.user,
|
||||
name=name,
|
||||
credential=credential,
|
||||
signup=False,
|
||||
)
|
||||
|
||||
|
||||
def _signup_or_add_authenticator(
|
||||
request,
|
||||
user,
|
||||
name: str,
|
||||
credential: dict,
|
||||
signup: bool = False,
|
||||
) -> Tuple[Authenticator, Optional[Authenticator]]:
|
||||
authenticator = auth.WebAuthn.add(
|
||||
user,
|
||||
name,
|
||||
credential,
|
||||
).instance
|
||||
signals.authenticator_added.send(
|
||||
sender=Authenticator,
|
||||
request=request,
|
||||
user=user,
|
||||
authenticator=authenticator,
|
||||
)
|
||||
adapter = get_account_adapter(request)
|
||||
adapter.add_message(request, messages.SUCCESS, "mfa/messages/webauthn_added.txt")
|
||||
if not signup:
|
||||
adapter.send_notification_mail("mfa/email/webauthn_added", user)
|
||||
rc_authenticator = None
|
||||
if not signup:
|
||||
rc_authenticator = auto_generate_recovery_codes(request)
|
||||
return authenticator, rc_authenticator
|
||||
|
||||
|
||||
def remove_authenticators(request, authenticators: Iterable[Authenticator]) -> None:
|
||||
raise_if_reauthentication_required(request)
|
||||
for authenticator in authenticators:
|
||||
remove_authenticator(request, authenticator)
|
||||
|
||||
|
||||
def remove_authenticator(request, authenticator: Authenticator):
|
||||
raise_if_reauthentication_required(request)
|
||||
delete_and_cleanup(request, authenticator)
|
||||
adapter = get_account_adapter(request)
|
||||
adapter.add_message(request, messages.SUCCESS, "mfa/messages/webauthn_removed.txt")
|
||||
adapter.send_notification_mail("mfa/email/webauthn_removed", request.user)
|
||||
|
||||
|
||||
def perform_passwordless_login(request, authenticator: Authenticator, login: Login):
|
||||
post_authentication(request, authenticator, passwordless=True)
|
||||
return flows.login.perform_login(request, login)
|
||||
|
||||
|
||||
def did_use_passwordless_login(request: HttpRequest) -> bool:
|
||||
records = get_authentication_records(request)
|
||||
return any(
|
||||
(record.get("method"), record.get("type"), record.get("passwordless"))
|
||||
== ("mfa", "webauthn", True)
|
||||
for record in records
|
||||
)
|
||||
|
||||
|
||||
def reauthenticate(request: HttpRequest, authenticator: Authenticator):
|
||||
post_authentication(request, authenticator, reauthenticated=True)
|
||||
|
||||
|
||||
def rename_authenticator(request, authenticator: Authenticator, name: str):
|
||||
raise_if_reauthentication_required(request)
|
||||
wrapper = authenticator.wrap()
|
||||
wrapper.name = name
|
||||
authenticator.save()
|
||||
@@ -0,0 +1,13 @@
|
||||
from allauth.account.stages import LoginStage
|
||||
from allauth.core.internal.httpkit import headed_redirect_response
|
||||
|
||||
|
||||
class PasskeySignupStage(LoginStage):
|
||||
key = "mfa_signup_webauthn"
|
||||
urlname = "mfa_signup_webauthn"
|
||||
|
||||
def handle(self):
|
||||
response, cont = None, True
|
||||
if self.login.state.get("passkey_signup"):
|
||||
response = headed_redirect_response("mfa_signup_webauthn")
|
||||
return response, cont
|
||||
@@ -0,0 +1,202 @@
|
||||
from unittest.mock import ANY
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
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_passkey_login(client, passkey, webauthn_authentication_bypass):
|
||||
with webauthn_authentication_bypass(passkey) as credential:
|
||||
resp = client.get(
|
||||
reverse("mfa_login_webauthn"), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
|
||||
)
|
||||
assert "request_options" in resp.json()
|
||||
resp = client.post(
|
||||
reverse("mfa_login_webauthn"), data={"credential": credential}
|
||||
)
|
||||
assert resp["location"] == settings.LOGIN_REDIRECT_URL
|
||||
assert client.session[AUTHENTICATION_METHODS_SESSION_KEY] == [
|
||||
{
|
||||
"at": ANY,
|
||||
"id": ANY,
|
||||
"method": "mfa",
|
||||
"passwordless": True,
|
||||
"type": "webauthn",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_reauthenticate(
|
||||
auth_client, passkey, user_with_recovery_codes, webauthn_authentication_bypass
|
||||
):
|
||||
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")
|
||||
|
||||
with webauthn_authentication_bypass(passkey) as credential:
|
||||
resp = auth_client.get(
|
||||
reverse("mfa_reauthenticate_webauthn"),
|
||||
)
|
||||
resp = auth_client.post(
|
||||
reverse("mfa_reauthenticate_webauthn"),
|
||||
data={"credential": credential, "next": "/redir"},
|
||||
)
|
||||
assert resp["location"] == "/redir"
|
||||
|
||||
|
||||
def test_get_passkey_login_challenge_redirects_if_not_ajax(client):
|
||||
resp = client.get(reverse("mfa_login_webauthn"))
|
||||
assert resp["location"] == reverse("account_login")
|
||||
|
||||
|
||||
def test_get_passkey_login_challenge(client, db):
|
||||
resp = client.get(
|
||||
reverse("mfa_login_webauthn"), HTTP_X_REQUESTED_WITH="XMLHttpRequest"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp["content-type"] == "application/json"
|
||||
data = resp.json()
|
||||
assert data == {
|
||||
"request_options": {
|
||||
"publicKey": {
|
||||
"challenge": ANY,
|
||||
"rpId": "testserver",
|
||||
"allowCredentials": [],
|
||||
"userVerification": "preferred",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_passkey_login(client, passkey):
|
||||
resp = client.post(reverse("mfa_login_webauthn"), data={"credential": "{}"})
|
||||
assert resp["location"] == reverse("account_login")
|
||||
|
||||
|
||||
def test_rename_key(auth_client, passkey, reauthentication_bypass):
|
||||
resp = auth_client.get(reverse("mfa_edit_webauthn", kwargs={"pk": passkey.pk}))
|
||||
assert resp["location"].startswith(reverse("account_reauthenticate"))
|
||||
with reauthentication_bypass():
|
||||
resp = auth_client.get(reverse("mfa_edit_webauthn", kwargs={"pk": passkey.pk}))
|
||||
assertTemplateUsed(resp, "mfa/webauthn/edit_form.html")
|
||||
resp = auth_client.post(
|
||||
reverse("mfa_edit_webauthn", kwargs={"pk": passkey.pk}),
|
||||
data={"name": "Renamed"},
|
||||
)
|
||||
assert resp["location"] == reverse("mfa_list_webauthn")
|
||||
passkey.refresh_from_db()
|
||||
assert passkey.data["name"] == "Renamed"
|
||||
assert str(passkey) == "Renamed"
|
||||
|
||||
|
||||
def test_remove_key(auth_client, passkey, reauthentication_bypass):
|
||||
resp = auth_client.get(reverse("mfa_remove_webauthn", kwargs={"pk": passkey.pk}))
|
||||
assert resp["location"].startswith(reverse("account_reauthenticate"))
|
||||
with reauthentication_bypass():
|
||||
resp = auth_client.get(
|
||||
reverse("mfa_remove_webauthn", kwargs={"pk": passkey.pk})
|
||||
)
|
||||
assertTemplateUsed(resp, "mfa/webauthn/authenticator_confirm_delete.html")
|
||||
resp = auth_client.post(
|
||||
reverse("mfa_remove_webauthn", kwargs={"pk": passkey.pk})
|
||||
)
|
||||
assert resp["location"] == reverse("mfa_list_webauthn")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("passwordless", [False, True])
|
||||
def test_add_key(
|
||||
auth_client,
|
||||
user,
|
||||
webauthn_registration_bypass,
|
||||
reauthentication_bypass,
|
||||
passwordless,
|
||||
):
|
||||
with webauthn_registration_bypass(user, passwordless) as credential:
|
||||
resp = auth_client.post(
|
||||
reverse("mfa_add_webauthn"), data={"credential": credential}
|
||||
)
|
||||
assert resp["location"].startswith(reverse("account_reauthenticate"))
|
||||
with reauthentication_bypass():
|
||||
resp = auth_client.get(reverse("mfa_add_webauthn"))
|
||||
assertTemplateUsed(resp, "mfa/webauthn/add_form.html")
|
||||
with webauthn_registration_bypass(user, passwordless) as credential:
|
||||
resp = auth_client.post(
|
||||
reverse("mfa_add_webauthn"),
|
||||
data={
|
||||
"credential": credential,
|
||||
"passwordless": "on" if passwordless else "",
|
||||
},
|
||||
)
|
||||
assert resp["location"].startswith(reverse("mfa_view_recovery_codes"))
|
||||
authenticator = Authenticator.objects.get(
|
||||
user=user, type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
assert authenticator.wrap().is_passwordless == passwordless
|
||||
Authenticator.objects.filter(
|
||||
user=user, type=Authenticator.Type.RECOVERY_CODES
|
||||
).exists()
|
||||
|
||||
|
||||
def test_list_keys(auth_client):
|
||||
resp = auth_client.get(reverse("mfa_list_webauthn"))
|
||||
assertTemplateUsed(resp, "mfa/webauthn/authenticator_list.html")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email_verified", [False])
|
||||
@pytest.mark.parametrize("method", ["get", "post"])
|
||||
def test_add_with_unverified_email(
|
||||
auth_client, user, webauthn_registration_bypass, reauthentication_bypass, method
|
||||
):
|
||||
with webauthn_registration_bypass(user, False) as credential:
|
||||
if method == "get":
|
||||
resp = auth_client.get(reverse("mfa_add_webauthn"))
|
||||
else:
|
||||
resp = auth_client.post(
|
||||
reverse("mfa_add_webauthn"), data={"credential": credential}
|
||||
)
|
||||
assert resp["location"] == reverse("mfa_index")
|
||||
|
||||
|
||||
def test_passkey_signup(client, db, webauthn_registration_bypass):
|
||||
resp = client.post(
|
||||
reverse("account_signup_by_passkey"),
|
||||
data={"email": "pass@key.org", "username": "passkey"},
|
||||
)
|
||||
assert resp["location"] == reverse("mfa_signup_webauthn")
|
||||
resp = client.post(resp["location"])
|
||||
assert resp.status_code == 200
|
||||
user = get_user_model().objects.get(email="pass@key.org")
|
||||
with webauthn_registration_bypass(user, True) as credential:
|
||||
resp = client.post(
|
||||
reverse("mfa_signup_webauthn"), data={"credential": credential}
|
||||
)
|
||||
assert resp["location"] == settings.LOGIN_REDIRECT_URL
|
||||
|
||||
|
||||
def test_webauthn_login(
|
||||
client, user_with_passkey, passkey, user_password, webauthn_authentication_bypass
|
||||
):
|
||||
resp = client.post(
|
||||
reverse("account_login"),
|
||||
{"login": user_with_passkey.username, "password": user_password},
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
assert resp["location"] == reverse("mfa_authenticate")
|
||||
with webauthn_authentication_bypass(passkey) as credential:
|
||||
resp = client.get(reverse("mfa_authenticate"))
|
||||
assert resp.status_code == 200
|
||||
resp = client.post(reverse("mfa_authenticate"), {"credential": credential})
|
||||
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_passkey.username},
|
||||
{"method": "mfa", "at": ANY, "id": ANY, "type": Authenticator.Type.WEBAUTHN},
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
from typing import List, Union
|
||||
|
||||
from django.urls import URLPattern, URLResolver, include, path
|
||||
|
||||
from allauth.mfa import app_settings
|
||||
from allauth.mfa.webauthn import views
|
||||
|
||||
|
||||
urlpatterns: List[Union[URLPattern, URLResolver]] = [
|
||||
path("", views.list_webauthn, name="mfa_list_webauthn"),
|
||||
path("add/", views.add_webauthn, name="mfa_add_webauthn"),
|
||||
path(
|
||||
"reauthenticate/",
|
||||
views.reauthenticate_webauthn,
|
||||
name="mfa_reauthenticate_webauthn",
|
||||
),
|
||||
path(
|
||||
"keys/<int:pk>/",
|
||||
include(
|
||||
[
|
||||
path(
|
||||
"remove/",
|
||||
views.remove_webauthn,
|
||||
name="mfa_remove_webauthn",
|
||||
),
|
||||
path(
|
||||
"edit/",
|
||||
views.edit_webauthn,
|
||||
name="mfa_edit_webauthn",
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
if app_settings.PASSKEY_LOGIN_ENABLED:
|
||||
urlpatterns.append(path("login/", views.login_webauthn, name="mfa_login_webauthn"))
|
||||
if app_settings.PASSKEY_SIGNUP_ENABLED:
|
||||
urlpatterns.append(
|
||||
path("signup/", views.signup_webauthn, name="mfa_signup_webauthn")
|
||||
)
|
||||
213
.venv/lib/python3.12/site-packages/allauth/mfa/webauthn/views.py
Normal file
213
.venv/lib/python3.12/site-packages/allauth/mfa/webauthn/views.py
Normal file
@@ -0,0 +1,213 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic.edit import DeleteView, FormView, UpdateView
|
||||
from django.views.generic.list import ListView
|
||||
|
||||
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.internal.decorators import login_stage_required
|
||||
from allauth.account.mixins import (
|
||||
NextRedirectMixin,
|
||||
RedirectAuthenticatedUserMixin,
|
||||
)
|
||||
from allauth.account.models import Login
|
||||
from allauth.account.views import BaseReauthenticateView
|
||||
from allauth.mfa.internal.flows.add import redirect_if_add_not_allowed
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.webauthn.forms import (
|
||||
AddWebAuthnForm,
|
||||
EditWebAuthnForm,
|
||||
LoginWebAuthnForm,
|
||||
ReauthenticateWebAuthnForm,
|
||||
SignupWebAuthnForm,
|
||||
)
|
||||
from allauth.mfa.webauthn.internal import auth, flows
|
||||
from allauth.mfa.webauthn.stages import PasskeySignupStage
|
||||
|
||||
|
||||
@method_decorator(redirect_if_add_not_allowed, name="dispatch")
|
||||
@method_decorator(reauthentication_required, name="dispatch")
|
||||
class AddWebAuthnView(FormView):
|
||||
form_class = AddWebAuthnForm
|
||||
template_name = "mfa/webauthn/add_form." + account_settings.TEMPLATE_EXTENSION
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ret = super().get_context_data()
|
||||
creation_options = auth.begin_registration(self.request.user, False)
|
||||
ret["js_data"] = {"creation_options": creation_options}
|
||||
return ret
|
||||
|
||||
def get_form_kwargs(self):
|
||||
ret = super().get_form_kwargs()
|
||||
ret["user"] = self.request.user
|
||||
return ret
|
||||
|
||||
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):
|
||||
auth, rc_auth = flows.add_authenticator(
|
||||
self.request,
|
||||
name=form.cleaned_data["name"],
|
||||
credential=form.cleaned_data["credential"],
|
||||
)
|
||||
self.did_generate_recovery_codes = bool(rc_auth)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
add_webauthn = AddWebAuthnView.as_view()
|
||||
|
||||
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
class ListWebAuthnView(ListView):
|
||||
template_name = (
|
||||
"mfa/webauthn/authenticator_list." + account_settings.TEMPLATE_EXTENSION
|
||||
)
|
||||
context_object_name = "authenticators"
|
||||
|
||||
def get_queryset(self):
|
||||
return Authenticator.objects.filter(
|
||||
user=self.request.user, type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
|
||||
|
||||
list_webauthn = ListWebAuthnView.as_view()
|
||||
|
||||
|
||||
@method_decorator(reauthentication_required, name="dispatch")
|
||||
class RemoveWebAuthnView(NextRedirectMixin, DeleteView):
|
||||
object: Authenticator # https://github.com/typeddjango/django-stubs/issues/1227
|
||||
template_name = (
|
||||
"mfa/webauthn/authenticator_confirm_delete."
|
||||
+ account_settings.TEMPLATE_EXTENSION
|
||||
)
|
||||
success_url = reverse_lazy("mfa_list_webauthn")
|
||||
|
||||
def get_queryset(self):
|
||||
return Authenticator.objects.filter(
|
||||
user=self.request.user, type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
|
||||
def form_valid(self, form):
|
||||
authenticator = self.get_object()
|
||||
flows.remove_authenticator(self.request, authenticator)
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
|
||||
remove_webauthn = RemoveWebAuthnView.as_view()
|
||||
|
||||
|
||||
class LoginWebAuthnView(RedirectAuthenticatedUserMixin, FormView):
|
||||
form_class = LoginWebAuthnForm
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if get_account_adapter().is_ajax(request):
|
||||
request_options = auth.begin_authentication(user=None)
|
||||
data = {"request_options": request_options}
|
||||
return JsonResponse(data)
|
||||
return HttpResponseRedirect(reverse("account_login"))
|
||||
|
||||
def form_invalid(self, form):
|
||||
for message in form.errors.get("credential", []):
|
||||
get_account_adapter().add_message(
|
||||
self.request, messages.ERROR, message=message
|
||||
)
|
||||
return HttpResponseRedirect(reverse("account_login"))
|
||||
|
||||
def form_valid(self, form):
|
||||
authenticator = form.cleaned_data["credential"]
|
||||
redirect_url = None
|
||||
login = Login(user=authenticator.user, redirect_url=redirect_url)
|
||||
return flows.perform_passwordless_login(self.request, authenticator, login)
|
||||
|
||||
|
||||
login_webauthn = LoginWebAuthnView.as_view()
|
||||
|
||||
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
class ReauthenticateWebAuthnView(BaseReauthenticateView):
|
||||
form_class = ReauthenticateWebAuthnForm
|
||||
template_name = "mfa/webauthn/reauthenticate." + account_settings.TEMPLATE_EXTENSION
|
||||
|
||||
def get_form_kwargs(self):
|
||||
ret = super().get_form_kwargs()
|
||||
ret["user"] = self.request.user
|
||||
return ret
|
||||
|
||||
def form_invalid(self, form):
|
||||
for message in form.errors.get("credential", []):
|
||||
get_account_adapter().add_message(
|
||||
self.request, messages.ERROR, message=message
|
||||
)
|
||||
return HttpResponseRedirect(reverse("account_login"))
|
||||
|
||||
def form_valid(self, form):
|
||||
authenticator = form.cleaned_data["credential"]
|
||||
flows.reauthenticate(self.request, authenticator)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ret = super().get_context_data()
|
||||
request_options = auth.begin_authentication(self.request.user)
|
||||
ret["js_data"] = {"request_options": request_options}
|
||||
return ret
|
||||
|
||||
|
||||
reauthenticate_webauthn = ReauthenticateWebAuthnView.as_view()
|
||||
|
||||
|
||||
@method_decorator(reauthentication_required, name="dispatch")
|
||||
class EditWebAuthnView(NextRedirectMixin, UpdateView):
|
||||
form_class = EditWebAuthnForm
|
||||
template_name = "mfa/webauthn/edit_form." + account_settings.TEMPLATE_EXTENSION
|
||||
success_url = reverse_lazy("mfa_list_webauthn")
|
||||
|
||||
def get_queryset(self):
|
||||
return Authenticator.objects.filter(
|
||||
user=self.request.user, type=Authenticator.Type.WEBAUTHN
|
||||
)
|
||||
|
||||
|
||||
edit_webauthn = EditWebAuthnView.as_view()
|
||||
|
||||
|
||||
@method_decorator(
|
||||
login_stage_required(
|
||||
stage=PasskeySignupStage.key, redirect_urlname="account_signup"
|
||||
),
|
||||
name="dispatch",
|
||||
)
|
||||
class SignupWebAuthnView(FormView):
|
||||
form_class = SignupWebAuthnForm
|
||||
template_name = "mfa/webauthn/signup_form." + account_settings.TEMPLATE_EXTENSION
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ret = super().get_context_data()
|
||||
creation_options = auth.begin_registration(
|
||||
self.request._login_stage.login.user, True
|
||||
)
|
||||
ret["js_data"] = {"creation_options": creation_options}
|
||||
return ret
|
||||
|
||||
def get_form_kwargs(self):
|
||||
ret = super().get_form_kwargs()
|
||||
ret["user"] = self.request._login_stage.login.user
|
||||
return ret
|
||||
|
||||
def form_valid(self, form):
|
||||
flows.signup_authenticator(
|
||||
self.request,
|
||||
user=self.request._login_stage.login.user,
|
||||
name=form.cleaned_data["name"],
|
||||
credential=form.cleaned_data["credential"],
|
||||
)
|
||||
return self.request._login_stage.exit()
|
||||
|
||||
|
||||
signup_webauthn = SignupWebAuthnView.as_view()
|
||||
Reference in New Issue
Block a user