Improved login process

This commit is contained in:
Antoine Nguyen
2024-06-22 19:45:55 +02:00
committed by Spitfireap
parent 03151f76f2
commit df3c56f810
11 changed files with 322 additions and 295 deletions
+12 -22
View File
@@ -75,8 +75,7 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
default_password = serializers.CharField(default="ChangeMe1!")
random_password_length = serializers.IntegerField(min_value=8, default=8)
update_password_url = serializers.URLField(required=False, allow_blank=True)
password_recovery_msg = serializers.CharField(
required=False, allow_blank=True)
password_recovery_msg = serializers.CharField(required=False, allow_blank=True)
sms_password_recovery = serializers.BooleanField(default=False)
sms_provider = serializers.ChoiceField(
choices=constants.SMS_BACKENDS, required=False, allow_null=True
@@ -87,8 +86,7 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
ldap_server_port = serializers.IntegerField(default=389)
ldap_enable_secondary_server = serializers.BooleanField(default=False)
ldap_secondary_server_address = serializers.CharField(required=False)
ldap_secondary_server_port = serializers.IntegerField(
default=389, required=False)
ldap_secondary_server_port = serializers.IntegerField(default=389, required=False)
ldap_secured = serializers.ChoiceField(
choices=constants.LDAP_SECURE_MODES, default="none"
)
@@ -109,8 +107,7 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
choices=constants.LDAP_AUTH_METHODS,
default="searchbind",
)
ldap_bind_dn = serializers.CharField(
default="", required=False, allow_blank=True)
ldap_bind_dn = serializers.CharField(default="", required=False, allow_blank=True)
ldap_bind_password = serializers.CharField(
default="", required=False, allow_blank=True
)
@@ -126,18 +123,15 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
# LDAP sync settings
ldap_sync_bind_dn = serializers.CharField(required=False, allow_blank=True)
ldap_sync_bind_password = serializers.CharField(
required=False, allow_blank=True)
ldap_sync_bind_password = serializers.CharField(required=False, allow_blank=True)
ldap_enable_sync = serializers.BooleanField(default=False)
ldap_sync_delete_remote_account = serializers.BooleanField(default=False)
ldap_sync_account_dn_template = serializers.CharField(
required=False, allow_blank=True
)
ldap_enable_import = serializers.BooleanField(default=False)
ldap_import_search_base = serializers.CharField(
required=False, allow_blank=True)
ldap_import_search_filter = serializers.CharField(
default="(cn=*)", required=False)
ldap_import_search_base = serializers.CharField(required=False, allow_blank=True)
ldap_import_search_filter = serializers.CharField(default="(cn=*)", required=False)
ldap_import_username_attr = serializers.CharField(default="cn")
ldap_dovecot_sync = serializers.BooleanField(default=False)
ldap_dovecot_conf_file = serializers.CharField(
@@ -151,8 +145,7 @@ class CoreGlobalParametersSerializer(serializers.Serializer):
hide_features_widget = serializers.BooleanField(default=False)
# Notification settings
sender_address = lib_fields.DRFEmailFieldUTF8(
default="noreply@yourdomain.test")
sender_address = lib_fields.DRFEmailFieldUTF8(default="noreply@yourdomain.test")
# API settings
enable_api_communication = serializers.BooleanField(default=True)
@@ -265,6 +258,7 @@ class FidoRegistrationSerializer(serializers.Serializer):
class FidoAuthenticationSerializer(serializers.Serializer):
"""Serializer used to finish the fido key authentication."""
authenticatorAttachment = serializers.CharField()
clientExtensionResults = serializers.JSONField()
id = serializers.CharField()
@@ -381,8 +375,7 @@ class PasswordRecoverySmsSerializer(serializers.Serializer):
request = self.context["request"]
clean_email = data["email"]
user = User.objects.filter(
email__iexact=clean_email, is_active=True).first()
user = User.objects.filter(email__iexact=clean_email, is_active=True).first()
if user is None:
raise CustomValidationError(
{"type": "sms", "reason": "No valid user found."}, 404
@@ -402,8 +395,7 @@ class PasswordRecoverySmsSerializer(serializers.Serializer):
user = self.context["user"]
if not user:
raise NoSMSAvailable()
backend = sms_backends.get_active_backend(
request.localconfig.parameters)
backend = sms_backends.get_active_backend(request.localconfig.parameters)
secret = cryptutils.random_hex_key(20)
send_sms_code(secret, backend, user)
request.session["totp_secret"] = secret
@@ -466,8 +458,7 @@ class PasswordRecoverySmsResendSerializer(serializers.Serializer):
backend = sms_backends.get_active_backend(
self.context["request"].localconfig.parameters
)
send_sms_code(self.context["totp_secret"],
backend, self.context["user"])
send_sms_code(self.context["totp_secret"], backend, self.context["user"])
return True
@@ -496,8 +487,7 @@ class PasswordRecoveryConfirmSerializer(serializers.Serializer):
data["new_password1"] == ""
or data["new_password1"] != data["new_password2"]
):
raise serializers.ValidationError(
"Password is empty or does not match")
raise serializers.ValidationError("Password is empty or does not match")
user = self.get_user(data["id"])
+18 -32
View File
@@ -64,8 +64,7 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet):
def manage_api_token(self, request):
"""Manage API token."""
if not request.user.is_superuser:
raise PermissionDenied(
"Only super administrators can have API tokens")
raise PermissionDenied("Only super administrators can have API tokens")
if request.method == "DELETE":
Token.objects.filter(user=request.user).delete()
return response.Response(status=204)
@@ -171,12 +170,14 @@ class LanguageViewSet(GetThrottleViewsetMixin, viewsets.ViewSet):
return response.Response(languages)
class FIDOViewSet(GetThrottleViewsetMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet):
"""Fido management viewset. """
class FIDOViewSet(
GetThrottleViewsetMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
"""Fido management viewset."""
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.FIDOSerializer
@@ -193,32 +194,17 @@ class FIDOViewSet(GetThrottleViewsetMixin,
@action(methods=["post"], detail=False, url_path="registration/end")
def registration_end(self, request):
"""An Api View to complete the registration process of a fido key."""
serializer = serializers.FidoRegistrationSerializer(
data=request.data)
serializer = serializers.FidoRegistrationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
credential_data = f2_auth.end_registration(request.data,
request.session.pop(
"fido2_state"),
request.user.id)
models.UserFidoKeys.objects.create(name=request.data["name"],
credential_data=credential_data,
user=request.user)
credential_data = f2_auth.end_registration(
request.data, request.session.pop("fido2_state"), request.user.id
)
models.UserFidoKeys.objects.create(
name=request.data["name"],
credential_data=credential_data,
user=request.user,
)
if not request.user.tfa_enabled:
request.user.tfa_enabled = True
request.user.save()
return response.Response(status=204)
@action(methods=["post"], detail=False, url_path="authenticate/begin")
def authenticate_begin(self, request):
"""An API View to start the authentication process."""
options = f2_auth.begin_authentication(request)
return response.Response(dict(options))
@action(methods=["post"], detail=False, url_path="authenticate/end")
def authenticate_end(self, request):
"""An API View to finish the authentication process."""
serializer = serializers.FidoAuthenticationSerializer(
data=request.data)
serializer.is_valid(raise_exception=True)
result, detail = f2_auth.end_authentication(request)
return response.Response({"success": True})
+2
View File
@@ -114,5 +114,7 @@ if settings.DEBUG:
SMS_BACKENDS.insert(1, ("dummy", gettext_lazy("Dummy")))
TFA_DEVICE_TOKEN_KEY = "otp_device_id"
TFA_PRE_VERIFY_USER_PK = "tfa_pre_verify_user_pk"
TFA_PRE_VERIFY_USER_BACKEND = "tfa_pre_verify_user_backend"
DOVEADM_PASS_SCHEME_ALARM = "doveadm_password_scheme_fail"
+27 -25
View File
@@ -1,11 +1,16 @@
from .models import UserFidoKeys
from fido2.webauthn import PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, AttestedCredentialData, UserVerificationRequirement
from fido2.webauthn import (
PublicKeyCredentialRpEntity,
PublicKeyCredentialUserEntity,
AttestedCredentialData,
UserVerificationRequirement,
)
from fido2.server import Fido2Server
from fido2.utils import websafe_decode, websafe_encode
import fido2.features
import datetime
from django.utils import timezone
from modoboa.core.models import User, UserFidoKeys
def set_json_mapping():
@@ -21,7 +26,10 @@ def create_fido2_server():
def get_creds_from_user(user_id):
return [AttestedCredentialData(websafe_decode(uf.credential_data)) for uf in UserFidoKeys.objects.filter(user=user_id)]
return [
AttestedCredentialData(websafe_decode(uf.credential_data))
for uf in UserFidoKeys.objects.filter(user=user_id)
]
def begin_registration(request):
@@ -30,49 +38,43 @@ def begin_registration(request):
options, state = server.register_begin(
PublicKeyCredentialUserEntity(
id=request.user.pk,
name=request.user.username.encode('utf-8'),
name=request.user.username.encode("utf-8"),
display_name=request.user.username,
),
get_creds_from_user(request.user.pk),
user_verification=UserVerificationRequirement.DISCOURAGED,
extensions={"credentialProtectionPolicy": "userVerificationOptional"}
extensions={"credentialProtectionPolicy": "userVerificationOptional"},
)
request.session['fido2_state'] = state
request.session["fido2_state"] = state
return options
def end_registration(data, fido2_state, user_id):
set_json_mapping()
server = create_fido2_server()
auth_data = server.register_complete(
fido2_state,
data
)
auth_data = server.register_complete(fido2_state, data)
return websafe_encode(auth_data.credential_data)
def begin_authentication(request):
def begin_authentication(user_id: int):
set_json_mapping()
server = create_fido2_server()
options, state = server.authenticate_begin(
get_creds_from_user(request.user.id))
request.session['fido_state'] = state
return options
return server.authenticate_begin(get_creds_from_user(user_id))
def end_authentication(request):
def end_authentication(user: User, state: str, data: dict):
set_json_mapping()
server = create_fido2_server()
credentials = get_creds_from_user(request.user.id)
# FIXME: review the next function...
credentials = get_creds_from_user(user.pk)
result = server.authenticate_complete(
request.session.pop("fido_state"),
state,
credentials,
request.data,
data,
)
for key in UserFidoKeys.objects.filter(user=request.user.id):
for key in user.userfidokeys_set.all():
cred = AttestedCredentialData(websafe_decode(key.credential_data))
if cred.credential_id == result.credential_id:
key.last_used = datetime.datetime.now()
key.last_used = timezone.now()
key.use_count += 1
key.save()
return True, ""
+1 -10
View File
@@ -14,17 +14,8 @@ from modoboa.lib.form_utils import UserKwargModelFormMixin
from modoboa.parameters import tools as param_tools
class LoginForm(forms.Form):
"""User login form."""
class AuthenticationForm(auth_forms.AuthenticationForm):
username = forms.CharField(
label=gettext_lazy("Username"),
widget=forms.TextInput(attrs={"class": "form-control"}),
)
password = forms.CharField(
label=gettext_lazy("Password"),
widget=forms.PasswordInput(attrs={"class": "form-control"}),
)
rememberme = forms.BooleanField(initial=False, required=False)
+11 -1
View File
@@ -10,13 +10,23 @@ app_name = "core"
urlpatterns = [
path("", views.RootDispatchView.as_view(), name="root"),
path("dashboard/", views.DashboardView.as_view(), name="dashboard"),
path("accounts/login/", views.dologin, name="login"),
path("accounts/login/", views.LoginView.as_view(), name="login"),
path("accounts/logout/", views.dologout, name="logout"),
path(
"accounts/2fa_verify/",
views.TwoFactorCodeVerifyView.as_view(),
name="2fa_verify",
),
path(
"accounts/fido/authenticate/begin",
views.FidoAuthenticationBeginView.as_view(),
name="fido_auth_begin",
),
path(
"accounts/fido/authenticate/end",
views.FidoAuthenticationEndView.as_view(),
name="fido_auth_end",
),
path("core/", views.viewsettings, name="index"),
path("core/parameters/", views.parameters, name="parameters"),
path("core/info/", views.information, name="information"),
+6 -2
View File
@@ -10,11 +10,13 @@ from .admin import (
)
from .auth import (
PasswordResetView,
dologin,
LoginView,
dologout,
VerifySMSCodeView,
ResendSMSCodeView,
TwoFactorCodeVerifyView,
FidoAuthenticationBeginView,
FidoAuthenticationEndView,
)
from .base import RootDispatchView
from .dashboard import DashboardView
@@ -22,15 +24,17 @@ from .user import api_access, index, preferences, profile, security
__all__ = [
"DashboardView",
"LoginView",
"PasswordResetView",
"ResendSMSCodeView",
"RootDispatchView",
"VerifySMSCodeView",
"api_access",
"check_top_notifications",
"dologin",
"dologout",
"TwoFactorCodeVerifyView",
"FidoAuthenticationBeginView",
"FidoAuthenticationEndView",
"index",
"information",
"logs",
+153 -116
View File
@@ -1,12 +1,13 @@
"""Core authentication views."""
from functools import cached_property
import logging
import oath
from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect, Http404, JsonResponse
from django.template.loader import render_to_string
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect, Http404, JsonResponse
from django.urls import reverse
from django.utils import translation
from django.utils.encoding import force_bytes
@@ -14,22 +15,21 @@ from django.utils.html import escape
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext as _
from django.views import generic
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods
from django.contrib.auth import authenticate, login, logout, views as auth_views
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth import load_backend, login, logout
from django.contrib.auth.tokens import default_token_generator
import django.contrib.auth.views as auth_views
import django_otp
from rest_framework.views import APIView
from modoboa.core import forms
from modoboa.core import constants, fido2_auth, forms, models
from modoboa.core.api.v2 import serializers
from modoboa.core.password_hashers import get_password_hasher
from modoboa.lib import cryptutils
from modoboa.lib.views import UserFormKwargsMixin
from modoboa.parameters import tools as param_tools
from .. import models
from .. import sms_backends
from .. import signals
from .base import find_nextlocation
@@ -37,104 +37,107 @@ from .base import find_nextlocation
logger = logging.getLogger("modoboa.auth")
def dologin(request):
"""Try to authenticate."""
error = None
if request.method == "POST":
form = forms.LoginForm(request.POST)
if form.is_valid():
logger = logging.getLogger("modoboa.auth")
user = authenticate(
username=form.cleaned_data["username"],
password=form.cleaned_data["password"],
class LoginViewMixin:
@cached_property
def logger(self):
return logging.getLogger("modoboa.auth")
def get_user(self):
try:
user_id = self.request.session[constants.TFA_PRE_VERIFY_USER_PK]
backend_path = self.request.session[constants.TFA_PRE_VERIFY_USER_BACKEND]
assert backend_path in settings.AUTHENTICATION_BACKENDS
backend = load_backend(backend_path)
user = backend.get_user(user_id)
if user is not None:
user.backend = backend_path
return user
except (KeyError, AssertionError):
return None
def login(self, user, rememberme):
login(self.request, user)
if not rememberme:
self.request.session.set_expiry(0)
translation.activate(user.language)
self.logger.info(_("User '%s' successfully logged in") % user.username)
response = HttpResponseRedirect(find_nextlocation(self.request, user))
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, user.language)
return response
class LoginView(LoginViewMixin, auth_views.LoginView):
"""Login view with 2FA support."""
form_class = forms.AuthenticationForm
template_name = "registration/login.html"
def get_context_data(self, **kwargs):
announcements = signals.get_announcements.send(
sender="login", location="loginpage"
)
announcements = [announcement[1] for announcement in announcements]
return super().get_context_data(announcements=announcements, **kwargs)
def check_password_hash(self, user, form):
condition = user.is_local and param_tools.get_global_parameter(
"update_scheme", raise_exception=False
)
if not condition:
return
# check if password scheme is correct
scheme = param_tools.get_global_parameter(
"password_scheme", raise_exception=False
)
# use SHA512CRYPT as default fallback
if scheme is None:
pwhash = get_password_hasher("sha512crypt")()
else:
pwhash = get_password_hasher(scheme)()
if not user.password.startswith(pwhash.scheme):
logging.info(
_("Password scheme mismatch. Updating %s password"),
user.username,
)
if user and user.is_active:
condition = user.is_local and param_tools.get_global_parameter(
"update_scheme", raise_exception=False
)
if condition:
# check if password scheme is correct
scheme = param_tools.get_global_parameter(
"password_scheme", raise_exception=False
)
# use SHA512CRYPT as default fallback
if scheme is None:
pwhash = get_password_hasher("sha512crypt")()
else:
pwhash = get_password_hasher(scheme)()
if not user.password.startswith(pwhash.scheme):
logging.info(
_("Password scheme mismatch. Updating %s password"),
user.username,
)
user.set_password(form.cleaned_data["password"])
user.save()
if pwhash.needs_rehash(user.password):
logging.info(
_(
"Password hash parameter missmatch. "
"Updating %s password"
),
user.username,
)
user.set_password(form.cleaned_data["password"])
user.save()
login(request, user)
if not form.cleaned_data["rememberme"]:
request.session.set_expiry(0)
translation.activate(request.user.language)
logger.info(_("User '%s' successfully logged in") %
user.username)
signals.user_login.send(
sender="dologin",
username=form.cleaned_data["username"],
password=form.cleaned_data["password"],
)
response = HttpResponseRedirect(
find_nextlocation(request, user))
response.set_cookie(
settings.LANGUAGE_COOKIE_NAME, request.user.language
)
return response
error = _("Your username and password didn't match. Please try again.")
logger.warning(
"Failed connection attempt from '%(addr)s' as user '%(user)s'"
% {
"addr": request.META["REMOTE_ADDR"],
"user": escape(form.cleaned_data["username"]),
}
user.set_password(form.cleaned_data["password"])
user.save()
if pwhash.needs_rehash(user.password):
logging.info(
_("Password hash parameter missmatch. " "Updating %s password"),
user.username,
)
user.set_password(form.cleaned_data["password"])
user.save()
nextlocation = request.POST.get("next", "")
httpcode = 401
else:
form = forms.LoginForm()
nextlocation = request.GET.get("next", "")
httpcode = 200
def form_valid(self, form):
user = form.get_user()
self.check_password_hash(user, form)
# FIXME: remove ASAP
signals.user_login.send(
sender="dologin",
username=user.username,
password=form.cleaned_data["password"],
)
if user.tfa_enabled:
self.request.session[constants.TFA_PRE_VERIFY_USER_PK] = user.pk
self.request.session[constants.TFA_PRE_VERIFY_USER_BACKEND] = user.backend
self.request.session["rememberme"] = form.cleaned_data["rememberme"]
return HttpResponseRedirect(reverse("core:2fa_verify"))
return self.login(user, form.cleaned_data["rememberme"])
announcements = signals.get_announcements.send(
sender="login", location="loginpage")
announcements = [announcement[1] for announcement in announcements]
return HttpResponse(
render_to_string(
"registration/login.html",
{
"form": form,
"error": error,
"next": nextlocation,
"annoucements": announcements,
},
request,
),
status=httpcode,
)
dologin = never_cache(dologin)
def form_invalid(self, form):
# FIXME: should we return a 401 as before ?
self.logger.warning(
"Failed connection attempt from '%(addr)s' as user '%(user)s'"
% {
"addr": self.request.META["REMOTE_ADDR"],
"user": escape(form.cleaned_data["username"]),
}
)
return super().form_invalid(form)
@require_http_methods(["POST"])
@@ -157,8 +160,7 @@ class PasswordResetView(auth_views.PasswordResetView):
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.from_email = request.localconfig.parameters.get_value(
"sender_address")
self.from_email = request.localconfig.parameters.get_value("sender_address")
def get_context_data(self, **kwargs):
"""Include help text."""
@@ -181,8 +183,7 @@ class PasswordResetView(auth_views.PasswordResetView):
if not user:
# Fallback to email
return super().form_valid(form)
backend = sms_backends.get_active_backend(
self.request.localconfig.parameters)
backend = sms_backends.get_active_backend(self.request.localconfig.parameters)
secret = cryptutils.random_hex_key(20)
code = oath.totp(secret)
text = _(
@@ -232,12 +233,10 @@ class ResendSMSCodeView(generic.View):
if not sms_password_recovery:
raise Http404
try:
user = models.User._default_manager.get(
pk=self.request.session["user_pk"])
user = models.User._default_manager.get(pk=self.request.session["user_pk"])
except KeyError:
raise Http404
backend = sms_backends.get_active_backend(
self.request.localconfig.parameters)
backend = sms_backends.get_active_backend(self.request.localconfig.parameters)
secret = cryptutils.random_hex_key(20)
code = oath.totp(secret)
text = _(
@@ -251,24 +250,62 @@ class ResendSMSCodeView(generic.View):
return JsonResponse({"status": "ok"})
class TwoFactorCodeVerifyView(
LoginRequiredMixin, UserFormKwargsMixin, generic.FormView
):
class TwoFactorCodeVerifyView(LoginViewMixin, generic.FormView):
"""View to verify a 2FA code after login."""
form_class = forms.Verify2FACodeForm
template_name = "registration/twofactor_code_verify.html"
def dispatch(self, request, *args, **kwargs):
self.user = self.get_user()
if not self.user:
return HttpResponseRedirect(reverse("core:login"))
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update({"user": self.user})
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
has_totp_device = django_otp.user_has_device(self.request.user)
has_totp_device = django_otp.user_has_device(self.user)
context["totp_device"] = has_totp_device
has_webauthn_device = models.UserFidoKeys.objects.filter(
user=self.request.user).exists()
user=self.user
).exists()
context["webauthn_device"] = has_webauthn_device
return context
def form_valid(self, form):
"""Login user."""
response = self.login(self.user, self.request.session.pop("rememberme", False))
django_otp.login(self.request, form.cleaned_data["tfa_code"])
return HttpResponseRedirect(find_nextlocation(self.request, self.request.user))
return response
class FidoAuthenticationBeginView(generic.View):
"""FIDO authentication process, begining."""
def post(self, request, *args, **kwargs):
user_id = request.session.get(constants.TFA_PRE_VERIFY_USER_PK)
if not user_id:
raise PermissionDenied
options, state = fido2_auth.begin_authentication(user_id)
request.session["fido_state"] = state
return JsonResponse(dict(options))
class FidoAuthenticationEndView(LoginViewMixin, APIView):
"""FIDO authentication process, end."""
def post(self, request, *args, **kwargs):
user = self.get_user()
fido_state = request.session.pop("fido_state", None)
if not user or not fido_state:
raise PermissionDenied
serializer = serializers.FidoAuthenticationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
fido2_auth.end_authentication(user, fido_state, serializer.validated_data)
response = self.login(user, self.request.session.pop("rememberme", False))
return JsonResponse({"next": response.url})
+4
View File
@@ -128,6 +128,10 @@ h2 {
margin-bottom: 20px;
}
.mt {
margin-top: 5px;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
@@ -7,106 +7,107 @@
{% endblock %}
{% block main_content %}
<div class="row">
<div class="col-sm-offset-4 col-sm-6">
<div id="methodSelection">
{% if totp_device %}
<button onclick="showTOTP()">Use TOTP/backup code</button>
{% endif %}
{% if webauthn_device %}
<button onclick="showWebAuthn()">Use Webautn device</button>
{% endif %}
</div>
<div id="totp_form" style="display:none;">
<form method="post" action="{% url 'core:2fa_verify' %}" class="login-form">
{% csrf_token %}
<div class="form-group{% if form.tfa_code.errors %} has-error{% endif %}">
<label class="control-label">Two-Factor authentication code</label>
<input id="id_tfa_code" name="tfa_code" class="form-control">
<span class="help-block">
{{ form.tfa_code.errors }}
</span>
<p class="help-block">
<div class="login-box">
<h2>{% translate "Verification" %}</h2>
<div id="methodSelection">
{% if totp_device %}
<button class="btn" onclick="showTOTP()">{% translate "Use TOTP/backup code" %}</button>
{% endif %}
{% if webauthn_device %}
<button class="mt btn" onclick="showWebAuthn()">{% translate "Use Webauthn device" %}</button>
{% endif %}
</div>
<div id="totp_form" style="display:none;">
<form method="post" action="{% url 'core:2fa_verify' %}">
{% csrf_token %}
<div class="input-group{% if form.tfa_code.errors %} has-error{% endif %}">
<label>{% translate "One-time password" %}</label>
<input id="id_tfa_code" name="tfa_code">
<span class="help-block">
{{ form.tfa_code.errors }}
</span>
<p class="help-block">
{% blocktranslate trimmed %}
Enter the code from the two-factor app on your mobile
device. If you've lost your device, you may enter one of your
recovery codes.
</p>
</div>
<button class="btn btn-primary col-sm-12" type="submit">Verify code</button>
</form>
<button onclick="backToSelection()">Back</button>
</div>
<div id="webautn_form" style="display:none;">
<button onclick="useWebAuthn()">Log using Webauthn</button>
<button onclick="backToSelection()">Back</button>
</div>
{% endblocktranslate %}
</p>
</div>
<button class="btn btn-primary" type="submit">Verify code</button>
</form>
<button class="mt" onclick="backToSelection()">Back</button>
</div>
<div id="webautn_form" style="display:none;">
<button class="btn" onclick="useWebAuthn()">Log using Webauthn</button>
<button class="mt" onclick="backToSelection()">Back</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script type="module" defer>
import {
get,
parseRequestOptionsFromJSON,
} from "{% static 'js/webauthn-json.browser-ponyfill.js' %}"
import {
get,
parseRequestOptionsFromJSON,
} from "{% static 'js/webauthn-json.browser-ponyfill.js' %}"
function showTOTP() {
document.getElementById("totp_form").style.display = 'block'
document.getElementById("methodSelection").style.display = 'none'
function showTOTP() {
document.getElementById("totp_form").style.display = 'block'
document.getElementById("methodSelection").style.display = 'none'
}
window.showTOTP = showTOTP
function backToSelection() {
document.getElementById("totp_form").style.display = 'none'
document.getElementById("webautn_form").style.display = 'none'
document.getElementById("methodSelection").style.display = 'block'
}
window.backToSelection = backToSelection
function showWebAuthn() {
document.getElementById("webautn_form").style.display = 'block'
document.getElementById("methodSelection").style.display = 'none'
}
window.showWebAuthn = showWebAuthn
async function useWebAuthn() {
const csrf = getCookie('csrftoken')
const request = await fetch("{% url 'core:fido_auth_begin' %}", {
method: 'POST',
headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json' }
})
if (!request.ok) {
throw new Error('No credential available to authenticate!')
}
window.showTOTP = showTOTP
let json = await request.json()
let options = parseRequestOptionsFromJSON(json)
function backToSelection() {
document.getElementById("totp_form").style.display = 'none'
document.getElementById("webautn_form").style.display = 'none'
document.getElementById("methodSelection").style.display = 'block'
}
window.backToSelection = backToSelection
let response = await get(options);
let result = await fetch("{% url 'core:fido_auth_end' %}", {
method: 'POST',
headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json'},
body: JSON.stringify(response),
})
json = await result.json()
location.href = json.next
}
window.useWebAuthn = useWebAuthn
function showWebAuthn() {
document.getElementById("webautn_form").style.display = 'block'
document.getElementById("methodSelection").style.display = 'none'
}
window.showWebAuthn = showWebAuthn
async function useWebAuthn() {
const csrf = getCookie('csrftoken')
console.log(csrf)
const request = await fetch("{% url 'v2:fido-authenticate-begin' %}", {
method: 'POST',
headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json' }
})
if(!request.ok) {
throw new Error('No credential available to authenticate!')
function getCookie(cname) {
const name = cname + "="
const decodedCookie = decodeURIComponent(document.cookie)
const ca = decodedCookie.split(';')
for(let i = 0; i <ca.length; i++) {
let c = ca[i]
while (c.charAt(0) == ' ') {
c = c.substring(1)
}
let json = await request.json()
let options = parseRequestOptionsFromJSON(json)
let response = await get(options);
let result = await fetch("{% url 'v2:fido-authenticate-end' %}", {
method: 'POST',
headers: { "X-CSRFToken": csrf, 'Content-Type': 'application/json'},
body: JSON.stringify(response),
})
}
window.useWebAuthn = useWebAuthn
function getCookie(cname) {
const name = cname + "="
const decodedCookie = decodeURIComponent(document.cookie)
const ca = decodedCookie.split(';')
for(let i = 0; i <ca.length; i++) {
let c = ca[i]
while (c.charAt(0) == ' ') {
c = c.substring(1)
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length)
}
if (c.indexOf(name) == 0) {
return c.substring(name.length, c.length)
}
return ""
}
return ""
}
</script>
{% endblock %}
+2 -2
View File
@@ -122,8 +122,8 @@ MIDDLEWARE = (
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django_otp.middleware.OTPMiddleware",
"modoboa.core.middleware.TwoFAMiddleware",
# "django_otp.middleware.OTPMiddleware",
# "modoboa.core.middleware.TwoFAMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"modoboa.core.middleware.LocalConfigMiddleware",