From fb3cc9e136e3706a2d554dce89384f4a039e81d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9=20Tille?= Date: Sat, 9 Nov 2024 21:15:59 +0100 Subject: [PATCH 1/2] Add possibility to allow/disallow main email, email alias and email forward form portal --- locales/en.json | 8 ++++++ share/actionsmap-portal.yml | 10 ++++--- share/config_global.toml | 12 +++++++++ src/portal.py | 52 ++++++++++++++++++++++++++++++++++--- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/locales/en.json b/locales/en.json index 4dd59bd919..2ff45750f1 100644 --- a/locales/en.json +++ b/locales/en.json @@ -466,6 +466,13 @@ "global_settings_setting_pop3_enabled": "Enable POP3", "global_settings_setting_pop3_enabled_help": "Enable the POP3 protocol for the mail server. POP3 is an older protocol to access mailboxes from email clients and is more lightweight, but has less features than IMAP (enabled by default)", "global_settings_setting_pop3_name": "POP3", + "global_settings_setting_portal_name": "Portal", + "global_settings_setting_portal_allow_edit_email": "Allow users to edit email", + "global_settings_setting_portal_allow_edit_email_alias": "Allow users to edit mail alias", + "global_settings_setting_portal_allow_edit_email_alias_help": "Allow users to edit their email address alias.", + "global_settings_setting_portal_allow_edit_email_forward": "Allow users to edit email forward", + "global_settings_setting_portal_allow_edit_email_forward_help": "Allow users to edit their main email forward.", + "global_settings_setting_portal_allow_edit_email_help": "Allow users to edit their main email address.", "global_settings_setting_postfix_compatibility": "Postfix Compatibility", "global_settings_setting_postfix_compatibility_help": "Compatibility vs. security tradeoff for the Postfix server. Affects the ciphers (and other security-related aspects)", "global_settings_setting_postfix_name": "Postfix (SMTP email server)", @@ -603,6 +610,7 @@ "log_user_update": "Update info for user '{}'", "mail_alias_remove_failed": "Could not remove e-mail alias '{mail}'", "mail_alias_unauthorized": "You are not authorized to add aliases related to domain '{domain}'", + "mail_edit_operation_unauthorized": "You are not authorized to do this change for your account.", "mail_already_exists": "Mail address '{mail}' already exists", "mail_domain_unknown": "Invalid e-mail address for domain '{domain}'. Please, use a domain administrated by this server.", "mail_forward_remove_failed": "Could not remove e-mail forwarding '{mail}'", diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml index 6b02a061d1..c71e638cd6 100644 --- a/share/actionsmap-portal.yml +++ b/share/actionsmap-portal.yml @@ -31,6 +31,12 @@ portal: pattern: &pattern_fullname - !!str ^([^\W_]{1,30}[ ,.'-]{0,3})+$ - "pattern_fullname" + --mainemail: + help: Main email + extra: + pattern: &pattern_email + - !!str ^[\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ + - "pattern_email" --mailforward: help: Mailforward addresses to add nargs: "*" @@ -44,9 +50,7 @@ portal: nargs: "*" metavar: MAIL extra: - pattern: &pattern_email - - !!str ^[\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ - - "pattern_email" + pattern: *pattern_email --currentpassword: help: Current password nargs: "?" diff --git a/share/config_global.toml b/share/config_global.toml index 2b2795a5cd..4a3ba26a5d 100644 --- a/share/config_global.toml +++ b/share/config_global.toml @@ -71,6 +71,18 @@ i18n = "global_settings_setting" optional = true default = "" + [security.portal] + [security.portal.portal_allow_edit_email] + type = "boolean" + + [security.portal.portal_allow_edit_email_alias] + type = "boolean" + default = 1 + + [security.portal.portal_allow_edit_email_forward] + type = "boolean" + default = 1 + [security.root_access] [security.root_access.root_access_explain] type = "alert" diff --git a/src/portal.py b/src/portal.py index a52c223a04..4a50c482e4 100644 --- a/src/portal.py +++ b/src/portal.py @@ -35,6 +35,7 @@ assert_password_is_strong_enough, _hash_user_password, ) +from yunohost.settings import settings_get logger = logging.getLogger("portal") @@ -150,6 +151,10 @@ def portal_me(): for infos in apps.values(): del infos["users"] + is_allowed_to_edit_main_email = settings_get("security.portal.portal_allow_edit_email") + is_allowed_to_edit_mail_alias = settings_get("security.portal.portal_allow_edit_email_alias") + is_allowed_to_edit_mail_forward = settings_get("security.portal.portal_allow_edit_email_forward") + result_dict = { "username": username, "fullname": user["cn"][0], @@ -158,6 +163,9 @@ def portal_me(): "mailforward": user["maildrop"][1:], "groups": groups, "apps": apps, + "can_edit_main_email": is_allowed_to_edit_main_email, + "can_edit_email_alias": is_allowed_to_edit_mail_alias, + "can_edit_email_forward": is_allowed_to_edit_mail_forward, } # FIXME / TODO : add mail quota status ? @@ -173,6 +181,7 @@ def portal_me(): def portal_update( fullname: Union[str, None] = None, + mainemail: Union[str, None] = None, mailforward: Union[list[str], None] = None, mailalias: Union[list[str], None] = None, currentpassword: Union[str, None] = None, @@ -198,14 +207,47 @@ def portal_update( (firstname + " " + lastname).strip() ] + new_mails = current_user["mail"] + + if mainemail is not None: + is_allowed_to_edit_main_email = settings_get("security.portal.portal_allow_edit_email") + if is_allowed_to_edit_main_email != 1: + raise YunohostValidationError("mail_edit_operation_unauthorized") + + if mainemail not in new_mails: + local_part, domain = mainemail.split("@") + if local_part in ADMIN_ALIASES: + raise YunohostValidationError("mail_unavailable") + + try: + _get_ldap_interface().validate_uniqueness({"mail": mainemail}) + except YunohostError: + raise YunohostValidationError( + "mail_already_exists", mail=mainemail + ) + + if domain not in domains or not user_is_allowed_on_domain(username, domain): + raise YunohostValidationError("mail_alias_unauthorized", domain=domain) + new_mails[0] = mainemail + else: + # email already exist in the list we just move it on the first place + new_mails.remove(mainemail) + new_mails.insert(0, mainemail) + + new_attr_dict["mail"] = new_mails + if mailalias is not None: + is_allowed_to_edit_mail_alias = settings_get("security.portal.portal_allow_edit_email_alias") + if is_allowed_to_edit_mail_alias != 1: + raise YunohostValidationError("mail_edit_operation_unauthorized") + mailalias = [mail.strip() for mail in mailalias if mail and mail.strip()] # keep first current mail unaltered - mails = [current_user["mail"][0]] + mails = [new_mails[0]] for index, mail in enumerate(mailalias): - if mail in current_user["mail"]: - if mail != current_user["mail"][0] and mail not in mails: + if mail in new_mails: + if mail != new_mails[0] and mail not in mails: mails.append(mail) continue # already in mails, skip validation @@ -230,6 +272,10 @@ def portal_update( new_attr_dict["mail"] = mails if mailforward is not None: + is_allowed_to_edit_mail_forward = settings_get("security.portal.portal_allow_edit_email_forward") + if is_allowed_to_edit_mail_forward != 1: + raise YunohostValidationError("mail_edit_operation_unauthorized") + new_attr_dict["maildrop"] = [current_user["maildrop"][0]] + [ mail.strip() for mail in mailforward From 33e8780abee151a33183d9754550606a3dd59fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josu=C3=A9=20Tille?= Date: Tue, 19 Nov 2024 23:56:00 +0100 Subject: [PATCH 2/2] Use config file from portal directory to fix permissions issues --- src/portal.py | 22 +++++++++++----------- src/settings.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/portal.py b/src/portal.py index 4a50c482e4..0a80303cd2 100644 --- a/src/portal.py +++ b/src/portal.py @@ -35,7 +35,6 @@ assert_password_is_strong_enough, _hash_user_password, ) -from yunohost.settings import settings_get logger = logging.getLogger("portal") @@ -79,15 +78,22 @@ def _get_portal_settings( "portal_title": "YunoHost", "show_other_domains_apps": False, "domain": domain, + "allow_edit_email": False, + "allow_edit_email_alias": False, + "allow_edit_email_forward": False, } portal_settings_path = Path(f"{PORTAL_SETTINGS_DIR}/{domain}.json") + global_portal_settings_path = Path(f"{PORTAL_SETTINGS_DIR}/global_settings.json") if portal_settings_path.exists(): settings.update(read_json(str(portal_settings_path))) # Portal may be public (no login required) settings["public"] = bool(settings.pop("enable_public_apps_page", False)) + if global_portal_settings_path.exists(): + settings.update(read_json(str(global_portal_settings_path))) + # First clear apps since it may contains private apps apps: dict[str, Any] = settings.pop("apps", {}) settings["apps"] = {} @@ -151,10 +157,6 @@ def portal_me(): for infos in apps.values(): del infos["users"] - is_allowed_to_edit_main_email = settings_get("security.portal.portal_allow_edit_email") - is_allowed_to_edit_mail_alias = settings_get("security.portal.portal_allow_edit_email_alias") - is_allowed_to_edit_mail_forward = settings_get("security.portal.portal_allow_edit_email_forward") - result_dict = { "username": username, "fullname": user["cn"][0], @@ -163,9 +165,6 @@ def portal_me(): "mailforward": user["maildrop"][1:], "groups": groups, "apps": apps, - "can_edit_main_email": is_allowed_to_edit_main_email, - "can_edit_email_alias": is_allowed_to_edit_mail_alias, - "can_edit_email_forward": is_allowed_to_edit_mail_forward, } # FIXME / TODO : add mail quota status ? @@ -194,6 +193,7 @@ def portal_update( ["givenName", "sn", "cn", "mail", "maildrop", "memberOf"] ) new_attr_dict = {} + portal_settings = _get_portal_settings(domain, username) if fullname is not None and fullname != current_user["cn"]: fullname = fullname.strip() @@ -210,7 +210,7 @@ def portal_update( new_mails = current_user["mail"] if mainemail is not None: - is_allowed_to_edit_main_email = settings_get("security.portal.portal_allow_edit_email") + is_allowed_to_edit_main_email = portal_settings["allow_edit_email"] if is_allowed_to_edit_main_email != 1: raise YunohostValidationError("mail_edit_operation_unauthorized") @@ -237,7 +237,7 @@ def portal_update( new_attr_dict["mail"] = new_mails if mailalias is not None: - is_allowed_to_edit_mail_alias = settings_get("security.portal.portal_allow_edit_email_alias") + is_allowed_to_edit_mail_alias = portal_settings["allow_edit_email_alias"] if is_allowed_to_edit_mail_alias != 1: raise YunohostValidationError("mail_edit_operation_unauthorized") @@ -272,7 +272,7 @@ def portal_update( new_attr_dict["mail"] = mails if mailforward is not None: - is_allowed_to_edit_mail_forward = settings_get("security.portal.portal_allow_edit_email_forward") + is_allowed_to_edit_mail_forward = portal_settings["allow_edit_email_forward"] if is_allowed_to_edit_mail_forward != 1: raise YunohostValidationError("mail_edit_operation_unauthorized") diff --git a/src/settings.py b/src/settings.py index 1027a55a0b..402ddeeb47 100644 --- a/src/settings.py +++ b/src/settings.py @@ -19,15 +19,18 @@ import os import subprocess from logging import getLogger +from pathlib import Path from typing import TYPE_CHECKING, Any, Union from moulinette import m18n +from moulinette.utils.filesystem import write_to_json from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.configpanel import ConfigPanel, parse_filter_key from yunohost.utils.form import BaseOption from yunohost.regenconf import regen_conf from yunohost.firewall import firewall_reload from yunohost.log import is_unit_operation +from yunohost.portal import PORTAL_SETTINGS_DIR if TYPE_CHECKING: from typing import cast @@ -246,6 +249,17 @@ def _apply( {"sudoOption": ["!authenticate"] if passwordless_sudo else []}, ) + global_portal_settings_path = Path(f"{PORTAL_SETTINGS_DIR}/global_settings.json") + portal_allow_edit_email = form.get("portal_allow_edit_email", self.get("security.portal.portal_allow_edit_email")) + portal_allow_edit_email_alias = form.get("portal_allow_edit_email_alias", self.get("security.portal.portal_allow_edit_email_alias")) + portal_allow_edit_email_forward = form.get("portal_allow_edit_email_forward", self.get("security.portal.portal_allow_edit_email_forward")) + write_to_json( + str(global_portal_settings_path), {"allow_edit_email": portal_allow_edit_email, + "allow_edit_email_alias": portal_allow_edit_email_alias, + "allow_edit_email_forward":portal_allow_edit_email_forward + }, sort_keys=True, indent=4 + ) + # First save settings except virtual + default ones super()._apply(form, config, previous_settings, exclude=self.virtual_settings) next_settings = {