diff --git a/modoboa/core/api/v2/serializers.py b/modoboa/core/api/v2/serializers.py index 6cbe33966..5b7c23202 100644 --- a/modoboa/core/api/v2/serializers.py +++ b/modoboa/core/api/v2/serializers.py @@ -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"]) diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index 933f85993..a7b17e845 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -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}) diff --git a/modoboa/core/constants.py b/modoboa/core/constants.py index 612682e07..8ebed6d0a 100644 --- a/modoboa/core/constants.py +++ b/modoboa/core/constants.py @@ -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" diff --git a/modoboa/core/fido2_auth.py b/modoboa/core/fido2_auth.py index aa38a8e28..084c81378 100644 --- a/modoboa/core/fido2_auth.py +++ b/modoboa/core/fido2_auth.py @@ -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, "" diff --git a/modoboa/core/forms.py b/modoboa/core/forms.py index 08a44abf8..14c2de4d8 100644 --- a/modoboa/core/forms.py +++ b/modoboa/core/forms.py @@ -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) diff --git a/modoboa/core/urls.py b/modoboa/core/urls.py index 76b6e31fa..510c0b197 100644 --- a/modoboa/core/urls.py +++ b/modoboa/core/urls.py @@ -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"), diff --git a/modoboa/core/views/__init__.py b/modoboa/core/views/__init__.py index eb728b210..494c4061d 100644 --- a/modoboa/core/views/__init__.py +++ b/modoboa/core/views/__init__.py @@ -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", diff --git a/modoboa/core/views/auth.py b/modoboa/core/views/auth.py index 64076cebb..cd6786ba1 100644 --- a/modoboa/core/views/auth.py +++ b/modoboa/core/views/auth.py @@ -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}) diff --git a/modoboa/static/css/offline.css b/modoboa/static/css/offline.css index 62288a7a4..c15fdeb37 100644 --- a/modoboa/static/css/offline.css +++ b/modoboa/static/css/offline.css @@ -128,6 +128,10 @@ h2 { margin-bottom: 20px; } +.mt { + margin-top: 5px; +} + @media (max-width: 768px) { .container { flex-direction: column; diff --git a/modoboa/templates/registration/twofactor_code_verify.html b/modoboa/templates/registration/twofactor_code_verify.html index a6c3fc19b..7df241935 100644 --- a/modoboa/templates/registration/twofactor_code_verify.html +++ b/modoboa/templates/registration/twofactor_code_verify.html @@ -7,106 +7,107 @@ {% endblock %} {% block main_content %} -
-
-
- {% if totp_device %} - - {% endif %} - {% if webauthn_device %} - - {% endif %} -
- {% endblock %} {% block extra_js %} {% endblock %} diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index f5671c574..93fc2f28c 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -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",