mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 01:11:09 -05:00
first commit
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
162
venv/lib/python3.11/site-packages/allauth/mfa/adapter.py
Normal file
162
venv/lib/python3.11/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.11/site-packages/allauth/mfa/admin.py
Normal file
10
venv/lib/python3.11/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.11/site-packages/allauth/mfa/apps.py
Normal file
19
venv/lib/python3.11/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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
47
venv/lib/python3.11/site-packages/allauth/mfa/base/forms.py
Normal file
47
venv/lib/python3.11/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)
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.11/site-packages/allauth/mfa/base/urls.py
Normal file
12
venv/lib/python3.11/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.11/site-packages/allauth/mfa/base/views.py
Normal file
152
venv/lib/python3.11/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.11/site-packages/allauth/mfa/checks.py
Normal file
41
venv/lib/python3.11/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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
68
venv/lib/python3.11/site-packages/allauth/mfa/models.py
Normal file
68
venv/lib/python3.11/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"])
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.11/site-packages/allauth/mfa/signals.py
Normal file
23
venv/lib/python3.11/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.11/site-packages/allauth/mfa/stages.py
Normal file
26
venv/lib/python3.11/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
|
||||
}
|
||||
}
|
||||
})()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
40
venv/lib/python3.11/site-packages/allauth/mfa/totp/forms.py
Normal file
40
venv/lib/python3.11/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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.11/site-packages/allauth/mfa/totp/urls.py
Normal file
11
venv/lib/python3.11/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.11/site-packages/allauth/mfa/totp/views.py
Normal file
116
venv/lib/python3.11/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.11/site-packages/allauth/mfa/urls.py
Normal file
21
venv/lib/python3.11/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.11/site-packages/allauth/mfa/utils.py
Normal file
13
venv/lib/python3.11/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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
130
venv/lib/python3.11/site-packages/allauth/mfa/webauthn/forms.py
Normal file
130
venv/lib/python3.11/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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user