From d0f1d9201ccc94cead99e460236805feffc77d84 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Thu, 21 Dec 2023 18:36:15 +0100 Subject: [PATCH] auth/portal/acl : add 'user is allowed for domain X' mechanism, such that users can't log in or add mail aliases for a domain they aint allowed to access. The fact that they are able to access a domain is derived from the fact that they have access to at least one app on that domain (actually .. we may want to bypass this check for admins, otherwise this is gonna be hella confusing for fresh intalls). --- locales/en.json | 1 + src/app.py | 9 +++++++- src/authenticators/ldap_ynhuser.py | 37 ++++++++++++++++++++++++++++++ src/portal.py | 6 ++--- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/locales/en.json b/locales/en.json index 4cb24a12c..fd80a5045 100644 --- a/locales/en.json +++ b/locales/en.json @@ -572,6 +572,7 @@ "log_user_permission_update": "Update accesses for permission '{}'", "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_already_exists": "Mail adress '{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/src/app.py b/src/app.py index f211a801a..c7c0f9d83 100644 --- a/src/app.py +++ b/src/app.py @@ -1723,7 +1723,7 @@ def app_ssowatconf(): for domain, apps in portal_domains_apps.items(): portal_settings = {} - portal_settings_path = Path(f"{PORTAL_SETTINGS_DIR}/{domain}.json") + portal_settings_path = Path(PORTAL_SETTINGS_DIR) / f"{domain}.json" if portal_settings_path.exists(): portal_settings.update(read_json(str(portal_settings_path))) @@ -1735,6 +1735,13 @@ def app_ssowatconf(): str(portal_settings_path), portal_settings, sort_keys=True, indent=4 ) + # Cleanup old files from possibly old domains + for setting_file in Path(PORTAL_SETTINGS_DIR).iterdir(): + if setting_file.name.endswith(".json"): + domain = setting_file.name[:-len(".json")] + if domain not in portal_domains_apps: + setting_file.unlink() + logger.debug(m18n.n("ssowat_conf_generated")) diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 789239bb1..1f696b588 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -18,6 +18,7 @@ from cryptography.hazmat.backends import default_backend from moulinette import m18n from moulinette.authentication import BaseAuthenticator from moulinette.utils.text import random_ascii +from moulinette.utils.filesystem import read_json from yunohost.utils.error import YunohostError, YunohostAuthenticationError logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") @@ -29,6 +30,34 @@ SESSION_VALIDITY = 3 * 24 * 3600 # 3 days URI = "ldap://localhost:389" USERDN = "uid={username},ou=users,dc=yunohost,dc=org" +DOMAIN_USER_ACL_DICT: dict[str, dict] = {} +PORTAL_SETTINGS_DIR = "/etc/yunohost/portal" + + +def user_is_allowed_on_domain(user: str, domain: str) -> bool: + + assert "/" not in domain + + portal_settings_path = Path(PORTAL_SETTINGS_DIR) / f"{domain}.json" + + if not portal_settings_path.exists(): + if "." not in domain: + return False + else: + parent_domain = domain.split(".", 1)[-1] + return user_is_allowed_on_domain(user, domain) + + ctime = portal_settings_path.stat().st_ctime + if domain not in DOMAIN_USER_ACL_DICT or DOMAIN_USER_ACL_DICT[domain]["ctime"] < time.time(): + users = set() + for infos in read_json(str(portal_settings_path))["apps"].values(): + users = users.union(infos["users"]) + DOMAIN_USER_ACL_DICT[domain] = {} + DOMAIN_USER_ACL_DICT[domain]["ctime"] = ctime + DOMAIN_USER_ACL_DICT[domain]["users"] = users + + return user in DOMAIN_USER_ACL_DICT[domain]["users"] + # We want to save the password in the cookie, but we should do so in an encrypted fashion # This is needed because the SSO later needs to possibly inject the Basic Auth header @@ -77,6 +106,8 @@ class Authenticator(BaseAuthenticator): name = "ldap_ynhuser" def _authenticate_credentials(self, credentials=None): + from bottle import request + try: username, password = credentials.split(":", 1) except ValueError: @@ -113,6 +144,9 @@ class Authenticator(BaseAuthenticator): if con: con.unbind_s() + if not user_is_allowed_on_domain(username, request.get_header("host")): + raise YunohostAuthenticationError("unable_authenticate") + return {"user": username, "pwd": encrypt(password)} def set_session_cookie(self, infos): @@ -165,6 +199,9 @@ class Authenticator(BaseAuthenticator): if infos["host"] != request.get_header("host"): raise YunohostAuthenticationError("unable_authenticate") + if not user_is_allowed_on_domain(infos["user"], infos["host"]): + raise YunohostAuthenticationError("unable_authenticate") + self.purge_expired_session_files() session_file = Path(SESSION_FOLDER) / infos["id"] if not session_file.exists(): diff --git a/src/portal.py b/src/portal.py index aabf577f0..14278894c 100644 --- a/src/portal.py +++ b/src/portal.py @@ -24,7 +24,7 @@ from typing import Any, Union import ldap from moulinette.utils.filesystem import read_json -from yunohost.authenticators.ldap_ynhuser import URI, USERDN, Authenticator as Auth +from yunohost.authenticators.ldap_ynhuser import URI, USERDN, Authenticator as Auth, user_is_allowed_on_domain from yunohost.user import _hash_user_password from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.ldap import LDAPInterface, _ldap_path_extract @@ -204,9 +204,9 @@ def portal_update( "mail_already_exists", mail=mail, path=f"mailalias[{index}]" ) - if domain not in domains: + if domain not in domains or not user_is_allowed_on_domain(username, domain): raise YunohostValidationError( - "mail_domain_unknown", domain=domain, path=f"mailalias[{index}]" + "mail_alias_unauthorized", domain=domain ) mails.append(mail)