From 381e4c7be50436e3275c5726d73be622a8684150 Mon Sep 17 00:00:00 2001 From: Spitfireap Date: Fri, 24 May 2024 17:48:07 +0200 Subject: [PATCH] Added Webautn login TODO: modify the middleware to certify the user has successfully used it... --- modoboa/core/api/v2/serializers.py | 43 +++- modoboa/core/api/v2/viewsets.py | 26 ++- modoboa/core/fido2_auth.py | 32 ++- modoboa/core/forms.py | 18 +- modoboa/core/middleware.py | 3 +- modoboa/core/models.py | 17 +- modoboa/core/views/auth.py | 30 ++- .../js/webauthn-json.browser-ponyfill.js | 193 ++++++++++++++++++ .../registration/twofactor_code_verify.html | 114 +++++++++-- 9 files changed, 429 insertions(+), 47 deletions(-) create mode 100644 modoboa/static/js/webauthn-json.browser-ponyfill.js diff --git a/modoboa/core/api/v2/serializers.py b/modoboa/core/api/v2/serializers.py index e56baa02f..8d46376da 100644 --- a/modoboa/core/api/v2/serializers.py +++ b/modoboa/core/api/v2/serializers.py @@ -75,7 +75,8 @@ 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 @@ -86,7 +87,8 @@ 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" ) @@ -107,7 +109,8 @@ 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 ) @@ -123,15 +126,18 @@ 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( @@ -145,7 +151,8 @@ 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) @@ -255,6 +262,16 @@ class FidoRegistrationSerializer(serializers.Serializer): name = serializers.CharField() +class FidoAuthenticationSerializer(serializers.Serializer): + """Serializer used to finish the fido key authentication.""" + authenticatorAttachment = serializers.CharField() + clientExtensionResults = serializers.JSONField() + id = serializers.CharField() + rawId = serializers.CharField() + response = serializers.JSONField() + type = serializers.CharField() + + class LogSerializer(serializers.ModelSerializer): """Log serializer.""" @@ -363,7 +380,8 @@ 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 @@ -383,7 +401,8 @@ 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 @@ -446,7 +465,8 @@ 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 @@ -475,7 +495,8 @@ 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 8efe905a1..74599432f 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -64,7 +64,8 @@ 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) @@ -196,9 +197,28 @@ class FIDOViewSet(GetThrottleViewsetMixin, 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) + 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({"success": True}, 200) + + @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/fido2_auth.py b/modoboa/core/fido2_auth.py index a1012edc3..aa38a8e28 100644 --- a/modoboa/core/fido2_auth.py +++ b/modoboa/core/fido2_auth.py @@ -5,6 +5,7 @@ from fido2.webauthn import PublicKeyCredentialRpEntity, PublicKeyCredentialUserE from fido2.server import Fido2Server from fido2.utils import websafe_decode, websafe_encode import fido2.features +import datetime def set_json_mapping(): @@ -34,7 +35,7 @@ def begin_registration(request): ), get_creds_from_user(request.user.pk), user_verification=UserVerificationRequirement.DISCOURAGED, - extensions={"credentialProtectionPolicy":"userVerificationOptional"} + extensions={"credentialProtectionPolicy": "userVerificationOptional"} ) request.session['fido2_state'] = state return options @@ -46,5 +47,32 @@ def end_registration(data, fido2_state, user_id): auth_data = server.register_complete( fido2_state, data - ) + ) return websafe_encode(auth_data.credential_data) + + +def begin_authentication(request): + 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 + + +def end_authentication(request): + set_json_mapping() + server = create_fido2_server() + credentials = get_creds_from_user(request.user.id) + result = server.authenticate_complete( + request.session.pop("fido_state"), + credentials, + request.data, + ) + for key in UserFidoKeys.objects.filter(user=request.user.id): + cred = AttestedCredentialData(websafe_decode(key.credential_data)) + if cred.credential_id == result.credential_id: + key.last_used = datetime.datetime.now() + key.use_count += 1 + key.save() + return True, "" diff --git a/modoboa/core/forms.py b/modoboa/core/forms.py index e5bda6f0c..6358d365e 100644 --- a/modoboa/core/forms.py +++ b/modoboa/core/forms.py @@ -9,7 +9,8 @@ from django.utils.translation import gettext as _, gettext_lazy import django_otp -from modoboa.core.models import User +from modoboa.core.models import User, UserFidoKeys +from modoboa.core import utils from modoboa.lib.form_utils import UserKwargModelFormMixin from modoboa.parameters import tools as param_tools @@ -117,6 +118,21 @@ class ProfileForm(forms.ModelForm): return user +class TwoFAChoiceForm(forms.Form): + """Form to select the 2FA method of choice.""" + two_fa_choices = forms.ChoiceField(choices=(), + required=True) + + def __init__(self, *args, **kwargs): + user = kwargs.pop("user") + fido_keys = UserFidoKeys.objects.filter(user=user) + if fido_keys.exists(): + self.fields["two_fa_choices.choices"].choices = [ + ("TOTP", "TOTP or recovery codes"), + ("FIDO", "Webauthn device") + ] + + class APIAccessForm(forms.Form): """Form to control API access.""" diff --git a/modoboa/core/middleware.py b/modoboa/core/middleware.py index b9aaef4aa..098575df3 100644 --- a/modoboa/core/middleware.py +++ b/modoboa/core/middleware.py @@ -16,7 +16,8 @@ class LocalConfigMiddleware(MiddlewareMixin): class TwoFAMiddleware: - """Custom 2FA middleware to enforce verification if user has TFA enabled.""" + """Custom 2FA middleware to enforce verification if user has TFA enabled. + Also verify the user if it successfully use a webautn token.""" def __init__(self, get_response=None): self.get_response = get_response diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 3d9376da6..28782b523 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -58,7 +58,8 @@ class User(AbstractUser): master_user = models.BooleanField( gettext_lazy("Allow mailboxes access"), default=False, - help_text=gettext_lazy("Allow this administrator to access user mailboxes"), + help_text=gettext_lazy( + "Allow this administrator to access user mailboxes"), ) password = models.CharField(gettext_lazy("password"), max_length=256) @@ -69,7 +70,8 @@ class User(AbstractUser): choices=constants.LANGUAGES, help_text=gettext_lazy("Prefered language to display pages."), ) - phone_number = PhoneNumberField(gettext_lazy("Phone number"), blank=True, null=True) + phone_number = PhoneNumberField(gettext_lazy( + "Phone number"), blank=True, null=True) secondary_email = models.EmailField( gettext_lazy("Secondary email"), max_length=254, @@ -212,7 +214,8 @@ class User(AbstractUser): """ ct = ContentType.objects.get_for_model(obj) try: - ooentry = self.objectaccess_set.get(content_type=ct, object_id=obj.id) + ooentry = self.objectaccess_set.get( + content_type=ct, object_id=obj.id) except ObjectAccess.DoesNotExist: return False return ooentry.is_owner @@ -233,7 +236,8 @@ class User(AbstractUser): ct = ContentType.objects.get_for_model(obj) try: - ooentry = self.objectaccess_set.get(content_type=ct, object_id=obj.id) + ooentry = self.objectaccess_set.get( + content_type=ct, object_id=obj.id) except ObjectAccess.DoesNotExist: pass else: @@ -394,7 +398,8 @@ class User(AbstractUser): smart_str(self.role), smart_str(self.email), ] - results = signals.account_exported.send(sender=self.__class__, user=self) + results = signals.account_exported.send( + sender=self.__class__, user=self) for result in results: row += result[1] return row @@ -413,7 +418,7 @@ reversion.register(User) class UserFidoKeys(models.Model): - user = models.ForeignKey(User ,on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=255) enabled = models.BooleanField(default=True) added_on = models.DateTimeField(auto_now_add=True) diff --git a/modoboa/core/views/auth.py b/modoboa/core/views/auth.py index 708708064..64076cebb 100644 --- a/modoboa/core/views/auth.py +++ b/modoboa/core/views/auth.py @@ -86,13 +86,15 @@ def dologin(request): translation.activate(request.user.language) - logger.info(_("User '%s' successfully logged in") % user.username) + 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 = HttpResponseRedirect( + find_nextlocation(request, user)) response.set_cookie( settings.LANGUAGE_COOKIE_NAME, request.user.language ) @@ -114,7 +116,8 @@ def dologin(request): nextlocation = request.GET.get("next", "") httpcode = 200 - announcements = signals.get_announcements.send(sender="login", location="loginpage") + announcements = signals.get_announcements.send( + sender="login", location="loginpage") announcements = [announcement[1] for announcement in announcements] return HttpResponse( render_to_string( @@ -154,7 +157,8 @@ 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.""" @@ -177,7 +181,8 @@ 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 = _( @@ -227,10 +232,12 @@ 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 = _( @@ -252,6 +259,15 @@ class TwoFactorCodeVerifyView( form_class = forms.Verify2FACodeForm template_name = "registration/twofactor_code_verify.html" + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + has_totp_device = django_otp.user_has_device(self.request.user) + context["totp_device"] = has_totp_device + has_webauthn_device = models.UserFidoKeys.objects.filter( + user=self.request.user).exists() + context["webauthn_device"] = has_webauthn_device + return context + def form_valid(self, form): """Login user.""" django_otp.login(self.request, form.cleaned_data["tfa_code"]) diff --git a/modoboa/static/js/webauthn-json.browser-ponyfill.js b/modoboa/static/js/webauthn-json.browser-ponyfill.js new file mode 100644 index 000000000..664592809 --- /dev/null +++ b/modoboa/static/js/webauthn-json.browser-ponyfill.js @@ -0,0 +1,193 @@ +// 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 +var copyValue = "copy"; +var convertValue = "convert"; +function convert(conversionFn, schema, input) { + if (schema === copyValue) { + return input; + } + if (schema === convertValue) { + return conversionFn(input); + } + if (schema instanceof Array) { + return input.map((v) => convert(conversionFn, schema[0], v)); + } + if (schema instanceof Object) { + const output = {}; + for (const [key, schemaField] of Object.entries(schema)) { + 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(schema, derive) { + return { + required: true, + schema, + derive + }; +} +function required(schema) { + return { + required: true, + schema + }; +} +function optional(schema) { + return { + required: false, + schema + }; +} + +// src/webauthn-json/basic/schema.ts +var publicKeyCredentialDescriptorSchema = { + type: required(copyValue), + id: required(convertValue), + transports: optional(copyValue) +}; +var simplifiedExtensionsSchema = { + appid: optional(copyValue), + appidExclude: optional(copyValue), + credProps: optional(copyValue) +}; +var simplifiedClientExtensionResultsSchema = { + appid: optional(copyValue), + appidExclude: optional(copyValue), + credProps: optional(copyValue) +}; +var 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) +}; +var 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) => { + var _a; + return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || []; + }) + }), + clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults()) +}; +var 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) +}; +var 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()) +}; + +// src/webauthn-json/basic/api.ts +function createRequestFromJSON(requestJSON) { + return convert(base64urlToBuffer, credentialCreationOptions, requestJSON); +} +function createResponseToJSON(credential) { + return convert(bufferToBase64url, publicKeyCredentialWithAttestation, credential); +} +function getRequestFromJSON(requestJSON) { + return convert(base64urlToBuffer, credentialRequestOptions, requestJSON); +} +function getResponseToJSON(credential) { + return convert(bufferToBase64url, publicKeyCredentialWithAssertion, credential); +} + +// src/webauthn-json/basic/supported.ts +function supported() { + return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential); +} + +// src/webauthn-json/browser-ponyfill.ts +async function create(options) { + const response = await navigator.credentials.create(options); + response.toJSON = () => createResponseToJSON(response); + return response; +} +async function get(options) { + const response = await navigator.credentials.get(options); + response.toJSON = () => getResponseToJSON(response); + return response; +} +export { + create, + get, + createRequestFromJSON as parseCreationOptionsFromJSON, + getRequestFromJSON as parseRequestOptionsFromJSON, + supported +}; +//# sourceMappingURL=webauthn-json.browser-ponyfill.js.map diff --git a/modoboa/templates/registration/twofactor_code_verify.html b/modoboa/templates/registration/twofactor_code_verify.html index 0a39eb4f4..a6c3fc19b 100644 --- a/modoboa/templates/registration/twofactor_code_verify.html +++ b/modoboa/templates/registration/twofactor_code_verify.html @@ -9,22 +9,104 @@ {% block main_content %}
- +
+ {% if totp_device %} + + {% endif %} + {% if webauthn_device %} + + {% endif %} +
+ +
{% endblock %} + +{% block extra_js %} + +{% endblock %}