From 2845914d44562aa39956386dedde23e5e10316cf Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 4 Dec 2021 03:27:23 +0100 Subject: [PATCH 01/36] WIP: foundation for a new portal API to partially replace SSOwat --- bin/yunohost-portal-api | 53 ++++++++++++++++++ share/actionsmap-portal.yml | 51 +++++++++++++++++ src/__init__.py | 20 ++++++- src/authenticators/ldap_ynhuser.py | 59 ++++++++++++++++++++ src/portal.py | 89 ++++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+), 1 deletion(-) create mode 100755 bin/yunohost-portal-api create mode 100644 share/actionsmap-portal.yml create mode 100644 src/authenticators/ldap_ynhuser.py create mode 100644 src/portal.py diff --git a/bin/yunohost-portal-api b/bin/yunohost-portal-api new file mode 100755 index 000000000..66751e66f --- /dev/null +++ b/bin/yunohost-portal-api @@ -0,0 +1,53 @@ +#! /usr/bin/python3 +# -*- coding: utf-8 -*- + +import argparse +import yunohost + +# Default server configuration +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 6788 + + +def _parse_api_args(): + """Parse main arguments for the api""" + parser = argparse.ArgumentParser( + add_help=False, + description="Run the YunoHost API to manage your server.", + ) + srv_group = parser.add_argument_group("server configuration") + srv_group.add_argument( + "-h", + "--host", + action="store", + default=DEFAULT_HOST, + help="Host to listen on (default: %s)" % DEFAULT_HOST, + ) + srv_group.add_argument( + "-p", + "--port", + action="store", + default=DEFAULT_PORT, + type=int, + help="Port to listen on (default: %d)" % DEFAULT_PORT, + ) + glob_group = parser.add_argument_group("global arguments") + glob_group.add_argument( + "--debug", + action="store_true", + default=False, + help="Set log level to DEBUG", + ) + glob_group.add_argument( + "--help", + action="help", + help="Show this help message and exit", + ) + + return parser.parse_args() + + +if __name__ == "__main__": + opts = _parse_api_args() + # Run the server + yunohost.portalapi(debug=opts.debug, host=opts.host, port=opts.port) diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml new file mode 100644 index 000000000..3d07656ae --- /dev/null +++ b/share/actionsmap-portal.yml @@ -0,0 +1,51 @@ +_global: + namespace: yunohost + cookie_name: yunohost.portal + authentication: + api: ldap_ynhuser + cli: null + +portal: + category_help: Portal routes + actions: + + ### portal_me() + me: + action_help: Allow user to fetch their own infos + api: GET /me + + ### portal_apps() + apps: + action_help: Allow users to fetch lit of apps they have access to + api: GET /me/apps + + ### portal_update() + update: + action_help: Allow user to update their infos (display name, mail aliases/forward, password, ...) + api: PUT /me + # FIXME: add args etc + + ### portal_reset_password() + reset_password: + action_help: Allow user to update their infos (display name, mail aliases/forward, ...) + api: PUT /me/reset_password + authentication: + # FIXME: to be implemented ? + api: reset_password_token + # FIXME: add args etc + + ### portal_register() + register: + action_help: Allow user to register using an invite token or ??? + api: POST /me + authentication: + # FIXME: to be implemented ? + api: register_invite_token + # FIXME: add args etc + + ### portal_public() + public: + action_help: Allow anybody to list public apps and other infos regarding the public portal + api: GET /public + authentication: + api: null diff --git a/src/__init__.py b/src/__init__.py index b9dcd93d9..aaeea7751 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -53,6 +53,20 @@ def api(debug, host, port): sys.exit(ret) +def portalapi(debug, host, port): + + # FIXME : is this the logdir we want ? (yolo to work around permission issue) + init_logging(interface="portalapi", debug=debug, logdir="/var/log") + + ret = moulinette.api( + host=host, + port=port, + actionsmap="/usr/share/yunohost/actionsmap-portal.yml", + locales_dir="/usr/share/yunohost/locales/" + ) + sys.exit(ret) + + def check_command_is_valid_before_postinstall(args): allowed_if_not_postinstalled = [ @@ -125,6 +139,10 @@ def init_logging(interface="cli", debug=False, quiet=False, logdir="/var/log/yun "level": "DEBUG" if debug else "INFO", "class": "moulinette.interfaces.api.APIQueueHandler", }, + "portalapi": { + "level": "DEBUG" if debug else "INFO", + "class": "moulinette.interfaces.api.APIQueueHandler", + }, "file": { "class": "logging.FileHandler", "formatter": "precise", @@ -151,7 +169,7 @@ def init_logging(interface="cli", debug=False, quiet=False, logdir="/var/log/yun } # Logging configuration for CLI (or any other interface than api...) # - if interface != "api": + if interface not in ["api", "portalapi"]: configure_logging(logging_configuration) # Logging configuration for API # diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py new file mode 100644 index 000000000..50dca3cc9 --- /dev/null +++ b/src/authenticators/ldap_ynhuser.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +import logging +import ldap +import ldap.sasl + +from moulinette import m18n +from moulinette.authentication import BaseAuthenticator +from yunohost.utils.error import YunohostError + +logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") + +URI = "ldap://localhost:389" +USERDN = "uid={username},ou=users,dc=yunohost,dc=org" + + +class Authenticator(BaseAuthenticator): + + name = "ldap_ynhuser" + + def _authenticate_credentials(self, credentials=None): + + # FIXME ':' should a legit char in the password ? shall we encode the password as base64 or something idk + if ":" not in credentials or len(credentials.split(":")) != 2: + raise YunohostError("invalid_credentials_format") + + username, password = credentials.split(":") + + def _reconnect(): + con = ldap.ldapobject.ReconnectLDAPObject( + URI, retry_max=2, retry_delay=0.5 + ) + con.simple_bind_s(USERDN.format(username=username), password) + return con + + try: + con = _reconnect() + except ldap.INVALID_CREDENTIALS: + raise YunohostError("invalid_password") + except ldap.SERVER_DOWN: + logger.warning(m18n.n("ldap_server_down")) + + # Check that we are indeed logged in with the expected identity + try: + # whoami_s return dn:..., then delete these 3 characters + who = con.whoami_s()[3:] + except Exception as e: + logger.warning("Error during ldap authentication process: %s", e) + raise + else: + if who != USERDN.format(username=username): + raise YunohostError( + "Not logged with the appropriate identity ?!", + raw_msg=True, + ) + finally: + # Free the connection, we don't really need it to keep it open as the point is only to check authentication... + if con: + con.unbind_s() diff --git a/src/portal.py b/src/portal.py new file mode 100644 index 000000000..4a2b449b2 --- /dev/null +++ b/src/portal.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2021 YUNOHOST.ORG + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + +""" + +# from moulinette import Moulinette, m18n +from moulinette.utils.log import getActionLogger + +from yunohost.utils.error import YunohostValidationError + +logger = getActionLogger("yunohostportal.user") + + +def me(): + """ + Get user informations + + Keyword argument: + username -- Username to get informations + + """ + + username = None # FIXME : this info should come from the authentication layer + + from yunohost.utils.ldap import _get_ldap_interface + + ldap = _get_ldap_interface() + + user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"] + + filter = "uid=" + username + result = ldap.search("ou=users,dc=yunohost,dc=org", filter, user_attrs) + + if result: + user = result[0] + else: + raise YunohostValidationError("user_unknown", user=username) + + result_dict = { + "username": user["uid"][0], + "fullname": user["cn"][0], + "firstname": user["givenName"][0], + "lastname": user["sn"][0], + "mail": user["mail"][0], + "mail-aliases": [], + "mail-forward": [], + } + + if len(user["mail"]) > 1: + result_dict["mail-aliases"] = user["mail"][1:] + + if len(user["maildrop"]) > 1: + result_dict["mail-forward"] = user["maildrop"][1:] + + if "mailuserquota" in user: + pass + # FIXME + # result_dict["mailbox-quota"] = { + # "limit": userquota if is_limited else m18n.n("unlimit"), + # "use": storage_use, + # } + + # FIXME : should also parse "permission" key in ldap maybe ? + # and list of groups / memberof ? + # (in particular to have e.g. the mail / xmpp / ssh / ... perms) + + return result_dict + + +def apps(username): + return {"foo": "bar"} + # FIXME: should list available apps and corresponding infos ? + # from /etc/ssowat/conf.json ? From 1efb50c7abc0141b0f6325ae155a653ed9027ff0 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 25 Dec 2021 15:44:14 +0100 Subject: [PATCH 02/36] Iterate on new portal API design: nginx config, cookie format, be able to open a non-root ldap session, --- conf/nginx/plain/yunohost_sso.conf.inc | 4 +- conf/nginx/yunohost_api.conf.inc | 20 +++++++ debian/control | 3 +- share/actionsmap-portal.yml | 3 +- src/authenticators/ldap_ynhuser.py | 83 +++++++++++++++++++++++++- src/portal.py | 12 ++-- src/utils/ldap.py | 38 ++++++++---- 7 files changed, 141 insertions(+), 22 deletions(-) diff --git a/conf/nginx/plain/yunohost_sso.conf.inc b/conf/nginx/plain/yunohost_sso.conf.inc index 308e5a9a4..984440679 100644 --- a/conf/nginx/plain/yunohost_sso.conf.inc +++ b/conf/nginx/plain/yunohost_sso.conf.inc @@ -2,6 +2,6 @@ rewrite ^/yunohost/sso$ /yunohost/sso/ permanent; location /yunohost/sso/ { - # This is an empty location, only meant to avoid other locations - # from matching /yunohost/sso, such that it's correctly handled by ssowat + alias /usr/share/ssowat/portal/; + index index.html; } diff --git a/conf/nginx/yunohost_api.conf.inc b/conf/nginx/yunohost_api.conf.inc index c9ae34f82..3a463c23b 100644 --- a/conf/nginx/yunohost_api.conf.inc +++ b/conf/nginx/yunohost_api.conf.inc @@ -23,3 +23,23 @@ location = /yunohost/api/error/502 { add_header Content-Type text/plain; internal; } + +location /yunohost/portalapi/ { + proxy_read_timeout 3600s; + proxy_pass http://127.0.0.1:6788/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + + # Custom 502 error page + error_page 502 /yunohost/portalapi/error/502; +} + + +# Yunohost admin output complete 502 error page, so use only plain text. +location = /yunohost/portalapi/error/502 { + return 502 '502 - Bad Gateway'; + add_header Content-Type text/plain; + internal; +} diff --git a/debian/control b/debian/control index 31204a180..8a9d841c5 100644 --- a/debian/control +++ b/debian/control @@ -14,7 +14,8 @@ Depends: ${python3:Depends}, ${misc:Depends} , python3-psutil, python3-requests, python3-dnspython, python3-openssl , python3-miniupnpc, python3-dbus, python3-jinja2 , python3-toml, python3-packaging, python3-publicsuffix2 - , python3-ldap, python3-zeroconf, python3-lexicon, + , python3-ldap, python3-zeroconf, python3-lexicon + , python3-jwt , python-is-python3 , nginx, nginx-extras (>=1.18) , apt, apt-transport-https, apt-utils, dirmngr diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml index 3d07656ae..761d5a6ce 100644 --- a/share/actionsmap-portal.yml +++ b/share/actionsmap-portal.yml @@ -1,9 +1,10 @@ _global: namespace: yunohost - cookie_name: yunohost.portal authentication: api: ldap_ynhuser cli: null + lock: false + cache: false portal: category_help: Portal routes diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 50dca3cc9..28b8c49fd 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -1,12 +1,17 @@ # -*- coding: utf-8 -*- +import jwt import logging import ldap import ldap.sasl +import datetime from moulinette import m18n from moulinette.authentication import BaseAuthenticator -from yunohost.utils.error import YunohostError +from moulinette.utils.text import random_ascii +from yunohost.utils.error import YunohostError, YunohostAuthenticationError + +session_secret = random_ascii() logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") @@ -57,3 +62,79 @@ class Authenticator(BaseAuthenticator): # Free the connection, we don't really need it to keep it open as the point is only to check authentication... if con: con.unbind_s() + + + + + + + + # FIXME FIXME FIXME : the password is to be encrypted to not expose it in the JWT cookie which is only signed and base64 encoded but not encrypted + + + + + + + + + + return {"user": username, "password": password} + + def set_session_cookie(self, infos): + + from bottle import response + + assert isinstance(infos, dict) + + # This allows to generate a new session id or keep the existing one + current_infos = self.get_session_cookie(raise_if_no_session_exists=False) + new_infos = { + "id": current_infos["id"], + # See https://pyjwt.readthedocs.io/en/latest/usage.html#registered-claim-names + # for explanations regarding nbf, exp + "nbf": int(datetime.datetime.now().timestamp()), + "exp": int(datetime.datetime.now().timestamp()) + (7 * 24 * 3600) # One week validity + } + new_infos.update(infos) + + response.set_cookie( + "yunohost.portal", + jwt.encode(new_infos, session_secret, algorithm="HS256").decode(), + secure=True, + httponly=True, + path="/", + # samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions + # FIXME : add Expire clause + ) + + def get_session_cookie(self, raise_if_no_session_exists=True): + + from bottle import request + + try: + token = request.get_cookie("yunohost.portal", default="").encode() + infos = jwt.decode(token, session_secret, algorithms="HS256", options={"require": ["id", "user", "exp", "nbf"]}) + except Exception: + if not raise_if_no_session_exists: + return {"id": random_ascii()} + raise YunohostAuthenticationError("unable_authenticate") + + if not infos and raise_if_no_session_exists: + raise YunohostAuthenticationError("unable_authenticate") + + if "id" not in infos: + infos["id"] = random_ascii() + + # FIXME: Here, maybe we want to re-authenticate the session via the authenticator + # For example to check that the username authenticated is still in the admin group... + + return infos + + @staticmethod + def delete_session_cookie(self): + + from bottle import response + + response.set_cookie("yunohost.portal", "", max_age=-1) + response.delete_cookie("yunohost.portal") diff --git a/src/portal.py b/src/portal.py index 4a2b449b2..2eaa59dd4 100644 --- a/src/portal.py +++ b/src/portal.py @@ -22,12 +22,14 @@ # from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger +from yunohost.authenticators.ldap_ynhuser import Authenticator as Auth +from yunohost.utils.ldap import LDAPInterface from yunohost.utils.error import YunohostValidationError logger = getActionLogger("yunohostportal.user") -def me(): +def portal_me(): """ Get user informations @@ -36,11 +38,13 @@ def me(): """ - username = None # FIXME : this info should come from the authentication layer + import pdb; pdb.set_trace() - from yunohost.utils.ldap import _get_ldap_interface + auth = Auth().get_session_cookie() + username = auth["user"] + password = auth["password"] - ldap = _get_ldap_interface() + ldap = LDAPInterface(username, password) user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"] diff --git a/src/utils/ldap.py b/src/utils/ldap.py index 651d09f75..852fa89c2 100644 --- a/src/utils/ldap.py +++ b/src/utils/ldap.py @@ -42,7 +42,7 @@ def _get_ldap_interface(): global _ldap_interface if _ldap_interface is None: - _ldap_interface = LDAPInterface() + _ldap_interface = LDAPInterface(user="root") return _ldap_interface @@ -71,22 +71,34 @@ def _destroy_ldap_interface(): atexit.register(_destroy_ldap_interface) +URI = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi" +BASEDN = "dc=yunohost,dc=org" +ROOTDN = "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" +USERDN = "uid={username},ou=users,dc=yunohost,dc=org" + class LDAPInterface: - def __init__(self): - logger.debug("initializing ldap interface") - self.uri = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi" - self.basedn = "dc=yunohost,dc=org" - self.rootdn = "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" + def __init__(self, user="root", password=None): + + if user == "root": + logger.debug("initializing root ldap interface") + self.userdn = ROOTDN + self._connect = lambda con: con.sasl_non_interactive_bind_s("EXTERNAL") + else: + logger.debug("initializing user ldap interface") + self.userdn = USERDN.format(username=user) + self._connect = lambda con: con.simple_bind_s(self.userdn, password) + self.connect() def connect(self): + def _reconnect(): con = ldap.ldapobject.ReconnectLDAPObject( - self.uri, retry_max=10, retry_delay=0.5 + URI, retry_max=10, retry_delay=0.5 ) - con.sasl_non_interactive_bind_s("EXTERNAL") + self._connect(con) return con try: @@ -113,7 +125,7 @@ class LDAPInterface: logger.warning("Error during ldap authentication process: %s", e) raise else: - if who != self.rootdn: + if who != self.userdn: raise MoulinetteError("Not logged in with the expected userdn ?!") else: self.con = con @@ -139,7 +151,7 @@ class LDAPInterface: """ if not base: - base = self.basedn + base = BASEDN try: result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs) @@ -184,7 +196,7 @@ class LDAPInterface: Boolean | MoulinetteError """ - dn = rdn + "," + self.basedn + dn = f"{rdn},{BASEDN}" ldif = modlist.addModlist(attr_dict) for i, (k, v) in enumerate(ldif): if isinstance(v, list): @@ -215,7 +227,7 @@ class LDAPInterface: Boolean | MoulinetteError """ - dn = rdn + "," + self.basedn + dn = f"{rdn},{BASEDN}" try: self.con.delete_s(dn) except Exception as e: @@ -240,7 +252,7 @@ class LDAPInterface: Boolean | MoulinetteError """ - dn = rdn + "," + self.basedn + dn = f"{rdn},{BASEDN}" actual_entry = self.search(base=dn, attrs=None) ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1) From 62808152eecf4fd5da659e1785b64460e9775a7f Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sun, 26 Dec 2021 16:31:05 +0100 Subject: [PATCH 03/36] Cookie handling for the new portal API --- src/app.py | 3 +++ src/authenticators/ldap_ynhuser.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app.py b/src/app.py index 27cf7caec..9db7a4e4e 100644 --- a/src/app.py +++ b/src/app.py @@ -1323,6 +1323,7 @@ def app_ssowatconf(): "public": True, "uris": [domain + "/yunohost/admin" for domain in domains] + [domain + "/yunohost/api" for domain in domains] + + [domain + "/yunohost/portalapi" for domain in domains] + [ "re:^[^/]*/%.well%-known/ynh%-diagnosis/.*$", "re:^[^/]*/%.well%-known/acme%-challenge/.*$", @@ -1368,6 +1369,8 @@ def app_ssowatconf(): } conf_dict = { + "cookie_secret_file": "/etc/yunohost/.ssowat_cookie_secret", + "cookie_name": "yunohost.portal", "portal_domain": main_domain, "portal_path": "/yunohost/sso/", "additional_headers": { diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 28b8c49fd..0e51d1925 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -11,7 +11,9 @@ from moulinette.authentication import BaseAuthenticator from moulinette.utils.text import random_ascii from yunohost.utils.error import YunohostError, YunohostAuthenticationError -session_secret = random_ascii() +# FIXME : we shall generate this somewhere if it doesnt exists yet +# FIXME : fix permissions +session_secret = open("/etc/yunohost/.ssowat_cookie_secret").read() logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") From 45baaead3689703b257c8ce16aee763a3a67c9ba Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sun, 26 Dec 2021 18:22:33 +0100 Subject: [PATCH 04/36] Fix typo + unused import --- src/authenticators/ldap_admin.py | 1 - src/authenticators/ldap_ynhuser.py | 1 - src/tools.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/authenticators/ldap_admin.py b/src/authenticators/ldap_admin.py index 7f96165cb..872dd3c8d 100644 --- a/src/authenticators/ldap_admin.py +++ b/src/authenticators/ldap_admin.py @@ -120,7 +120,6 @@ class Authenticator(BaseAuthenticator): return infos - @staticmethod def delete_session_cookie(self): from bottle import response diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 0e51d1925..fe2a657f5 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -133,7 +133,6 @@ class Authenticator(BaseAuthenticator): return infos - @staticmethod def delete_session_cookie(self): from bottle import response diff --git a/src/tools.py b/src/tools.py index b66d20811..467e33cfa 100644 --- a/src/tools.py +++ b/src/tools.py @@ -33,7 +33,7 @@ from typing import List from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger -from moulinette.utils.process import check_output, call_async_output +from moulinette.utils.process import call_async_output from moulinette.utils.filesystem import read_yaml, write_to_yaml, cp, mkdir, rm from yunohost.app import ( From bd564e6a536f0b0b54c09fdd687a23f76372a59a Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Mon, 27 Dec 2021 12:44:20 +0100 Subject: [PATCH 05/36] Add systemd conf for new service yunohost-portal-api --- conf/yunohost/yunohost-portal-api.service | 14 ++++++++++++++ debian/postinst | 2 ++ hooks/conf_regen/01-yunohost | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+) create mode 100644 conf/yunohost/yunohost-portal-api.service diff --git a/conf/yunohost/yunohost-portal-api.service b/conf/yunohost/yunohost-portal-api.service new file mode 100644 index 000000000..0ba6e8b3d --- /dev/null +++ b/conf/yunohost/yunohost-portal-api.service @@ -0,0 +1,14 @@ +[Unit] +Description=YunoHost Portal API +After=network.target + +[Service] +User=ynh-portal +Type=simple +ExecStart=/usr/bin/yunohost-portal-api +Restart=always +RestartSec=5 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/debian/postinst b/debian/postinst index e93845e88..c62926a30 100644 --- a/debian/postinst +++ b/debian/postinst @@ -29,6 +29,8 @@ do_configure() { yunohost diagnosis run --force fi + systemctl restart yunohost-portal-api + # Trick to let yunohost handle the restart of the API, # to prevent the webadmin from cutting the branch it's sitting on if systemctl is-enabled yunohost-api --quiet diff --git a/hooks/conf_regen/01-yunohost b/hooks/conf_regen/01-yunohost index 14840e2f1..597595231 100755 --- a/hooks/conf_regen/01-yunohost +++ b/hooks/conf_regen/01-yunohost @@ -56,7 +56,10 @@ do_init_regen() { chown root:root /var/cache/yunohost chmod 700 /var/cache/yunohost + getent passwd ynh-portal &>/dev/null || useradd --no-create-home --shell /usr/sbin/nologin --system --user-group ynh-portal + cp yunohost-api.service /etc/systemd/system/yunohost-api.service + cp yunohost-portal-api.service /etc/systemd/system/yunohost-portal-api.service cp yunohost-firewall.service /etc/systemd/system/yunohost-firewall.service cp yunoprompt.service /etc/systemd/system/yunoprompt.service @@ -64,6 +67,10 @@ do_init_regen() { systemctl enable yunohost-api.service systemctl start yunohost-api.service + + systemctl enable yunohost-portal-api.service + systemctl start yunohost-portal-api.service + # Yunohost-firewall is enabled only during postinstall, not init, not 100% sure why cp dpkg-origins /etc/dpkg/origins/yunohost @@ -152,6 +159,7 @@ HandleLidSwitchExternalPower=ignore EOF cp yunohost-api.service ${pending_dir}/etc/systemd/system/yunohost-api.service + cp yunohost-portal-api.service ${pending_dir}/etc/systemd/system/yunohost-portal-api.service cp yunohost-firewall.service ${pending_dir}/etc/systemd/system/yunohost-firewall.service cp yunoprompt.service ${pending_dir}/etc/systemd/system/yunoprompt.service @@ -169,6 +177,13 @@ EOF do_post_regen() { regen_conf_files=$1 + getent passwd ynh-portal &>/dev/null || useradd --no-create-home --shell /usr/sbin/nologin --system --user-group ynh-portal + if [ ! -e /etc/yunohost/.ssowat_cookie_secret ]; then + dd if=/dev/urandom bs=1 count=1000 2>/dev/null | tr --complement --delete 'A-Za-z0-9' | head -c 64 > /etc/yunohost/.ssowat_cookie_secret + fi + chown ynh-portal:root /etc/yunohost/.ssowat_cookie_secret + chmod 400 /etc/yunohost/.ssowat_cookie_secret + ###################### # Enfore permissions # ###################### @@ -225,10 +240,12 @@ do_post_regen() { systemctl daemon-reload systemctl restart ntp } + [[ ! "$regen_conf_files" =~ "nftables.service.d/ynh-override.conf" ]] || systemctl daemon-reload [[ ! "$regen_conf_files" =~ "login.conf.d/ynh-override.conf" ]] || systemctl daemon-reload [[ ! "$regen_conf_files" =~ "yunohost-firewall.service" ]] || systemctl daemon-reload [[ ! "$regen_conf_files" =~ "yunohost-api.service" ]] || systemctl daemon-reload + [[ ! "$regen_conf_files" =~ "yunohost-portal-api.service" ]] || systemctl daemon-reload if [[ "$regen_conf_files" =~ "yunoprompt.service" ]]; then systemctl daemon-reload @@ -241,6 +258,9 @@ do_post_regen() { systemctl $action proc-hidepid --quiet --now fi + systemctl enable yunohost-portal-api.service --quiet + systemctl is-active yunohost-portal-api --quiet || systemctl start yunohost-portal-api.service + # Change dpkg vendor # see https://wiki.debian.org/Derivatives/Guidelines#Vendor if readlink -f /etc/dpkg/origins/default | grep -q debian; From 76eba6fc88814c1900cb94a3477eb3fea9a93801 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Mon, 27 Dec 2021 13:05:11 +0100 Subject: [PATCH 06/36] Fix log permission issue for yunohost-portal-api --- hooks/conf_regen/01-yunohost | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hooks/conf_regen/01-yunohost b/hooks/conf_regen/01-yunohost index 597595231..4dfd59912 100755 --- a/hooks/conf_regen/01-yunohost +++ b/hooks/conf_regen/01-yunohost @@ -184,6 +184,10 @@ do_post_regen() { chown ynh-portal:root /etc/yunohost/.ssowat_cookie_secret chmod 400 /etc/yunohost/.ssowat_cookie_secret + touch /var/log/yunohost-portalapi.log + chown ynh-portal:root /var/log/yunohost-portalapi.log + chmod 600 /var/log/yunohost-portalapi.log + ###################### # Enfore permissions # ###################### From 9a5080ea16b36e465587d4f0c9b2d531e9dfc6ce Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Tue, 11 Jul 2023 17:49:25 +0200 Subject: [PATCH 07/36] portalapi: fix split or user/password in auth code --- src/authenticators/ldap_ynhuser.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index fe2a657f5..c8ba4ecf2 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -27,11 +27,10 @@ class Authenticator(BaseAuthenticator): def _authenticate_credentials(self, credentials=None): - # FIXME ':' should a legit char in the password ? shall we encode the password as base64 or something idk - if ":" not in credentials or len(credentials.split(":")) != 2: - raise YunohostError("invalid_credentials_format") - - username, password = credentials.split(":") + try: + username, password = credentials.split(":", 1) + except ValueError: + raise YunohostError("invalid_credentials") def _reconnect(): con = ldap.ldapobject.ReconnectLDAPObject( From 6c6dd318fb8c5a31fe64fbeb78ed1d7304dd8a8c Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Tue, 11 Jul 2023 22:39:22 +0200 Subject: [PATCH 08/36] portalapi: implement encrypted password storage in the user's cookie using AES256 --- debian/control | 2 +- hooks/conf_regen/01-yunohost | 3 +- src/authenticators/ldap_ynhuser.py | 73 ++++++++++++++++++++++-------- src/portal.py | 13 ++---- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/debian/control b/debian/control index 8880867a2..df9a6d2bd 100644 --- a/debian/control +++ b/debian/control @@ -15,7 +15,7 @@ Depends: ${python3:Depends}, ${misc:Depends} , python3-miniupnpc, python3-dbus, python3-jinja2 , python3-toml, python3-packaging, python3-publicsuffix2 , python3-ldap, python3-zeroconf (>= 0.36), python3-lexicon, - , python3-jwt + , python3-cryptography, python3-jwt , python-is-python3 , nginx, nginx-extras (>=1.18) , apt, apt-transport-https, apt-utils, dirmngr diff --git a/hooks/conf_regen/01-yunohost b/hooks/conf_regen/01-yunohost index 7bd835f8f..4d53997a5 100755 --- a/hooks/conf_regen/01-yunohost +++ b/hooks/conf_regen/01-yunohost @@ -177,7 +177,8 @@ do_post_regen() { getent passwd ynh-portal &>/dev/null || useradd --no-create-home --shell /usr/sbin/nologin --system --user-group ynh-portal if [ ! -e /etc/yunohost/.ssowat_cookie_secret ]; then - dd if=/dev/urandom bs=1 count=1000 2>/dev/null | tr --complement --delete 'A-Za-z0-9' | head -c 64 > /etc/yunohost/.ssowat_cookie_secret + # NB: we need this to be exactly 32 char long, because it is later used as a key for AES256 + dd if=/dev/urandom bs=1 count=1000 2>/dev/null | tr --complement --delete 'A-Za-z0-9' | head -c 32 > /etc/yunohost/.ssowat_cookie_secret fi chown ynh-portal:root /etc/yunohost/.ssowat_cookie_secret chmod 400 /etc/yunohost/.ssowat_cookie_secret diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index c8ba4ecf2..407277acf 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -5,6 +5,13 @@ import logging import ldap import ldap.sasl import datetime +import base64 +import os + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.backends import default_backend + from moulinette import m18n from moulinette.authentication import BaseAuthenticator @@ -13,13 +20,52 @@ from yunohost.utils.error import YunohostError, YunohostAuthenticationError # FIXME : we shall generate this somewhere if it doesnt exists yet # FIXME : fix permissions -session_secret = open("/etc/yunohost/.ssowat_cookie_secret").read() +session_secret = open("/etc/yunohost/.ssowat_cookie_secret").read().strip() logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") URI = "ldap://localhost:389" USERDN = "uid={username},ou=users,dc=yunohost,dc=org" +# 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 +# which includes the user's password +# It's also needed because we need to be able to open LDAP sessions, authenticated as the user, +# which requires the user's password +# +# To do so, we use AES-256-CBC. As it's a block encryption algorithm, it requires an IV, +# which we need to keep around for decryption on SSOwat'side. +# +# session_secret is used as the encryption key, which implies it must be exactly 32-char long (256/8) +# +# The result is a string formatted as | +# For example: ctl8kk5GevYdaA5VZ2S88Q==|yTAzCx0Gd1+MCit4EQl9lA== +def encrypt(data): + + alg = algorithms.AES(session_secret.encode()) + iv = os.urandom(int(alg.block_size / 8)) + + E = Cipher(alg, modes.CBC(iv), default_backend()).encryptor() + p = padding.PKCS7(alg.block_size).padder() + data_padded = p.update(data.encode()) + p.finalize() + data_enc = E.update(data_padded) + E.finalize() + data_enc_b64 = base64.b64encode(data_enc).decode() + iv_b64 = base64.b64encode(iv).decode() + return data_enc_b64 + "|" + iv_b64 + +def decrypt(data_enc_and_iv_b64): + + data_enc_b64, iv_b64 = data_enc_and_iv_b64.split("|") + data_enc = base64.b64decode(data_enc_b64) + iv = base64.b64decode(iv_b64) + + alg = algorithms.AES(session_secret.encode()) + D = Cipher(alg, modes.CBC(iv), default_backend()).decryptor() + p = padding.PKCS7(alg.block_size).unpadder() + data_padded = D.update(data_enc) + data = p.update(data_padded) + p.finalize() + return data.decode() + class Authenticator(BaseAuthenticator): @@ -64,23 +110,7 @@ class Authenticator(BaseAuthenticator): if con: con.unbind_s() - - - - - - - # FIXME FIXME FIXME : the password is to be encrypted to not expose it in the JWT cookie which is only signed and base64 encoded but not encrypted - - - - - - - - - - return {"user": username, "password": password} + return {"user": username, "pwd": encrypt(password)} def set_session_cookie(self, infos): @@ -101,7 +131,7 @@ class Authenticator(BaseAuthenticator): response.set_cookie( "yunohost.portal", - jwt.encode(new_infos, session_secret, algorithm="HS256").decode(), + jwt.encode(new_infos, session_secret, algorithm="HS256"), secure=True, httponly=True, path="/", @@ -109,7 +139,7 @@ class Authenticator(BaseAuthenticator): # FIXME : add Expire clause ) - def get_session_cookie(self, raise_if_no_session_exists=True): + def get_session_cookie(self, raise_if_no_session_exists=True, decrypt_pwd=False): from bottle import request @@ -127,6 +157,9 @@ class Authenticator(BaseAuthenticator): if "id" not in infos: infos["id"] = random_ascii() + if decrypt_pwd: + infos["pwd"] = decrypt(infos["pwd"]) + # FIXME: Here, maybe we want to re-authenticate the session via the authenticator # For example to check that the username authenticated is still in the admin group... diff --git a/src/portal.py b/src/portal.py index 2eaa59dd4..6aa7cba6d 100644 --- a/src/portal.py +++ b/src/portal.py @@ -32,24 +32,17 @@ logger = getActionLogger("yunohostportal.user") def portal_me(): """ Get user informations - - Keyword argument: - username -- Username to get informations - """ - import pdb; pdb.set_trace() - - auth = Auth().get_session_cookie() + auth = Auth().get_session_cookie(decrypt_pwd=True) username = auth["user"] - password = auth["password"] - ldap = LDAPInterface(username, password) + ldap = LDAPInterface(username, auth["pwd"]) user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"] filter = "uid=" + username - result = ldap.search("ou=users,dc=yunohost,dc=org", filter, user_attrs) + result = ldap.search("ou=users", filter, user_attrs) if result: user = result[0] From 2c0f49cef3fbf044e89ea5b43933c06dbbf8d956 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 14 Jul 2023 04:44:03 +0200 Subject: [PATCH 09/36] portalapi: add groups and apps list in infos returned by GET /me --- src/portal.py | 65 ++++++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/src/portal.py b/src/portal.py index 6aa7cba6d..6a51f33d4 100644 --- a/src/portal.py +++ b/src/portal.py @@ -19,14 +19,14 @@ """ -# from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import read_json from yunohost.authenticators.ldap_ynhuser import Authenticator as Auth from yunohost.utils.ldap import LDAPInterface from yunohost.utils.error import YunohostValidationError -logger = getActionLogger("yunohostportal.user") +logger = getActionLogger("portal") def portal_me(): @@ -39,48 +39,43 @@ def portal_me(): ldap = LDAPInterface(username, auth["pwd"]) - user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"] + user_attrs = ["cn", "mail", "maildrop", "mailuserquota", "memberOf", "permission"] - filter = "uid=" + username - result = ldap.search("ou=users", filter, user_attrs) + result = ldap.search("ou=users", f"uid={username}", user_attrs) if result: user = result[0] else: raise YunohostValidationError("user_unknown", user=username) - result_dict = { - "username": user["uid"][0], - "fullname": user["cn"][0], - "firstname": user["givenName"][0], - "lastname": user["sn"][0], - "mail": user["mail"][0], - "mail-aliases": [], - "mail-forward": [], + groups = [g.replace("cn=", "").replace(",ou=groups,dc=yunohost,dc=org", "") for g in user["memberOf"]] + groups = [g for g in groups if g not in [username, "all_users"]] + + permissions = [p.replace("cn=", "").replace(",ou=permission,dc=yunohost,dc=org", "") for p in user["permission"]] + + ssowat_conf = read_json("/etc/ssowat/conf.json") + apps = { + perm.replace(".main", ""): {"label": infos["label"], "url": infos["uris"][0]} + for perm, infos in ssowat_conf["permissions"].items() + if perm in permissions and infos["show_tile"] and username in infos["users"] } - if len(user["mail"]) > 1: - result_dict["mail-aliases"] = user["mail"][1:] + result_dict = { + "username": username, + "fullname": user["cn"][0], + "mail": user["mail"][0], + "mail-aliases": user["mail"][1:], + "mail-forward": user["maildrop"][1:], + "groups": groups, + "apps": apps + } - if len(user["maildrop"]) > 1: - result_dict["mail-forward"] = user["maildrop"][1:] - - if "mailuserquota" in user: - pass - # FIXME - # result_dict["mailbox-quota"] = { - # "limit": userquota if is_limited else m18n.n("unlimit"), - # "use": storage_use, - # } - - # FIXME : should also parse "permission" key in ldap maybe ? - # and list of groups / memberof ? - # (in particular to have e.g. the mail / xmpp / ssh / ... perms) + # FIXME / TODO : add mail quota status ? + # result_dict["mailbox-quota"] = { + # "limit": userquota if is_limited else m18n.n("unlimit"), + # "use": storage_use, + # } + # Could use : doveadm -c /dev/null -f flow quota recalc -u johndoe + # But this requires to be in the mail group ... return result_dict - - -def apps(username): - return {"foo": "bar"} - # FIXME: should list available apps and corresponding infos ? - # from /etc/ssowat/conf.json ? From 5e1d69a2cb09c8a125f6a6cce76a54c42eed9338 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 14 Jul 2023 18:55:33 +0200 Subject: [PATCH 10/36] portalapi: harden systemd service configuration --- conf/yunohost/yunohost-portal-api.service | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/conf/yunohost/yunohost-portal-api.service b/conf/yunohost/yunohost-portal-api.service index 0ba6e8b3d..006af0080 100644 --- a/conf/yunohost/yunohost-portal-api.service +++ b/conf/yunohost/yunohost-portal-api.service @@ -4,11 +4,45 @@ After=network.target [Service] User=ynh-portal +Group=ynh-portal Type=simple ExecStart=/usr/bin/yunohost-portal-api Restart=always RestartSec=5 TimeoutStopSec=30 +# Sandboxing options to harden security +# Details for these options: https://www.freedesktop.org/software/systemd/man/systemd.exec.html +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictNamespaces=yes +RestrictRealtime=yes +DevicePolicy=closed +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +ProtectSystem=full +ProtectControlGroups=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +LockPersonality=yes +SystemCallArchitectures=native +SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap @cpu-emulation @privileged + +# Denying access to capabilities that should not be relevant +# Doc: https://man7.org/linux/man-pages/man7/capabilities.7.html +CapabilityBoundingSet=~CAP_RAWIO CAP_MKNOD +CapabilityBoundingSet=~CAP_AUDIT_CONTROL CAP_AUDIT_READ CAP_AUDIT_WRITE +CapabilityBoundingSet=~CAP_SYS_BOOT CAP_SYS_TIME CAP_SYS_MODULE CAP_SYS_PACCT +CapabilityBoundingSet=~CAP_LEASE CAP_LINUX_IMMUTABLE CAP_IPC_LOCK +CapabilityBoundingSet=~CAP_BLOCK_SUSPEND CAP_WAKE_ALARM +CapabilityBoundingSet=~CAP_SYS_TTY_CONFIG +CapabilityBoundingSet=~CAP_MAC_ADMIN CAP_MAC_OVERRIDE +CapabilityBoundingSet=~CAP_NET_ADMIN CAP_NET_BROADCAST CAP_NET_RAW +CapabilityBoundingSet=~CAP_SYS_ADMIN CAP_SYS_PTRACE CAP_SYSLOG + + [Install] WantedBy=multi-user.target From 5104c2a79f698bd359adc4d7c32ab9232c9c05b0 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 14 Jul 2023 19:11:32 +0200 Subject: [PATCH 11/36] portalapi: add CORS headers ... though gotta revisit this later, I don't know what I'm doing --- conf/nginx/yunohost_api.conf.inc | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/conf/nginx/yunohost_api.conf.inc b/conf/nginx/yunohost_api.conf.inc index b4567e0b8..a3a23f1a9 100644 --- a/conf/nginx/yunohost_api.conf.inc +++ b/conf/nginx/yunohost_api.conf.inc @@ -25,12 +25,25 @@ location = /yunohost/api/error/502 { } location /yunohost/portalapi/ { - proxy_read_timeout 3600s; + + # FIXME FIXME FIXME : we should think about what we really want here ... + more_set_headers "Access-Control-Allow-Origin: $http_origin"; + more_set_headers "Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, DELETE"; + more_set_headers "Access-Control-Allow-Headers: Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With"; + more_set_headers "Access-Control-Allow-Credentials: true"; + + if ($request_method = 'OPTIONS') { + more_set_headers "Content-Type: text/plain; charset=utf-8"; + more_set_headers "Content-Length: 0"; + return 204; + } + + proxy_read_timeout 5s; proxy_pass http://127.0.0.1:6788/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; - proxy_set_header Host $http_host; + proxy_set_header Host $http; # Custom 502 error page error_page 502 /yunohost/portalapi/error/502; From 0cb673c12504d386ec7341ff199440a216f8ffd4 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Fri, 14 Jul 2023 19:35:05 +0200 Subject: [PATCH 12/36] portalapi: woopsies --- conf/nginx/yunohost_api.conf.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/nginx/yunohost_api.conf.inc b/conf/nginx/yunohost_api.conf.inc index a3a23f1a9..8133624b3 100644 --- a/conf/nginx/yunohost_api.conf.inc +++ b/conf/nginx/yunohost_api.conf.inc @@ -43,7 +43,7 @@ location /yunohost/portalapi/ { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; - proxy_set_header Host $http; + proxy_set_header Host $host; # Custom 502 error page error_page 502 /yunohost/portalapi/error/502; From f4dfb560068b1f952de86653427ded5cbc1206bb Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 15 Jul 2023 16:01:03 +0200 Subject: [PATCH 13/36] portal refactoring: the 'yunohost tile' thingy won't work anymore, gotta discuss what we want to do exactly --- conf/nginx/plain/yunohost_panel.conf.inc | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/conf/nginx/plain/yunohost_panel.conf.inc b/conf/nginx/plain/yunohost_panel.conf.inc index 16a6e6b29..69ca48a62 100644 --- a/conf/nginx/plain/yunohost_panel.conf.inc +++ b/conf/nginx/plain/yunohost_panel.conf.inc @@ -1,8 +1,12 @@ -# Insert YunoHost button + portal overlay -sub_filter ''; -sub_filter_once on; -# Apply to other mime types than text/html -sub_filter_types application/xhtml+xml; -# Prevent YunoHost panel files from being blocked by specific app rules -location ~ (ynh_portal.js|ynh_overlay.css|ynh_userinfo.json|ynhtheme/custom_portal.js|ynhtheme/custom_overlay.css) { -} +# This is some old code that worked with the old portal +# We need to rethink wether we want to keep something similar, +# or drop the feature + +# # Insert YunoHost button + portal overlay +# sub_filter ''; +# sub_filter_once on; +# # Apply to other mime types than text/html +# sub_filter_types application/xhtml+xml; +# # Prevent YunoHost panel files from being blocked by specific app rules +# location ~ (ynh_portal.js|ynh_overlay.css|ynh_userinfo.json|ynhtheme/custom_portal.js|ynhtheme/custom_overlay.css) { +# } From ec96558c8126a60bf45e14583cff0ce428ae578d Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 15 Jul 2023 20:07:18 +0200 Subject: [PATCH 14/36] portalapi: add FIXMEs about auth layer --- src/authenticators/ldap_ynhuser.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 407277acf..e8cfaf108 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -88,6 +88,7 @@ class Authenticator(BaseAuthenticator): try: con = _reconnect() except ldap.INVALID_CREDENTIALS: + # FIXME FIXME FIXME : this should be properly logged and caught by Fail2ban ! ! ! ! ! ! ! raise YunohostError("invalid_password") except ldap.SERVER_DOWN: logger.warning(m18n.n("ldap_server_down")) @@ -125,7 +126,7 @@ class Authenticator(BaseAuthenticator): # See https://pyjwt.readthedocs.io/en/latest/usage.html#registered-claim-names # for explanations regarding nbf, exp "nbf": int(datetime.datetime.now().timestamp()), - "exp": int(datetime.datetime.now().timestamp()) + (7 * 24 * 3600) # One week validity + "exp": int(datetime.datetime.now().timestamp()) + (7 * 24 * 3600) # One week validity # FIXME : does it mean the session suddenly expires after a week ? Can we somehow auto-renew it at every usage or something ? } new_infos.update(infos) @@ -149,6 +150,7 @@ class Authenticator(BaseAuthenticator): except Exception: if not raise_if_no_session_exists: return {"id": random_ascii()} + # FIXME FIXME FIXME : we might also want this to be caught by fail2ban ? Idk ... raise YunohostAuthenticationError("unable_authenticate") if not infos and raise_if_no_session_exists: @@ -160,8 +162,9 @@ class Authenticator(BaseAuthenticator): if decrypt_pwd: infos["pwd"] = decrypt(infos["pwd"]) - # FIXME: Here, maybe we want to re-authenticate the session via the authenticator - # For example to check that the username authenticated is still in the admin group... + # FIXME : maybe check expiration here ? Or is it already done in jwt.decode ? + + # FIXME: also a valid cookie ain't everything ... i.e. maybe we should validate that the user still exists return infos From 4561f900df20897cffef8633e89e492f45551465 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 15 Jul 2023 21:20:15 +0200 Subject: [PATCH 15/36] portal refactoring: update ssowat conf format with a dict mapping domains to portal urls. For now, let's have one portal per main/parent domain (which is anyway imposed by cookie management unless we reintroduce complex cross-domain authentication...) --- src/app.py | 14 ++++---------- src/domain.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/app.py b/src/app.py index 069134798..31108fde5 100644 --- a/src/app.py +++ b/src/app.py @@ -1712,7 +1712,7 @@ def app_ssowatconf(): """ - from yunohost.domain import domain_list, _get_maindomain, domain_config_get + from yunohost.domain import domain_list, _get_maindomain, domain_config_get, _get_domain_portal_dict from yunohost.permission import user_permission_list from yunohost.settings import settings_get @@ -1740,6 +1740,8 @@ def app_ssowatconf(): ], } } + + # FIXME : what's the reason we do this only for the maindomain ? x_X redirected_regex = { main_domain + r"/yunohost[\/]?$": "https://" + main_domain + "/yunohost/sso/" } @@ -1808,17 +1810,9 @@ def app_ssowatconf(): "cookie_secret_file": "/etc/yunohost/.ssowat_cookie_secret", "cookie_name": "yunohost.portal", "theme": settings_get("misc.portal.portal_theme"), - "portal_domain": main_domain, - "portal_path": "/yunohost/sso/", - "additional_headers": { - "Auth-User": "uid", - "Remote-User": "uid", - "Name": "cn", - "Email": "mail", - }, - "domains": domains, "redirected_urls": redirected_urls, "redirected_regex": redirected_regex, + "domain_portal_urls": _get_domain_portal_dict(), "permissions": permissions, } diff --git a/src/domain.py b/src/domain.py index 4f96d08c4..ecb1cc5ea 100644 --- a/src/domain.py +++ b/src/domain.py @@ -99,6 +99,26 @@ def _get_domains(exclude_subdomains=False): return domain_list_cache +def _get_domain_portal_dict(): + + domains = _get_domains() + out = OrderedDict() + + for domain in domains: + + parent = None + + # Use the topest parent domain if any + for d in out.keys(): + if domain.endswith(f".{d}"): + parent = d + break + + out[domain] = f'{parent or domain}/yunohost/sso' + + return dict(out) + + def domain_list(exclude_subdomains=False, tree=False, features=[]): """ List domains From ae37b5fc248c600e9be6f1c404c3383a8a0e258d Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Mon, 17 Jul 2023 19:47:24 +0200 Subject: [PATCH 16/36] portalapi: Add new yunohost-portal-api to yunohost services --- conf/yunohost/services.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/conf/yunohost/services.yml b/conf/yunohost/services.yml index 45621876e..693793465 100644 --- a/conf/yunohost/services.yml +++ b/conf/yunohost/services.yml @@ -51,6 +51,9 @@ ssh: test_conf: sshd -t needs_exposed_ports: [22] category: admin +yunohost-portal-api: + log: /var/log/yunohost-portal-api.log + category: userportal yunohost-api: log: /var/log/yunohost/yunohost-api.log category: admin From 704e42a6af530c4816a50c9cb93655a14ed4cbe8 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 29 Jul 2023 19:13:00 +0200 Subject: [PATCH 17/36] portalapi: fix cookie not being deleted because maxage=-1 or something --- src/authenticators/ldap_ynhuser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index e8cfaf108..9702693ed 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -172,5 +172,5 @@ class Authenticator(BaseAuthenticator): from bottle import response - response.set_cookie("yunohost.portal", "", max_age=-1) + response.set_cookie("yunohost.portal", "") response.delete_cookie("yunohost.portal") From 09c5a4cfb91cdede55956bb2e3cf747a0a2c6e18 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 29 Jul 2023 19:15:30 +0200 Subject: [PATCH 18/36] admin and portalapi: propagate new configurable CORS mechanism from moulinette --- conf/nginx/yunohost_api.conf.inc | 12 ------------ src/__init__.py | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/conf/nginx/yunohost_api.conf.inc b/conf/nginx/yunohost_api.conf.inc index 8133624b3..9cb4ff00d 100644 --- a/conf/nginx/yunohost_api.conf.inc +++ b/conf/nginx/yunohost_api.conf.inc @@ -26,18 +26,6 @@ location = /yunohost/api/error/502 { location /yunohost/portalapi/ { - # FIXME FIXME FIXME : we should think about what we really want here ... - more_set_headers "Access-Control-Allow-Origin: $http_origin"; - more_set_headers "Access-Control-Allow-Methods: GET, HEAD, POST, OPTIONS, DELETE"; - more_set_headers "Access-Control-Allow-Headers: Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With"; - more_set_headers "Access-Control-Allow-Credentials: true"; - - if ($request_method = 'OPTIONS') { - more_set_headers "Content-Type: text/plain; charset=utf-8"; - more_set_headers "Content-Length: 0"; - return 204; - } - proxy_read_timeout 5s; proxy_pass http://127.0.0.1:6788/; proxy_http_version 1.1; diff --git a/src/__init__.py b/src/__init__.py index 146485d2d..99f3739bf 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -50,6 +50,13 @@ def cli(debug, quiet, output_as, timeout, args, parser): def api(debug, host, port): + + allowed_cors_origins = [] + allowed_cors_origins_file = "/etc/yunohost/.admin-api-allowed-cors-origins" + + if os.path.exists(allowed_cors_origins_file): + allowed_cors_origins = open(allowed_cors_origins_file).read().strip().split(",") + init_logging(interface="api", debug=debug) def is_installed_api(): @@ -64,12 +71,19 @@ def api(debug, host, port): actionsmap="/usr/share/yunohost/actionsmap.yml", locales_dir="/usr/share/yunohost/locales/", routes={("GET", "/installed"): is_installed_api}, + allowed_cors_origins=allowed_cors_origins, ) sys.exit(ret) def portalapi(debug, host, port): + allowed_cors_origins = [] + allowed_cors_origins_file = "/etc/yunohost/.portal-api-allowed-cors-origins" + + if os.path.exists(allowed_cors_origins_file): + allowed_cors_origins = open(allowed_cors_origins_file).read().strip().split(",") + # FIXME : is this the logdir we want ? (yolo to work around permission issue) init_logging(interface="portalapi", debug=debug, logdir="/var/log") @@ -77,7 +91,8 @@ def portalapi(debug, host, port): host=host, port=port, actionsmap="/usr/share/yunohost/actionsmap-portal.yml", - locales_dir="/usr/share/yunohost/locales/" + locales_dir="/usr/share/yunohost/locales/", + allowed_cors_origins=allowed_cors_origins, ) sys.exit(ret) From 5fd1850f19c020115298d7595fc0212a8fb0b9be Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sun, 30 Jul 2023 23:53:04 +0200 Subject: [PATCH 19/36] Add dependency to new yunohost-portal debian package --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index df9a6d2bd..121ac3f6f 100644 --- a/debian/control +++ b/debian/control @@ -10,7 +10,7 @@ Package: yunohost Essential: yes Architecture: all Depends: ${python3:Depends}, ${misc:Depends} - , moulinette (>= 11.1), ssowat (>= 11.1) + , moulinette (>= 11.1), ssowat (>= 11.1), yunohost-portal (>= 11.1) , python3-psutil, python3-requests, python3-dnspython, python3-openssl , python3-miniupnpc, python3-dbus, python3-jinja2 , python3-toml, python3-packaging, python3-publicsuffix2 From afd7b37ebcabfd53e6fbfe2dc64503aab25d5f1a Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sun, 30 Jul 2023 23:53:43 +0200 Subject: [PATCH 20/36] Tweak nginx portal conf to serve html/css/js/assets from /usr/share/yunohost/portal, similar to webadmin --- conf/nginx/plain/yunohost_sso.conf.inc | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/conf/nginx/plain/yunohost_sso.conf.inc b/conf/nginx/plain/yunohost_sso.conf.inc index 984440679..578a75e52 100644 --- a/conf/nginx/plain/yunohost_sso.conf.inc +++ b/conf/nginx/plain/yunohost_sso.conf.inc @@ -2,6 +2,16 @@ rewrite ^/yunohost/sso$ /yunohost/sso/ permanent; location /yunohost/sso/ { - alias /usr/share/ssowat/portal/; + alias /usr/share/yunohost/portal/; + default_type text/html; index index.html; + try_files $uri $uri/ /index.html; + + location = /yunohost/sso/index.html { + etag off; + expires off; + more_set_headers "Cache-Control: no-store, no-cache, must-revalidate"; + } + + more_set_headers "Content-Security-Policy: upgrade-insecure-requests; default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; img-src 'self' data:;"; } From ca6eb2cbaf8e65abc95c78e2223a24b36c9005e3 Mon Sep 17 00:00:00 2001 From: axolotle Date: Tue, 1 Aug 2023 15:15:52 +0200 Subject: [PATCH 21/36] lint --- src/portal.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/portal.py b/src/portal.py index 6a51f33d4..f102f1515 100644 --- a/src/portal.py +++ b/src/portal.py @@ -48,10 +48,16 @@ def portal_me(): else: raise YunohostValidationError("user_unknown", user=username) - groups = [g.replace("cn=", "").replace(",ou=groups,dc=yunohost,dc=org", "") for g in user["memberOf"]] + groups = [ + g.replace("cn=", "").replace(",ou=groups,dc=yunohost,dc=org", "") + for g in user["memberOf"] + ] groups = [g for g in groups if g not in [username, "all_users"]] - permissions = [p.replace("cn=", "").replace(",ou=permission,dc=yunohost,dc=org", "") for p in user["permission"]] + permissions = [ + p.replace("cn=", "").replace(",ou=permission,dc=yunohost,dc=org", "") + for p in user["permission"] + ] ssowat_conf = read_json("/etc/ssowat/conf.json") apps = { @@ -67,7 +73,7 @@ def portal_me(): "mail-aliases": user["mail"][1:], "mail-forward": user["maildrop"][1:], "groups": groups, - "apps": apps + "apps": apps, } # FIXME / TODO : add mail quota status ? From c3a4b7dabb9946bc6ed1d12aa7ba50fc85c255cb Mon Sep 17 00:00:00 2001 From: axolotle Date: Tue, 1 Aug 2023 15:18:48 +0200 Subject: [PATCH 22/36] add _get_user_infos helper --- src/portal.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/portal.py b/src/portal.py index f102f1515..7e67ff35d 100644 --- a/src/portal.py +++ b/src/portal.py @@ -29,24 +29,25 @@ from yunohost.utils.error import YunohostValidationError logger = getActionLogger("portal") +def _get_user_infos(user_attrs: list[str]): + auth = Auth().get_session_cookie(decrypt_pwd=True) + username = auth["user"] + ldap = LDAPInterface(username, auth["pwd"]) + result = ldap.search("ou=users", f"uid={username}", user_attrs) + if not result: + raise YunohostValidationError("user_unknown", user=username) + + return username, result[0], ldap + + def portal_me(): """ Get user informations """ - auth = Auth().get_session_cookie(decrypt_pwd=True) - username = auth["user"] - - ldap = LDAPInterface(username, auth["pwd"]) - - user_attrs = ["cn", "mail", "maildrop", "mailuserquota", "memberOf", "permission"] - - result = ldap.search("ou=users", f"uid={username}", user_attrs) - - if result: - user = result[0] - else: - raise YunohostValidationError("user_unknown", user=username) + username, user, ldap = _get_user_infos( + ["cn", "mail", "maildrop", "mailuserquota", "memberOf", "permission"] + ) groups = [ g.replace("cn=", "").replace(",ou=groups,dc=yunohost,dc=org", "") From c9092b2aadd82f3bc7c57ce626248f6a9983fba6 Mon Sep 17 00:00:00 2001 From: axolotle Date: Tue, 1 Aug 2023 15:29:09 +0200 Subject: [PATCH 23/36] add portal_update to update user infos --- share/actionsmap-portal.yml | 28 +++++++++++- src/portal.py | 85 +++++++++++++++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml index 761d5a6ce..268a420b3 100644 --- a/share/actionsmap-portal.yml +++ b/share/actionsmap-portal.yml @@ -23,8 +23,32 @@ portal: ### portal_update() update: action_help: Allow user to update their infos (display name, mail aliases/forward, password, ...) - api: PUT /me - # FIXME: add args etc + api: PUT /update + arguments: + -F: + full: --fullname + help: The full name of the user. For example 'Camille Dupont' + extra: + pattern: &pattern_fullname + - !!str ^([^\W_]{1,30}[ ,.'-]{0,3})+$ + - "pattern_fullname" + --mailforward: + help: Mailforward addresses to add + nargs: "*" + metavar: MAIL + extra: + pattern: &pattern_email_forward + - !!str ^[\w\+.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ + - "pattern_email_forward" + --mailalias: + help: Mail aliases to add + nargs: "*" + metavar: MAIL + extra: + pattern: &pattern_email + - !!str ^[\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ + - "pattern_email" + ### portal_reset_password() reset_password: diff --git a/src/portal.py b/src/portal.py index 7e67ff35d..2e234ec73 100644 --- a/src/portal.py +++ b/src/portal.py @@ -18,16 +18,19 @@ along with this program; if not, see http://www.gnu.org/licenses """ +from typing import Union from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import read_json from yunohost.authenticators.ldap_ynhuser import Authenticator as Auth from yunohost.utils.ldap import LDAPInterface -from yunohost.utils.error import YunohostValidationError +from yunohost.utils.error import YunohostError, YunohostValidationError logger = getActionLogger("portal") +ADMIN_ALIASES = ["root", "admin", "admins", "webmaster", "postmaster", "abuse"] + def _get_user_infos(user_attrs: list[str]): auth = Auth().get_session_cookie(decrypt_pwd=True) @@ -71,8 +74,8 @@ def portal_me(): "username": username, "fullname": user["cn"][0], "mail": user["mail"][0], - "mail-aliases": user["mail"][1:], - "mail-forward": user["maildrop"][1:], + "mailalias": user["mail"][1:], + "mailforward": user["maildrop"][1:], "groups": groups, "apps": apps, } @@ -86,3 +89,79 @@ def portal_me(): # But this requires to be in the mail group ... return result_dict + + +def portal_update( + fullname: Union[str, None] = None, + mailforward: Union[list[str], None] = None, + mailalias: Union[list[str], None] = None, +): + from yunohost.domain import domain_list + + domains = domain_list()["domains"] + username, current_user, ldap = _get_user_infos( + ["givenName", "sn", "cn", "mail", "maildrop", "memberOf"] + ) + new_attr_dict = {} + + if fullname is not None and fullname != current_user["cn"]: + fullname = fullname.strip() + firstname = fullname.split()[0] + lastname = ( + " ".join(fullname.split()[1:]) or " " + ) # Stupid hack because LDAP requires the sn/lastname attr, but it accepts a single whitespace... + new_attr_dict["givenName"] = [firstname] # TODO: Validate + new_attr_dict["sn"] = [lastname] # TODO: Validate + new_attr_dict["cn"] = new_attr_dict["displayName"] = [ + (firstname + " " + lastname).strip() + ] + + if mailalias is not None: + mailalias = [mail.strip() for mail in mailalias if mail and mail.strip()] + # keep first current mail unaltered + mails = [current_user["mail"][0]] + + for index, mail in enumerate(mailalias): + if mail in current_user["mail"]: + if mail != current_user["mail"][0]: + mails.append(mail) + continue # already in mails, skip validation + + local_part, domain = mail.strip().split("@") + if local_part in ADMIN_ALIASES: + raise YunohostValidationError( + "mail_unavailable", path="mailalias", index=index + ) + + try: + ldap.validate_uniqueness({"mail": mail}) + except Exception as e: + raise YunohostError("user_update_failed", user=username, error=e) + + if domain not in domains: + raise YunohostError("mail_domain_unknown", domain=domain) + + mails.append(mail) + + new_attr_dict["mail"] = mails + + if mailforward is not None: + new_attr_dict["maildrop"] = [current_user["maildrop"][0]] + [ + mail.strip() + for mail in mailforward + if mail and mail.strip() and mail != current_user["maildrop"][0] + ] + + try: + ldap.update(f"uid={username},ou=users", new_attr_dict) + except Exception as e: + raise YunohostError("user_update_failed", user=username, error=e) + + # FIXME: Here we could want to trigger "post_user_update" hook but hooks has to + # be run as root + + return { + "fullname": new_attr_dict["cn"][0], + "mailalias": new_attr_dict["mail"][1:], + "mailforward": new_attr_dict["maildrop"][1:], + } From db1670ca5d8cec856cda93ed3b5e06d4c03e6dfe Mon Sep 17 00:00:00 2001 From: axolotle Date: Tue, 1 Aug 2023 16:28:25 +0200 Subject: [PATCH 24/36] add temp portal_update_password --- share/actionsmap-portal.yml | 11 +++++++++++ src/portal.py | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml index 268a420b3..2673fc226 100644 --- a/share/actionsmap-portal.yml +++ b/share/actionsmap-portal.yml @@ -49,6 +49,17 @@ portal: - !!str ^[\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ - "pattern_email" + ### portal_update_password() + update_password: + action_help: Allow user to change their password + api: PUT /me/update_password + arguments: + -c: + full: --current + help: Current password + -p: + full: --password + help: New password to set ### portal_reset_password() reset_password: diff --git a/src/portal.py b/src/portal.py index 2e234ec73..fa2d1a30a 100644 --- a/src/portal.py +++ b/src/portal.py @@ -26,6 +26,11 @@ from moulinette.utils.filesystem import read_json from yunohost.authenticators.ldap_ynhuser import Authenticator as Auth from yunohost.utils.ldap import LDAPInterface from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.utils.password import ( + assert_password_is_compatible, + assert_password_is_strong_enough, +) +from yunohost.user import _hash_user_password logger = getActionLogger("portal") @@ -165,3 +170,22 @@ def portal_update( "mailalias": new_attr_dict["mail"][1:], "mailforward": new_attr_dict["maildrop"][1:], } + + +def portal_update_password(current: str, password: str): + username, current_user, ldap = _get_user_infos(["userPassword", "memberOf"]) + is_admin = "cn=admins,ou=groups,dc=yunohost,dc=org" in current_user["memberOf"] + + # FIXME: Verify current password ? + + # Ensure compatibility and sufficiently complex password + assert_password_is_compatible(password) + assert_password_is_strong_enough("admin" if is_admin else "user", password) + + try: + ldap.update( + f"uid={username},ou=users", + {"userPassword": [_hash_user_password(password)]}, + ) + except Exception as e: + raise YunohostError("user_update_failed", user=username, error=e) From 6f8b3fd57feebbbf987a02605fd842f9324ff646 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Sun, 13 Aug 2023 23:11:31 +0200 Subject: [PATCH 25/36] Handle both cookies in the same way (please let me logout) --- src/authenticators/ldap_admin.py | 4 ++-- src/authenticators/ldap_ynhuser.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/authenticators/ldap_admin.py b/src/authenticators/ldap_admin.py index b1b550bc0..155e84127 100644 --- a/src/authenticators/ldap_admin.py +++ b/src/authenticators/ldap_admin.py @@ -138,6 +138,7 @@ class Authenticator(BaseAuthenticator): secure=True, secret=session_secret, httponly=True, + path="/" # samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions ) @@ -172,5 +173,4 @@ class Authenticator(BaseAuthenticator): def delete_session_cookie(self): from bottle import response - response.set_cookie("yunohost.admin", "", max_age=-1) - response.delete_cookie("yunohost.admin") + response.delete_cookie("yunohost.admin", path="/") diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 9702693ed..08138f1b5 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -172,5 +172,4 @@ class Authenticator(BaseAuthenticator): from bottle import response - response.set_cookie("yunohost.portal", "") - response.delete_cookie("yunohost.portal") + response.delete_cookie("yunohost.portal", path="/") From 101b5704c491de6df80f236a10c4fc865fdc9963 Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Tue, 15 Aug 2023 12:23:56 +0200 Subject: [PATCH 26/36] Serialize the JWT token to a cookie string instead of failing --- src/authenticators/ldap_ynhuser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 08138f1b5..2add68cab 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -132,7 +132,7 @@ class Authenticator(BaseAuthenticator): response.set_cookie( "yunohost.portal", - jwt.encode(new_infos, session_secret, algorithm="HS256"), + jwt.encode(new_infos, session_secret, algorithm="HS256").decode(), secure=True, httponly=True, path="/", From 26d4d9420c6e129e3de869f85cd308e087d25d5b Mon Sep 17 00:00:00 2001 From: selfhoster1312 Date: Tue, 15 Aug 2023 14:12:08 +0200 Subject: [PATCH 27/36] Allow inline scripts for yunohost-portal (nginx CSP) --- conf/nginx/plain/yunohost_sso.conf.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/nginx/plain/yunohost_sso.conf.inc b/conf/nginx/plain/yunohost_sso.conf.inc index 578a75e52..fb5406cfc 100644 --- a/conf/nginx/plain/yunohost_sso.conf.inc +++ b/conf/nginx/plain/yunohost_sso.conf.inc @@ -13,5 +13,5 @@ location /yunohost/sso/ { more_set_headers "Cache-Control: no-store, no-cache, must-revalidate"; } - more_set_headers "Content-Security-Policy: upgrade-insecure-requests; default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; img-src 'self' data:;"; + more_set_headers "Content-Security-Policy: upgrade-insecure-requests; default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; object-src 'none'; img-src 'self' data:;"; } From 8f0f85b7221a6f6577d1391916ade88a78506d17 Mon Sep 17 00:00:00 2001 From: axolotle Date: Tue, 29 Aug 2023 15:24:52 +0200 Subject: [PATCH 28/36] merge update_password with update --- share/actionsmap-portal.yml | 29 ++++++++------ src/portal.py | 78 ++++++++++++++++++++----------------- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml index 2673fc226..6b02a061d 100644 --- a/share/actionsmap-portal.yml +++ b/share/actionsmap-portal.yml @@ -25,8 +25,7 @@ portal: action_help: Allow user to update their infos (display name, mail aliases/forward, password, ...) api: PUT /update arguments: - -F: - full: --fullname + --fullname: help: The full name of the user. For example 'Camille Dupont' extra: pattern: &pattern_fullname @@ -48,18 +47,24 @@ portal: pattern: &pattern_email - !!str ^[\w.-]+@([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$ - "pattern_email" + --currentpassword: + help: Current password + nargs: "?" + --newpassword: + help: New password to set + nargs: "?" ### portal_update_password() - update_password: - action_help: Allow user to change their password - api: PUT /me/update_password - arguments: - -c: - full: --current - help: Current password - -p: - full: --password - help: New password to set + # update_password: + # action_help: Allow user to change their password + # api: PUT /me/update_password + # arguments: + # -c: + # full: --current + # help: Current password + # -p: + # full: --password + # help: New password to set ### portal_reset_password() reset_password: diff --git a/src/portal.py b/src/portal.py index fa2d1a30a..e9c2b3f48 100644 --- a/src/portal.py +++ b/src/portal.py @@ -20,17 +20,17 @@ """ from typing import Union -from moulinette.utils.log import getActionLogger +import ldap from moulinette.utils.filesystem import read_json - -from yunohost.authenticators.ldap_ynhuser import Authenticator as Auth -from yunohost.utils.ldap import LDAPInterface +from moulinette.utils.log import getActionLogger +from yunohost.authenticators.ldap_ynhuser import URI, USERDN, Authenticator as Auth +from yunohost.user import _hash_user_password from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.utils.ldap import LDAPInterface from yunohost.utils.password import ( assert_password_is_compatible, assert_password_is_strong_enough, ) -from yunohost.user import _hash_user_password logger = getActionLogger("portal") @@ -40,12 +40,12 @@ ADMIN_ALIASES = ["root", "admin", "admins", "webmaster", "postmaster", "abuse"] def _get_user_infos(user_attrs: list[str]): auth = Auth().get_session_cookie(decrypt_pwd=True) username = auth["user"] - ldap = LDAPInterface(username, auth["pwd"]) - result = ldap.search("ou=users", f"uid={username}", user_attrs) + ldap_interface = LDAPInterface(username, auth["pwd"]) + result = ldap_interface.search("ou=users", f"uid={username}", user_attrs) if not result: raise YunohostValidationError("user_unknown", user=username) - return username, result[0], ldap + return username, result[0], ldap_interface def portal_me(): @@ -53,7 +53,7 @@ def portal_me(): Get user informations """ - username, user, ldap = _get_user_infos( + username, user, _ = _get_user_infos( ["cn", "mail", "maildrop", "mailuserquota", "memberOf", "permission"] ) @@ -100,11 +100,13 @@ def portal_update( fullname: Union[str, None] = None, mailforward: Union[list[str], None] = None, mailalias: Union[list[str], None] = None, + currentpassword: Union[str, None] = None, + newpassword: Union[str, None] = None, ): from yunohost.domain import domain_list domains = domain_list()["domains"] - username, current_user, ldap = _get_user_infos( + username, current_user, ldap_interface = _get_user_infos( ["givenName", "sn", "cn", "mail", "maildrop", "memberOf"] ) new_attr_dict = {} @@ -128,23 +130,25 @@ def portal_update( for index, mail in enumerate(mailalias): if mail in current_user["mail"]: - if mail != current_user["mail"][0]: + if mail != current_user["mail"][0] and mail not in mails: mails.append(mail) continue # already in mails, skip validation - local_part, domain = mail.strip().split("@") + local_part, domain = mail.split("@") if local_part in ADMIN_ALIASES: raise YunohostValidationError( - "mail_unavailable", path="mailalias", index=index + "mail_unavailable", path=f"mailalias[{index}]" ) try: - ldap.validate_uniqueness({"mail": mail}) + ldap_interface.validate_uniqueness({"mail": mail}) except Exception as e: raise YunohostError("user_update_failed", user=username, error=e) if domain not in domains: - raise YunohostError("mail_domain_unknown", domain=domain) + raise YunohostValidationError( + "mail_domain_unknown", domain=domain, path=f"mailalias[{index}]" + ) mails.append(mail) @@ -157,8 +161,31 @@ def portal_update( if mail and mail.strip() and mail != current_user["maildrop"][0] ] + if newpassword: + # Check that current password is valid + try: + con = ldap.ldapobject.ReconnectLDAPObject(URI, retry_max=0) + con.simple_bind_s(USERDN.format(username=username), currentpassword) + except ldap.INVALID_CREDENTIALS: + raise YunohostValidationError("invalid_password", path="currentpassword") + finally: + # Free the connection, we don't really need it to keep it open as the point is only to check authentication... + if con: + con.unbind_s() + + # Ensure compatibility and sufficiently complex password + try: + assert_password_is_compatible(newpassword) + is_admin = "cn=admins,ou=groups,dc=yunohost,dc=org" in current_user["memberOf"] + assert_password_is_strong_enough("admin" if is_admin else "user", newpassword) + except YunohostValidationError as e: + raise YunohostValidationError(e.key, path="newpassword") + + Auth().delete_session_cookie() + new_attr_dict["userPassword"] = [_hash_user_password(newpassword)] + try: - ldap.update(f"uid={username},ou=users", new_attr_dict) + ldap_interface.update(f"uid={username},ou=users", new_attr_dict) except Exception as e: raise YunohostError("user_update_failed", user=username, error=e) @@ -170,22 +197,3 @@ def portal_update( "mailalias": new_attr_dict["mail"][1:], "mailforward": new_attr_dict["maildrop"][1:], } - - -def portal_update_password(current: str, password: str): - username, current_user, ldap = _get_user_infos(["userPassword", "memberOf"]) - is_admin = "cn=admins,ou=groups,dc=yunohost,dc=org" in current_user["memberOf"] - - # FIXME: Verify current password ? - - # Ensure compatibility and sufficiently complex password - assert_password_is_compatible(password) - assert_password_is_strong_enough("admin" if is_admin else "user", password) - - try: - ldap.update( - f"uid={username},ou=users", - {"userPassword": [_hash_user_password(password)]}, - ) - except Exception as e: - raise YunohostError("user_update_failed", user=username, error=e) From 0645d18e677ad399d36d1ca23327097af2043504 Mon Sep 17 00:00:00 2001 From: axolotle Date: Mon, 4 Sep 2023 16:19:07 +0200 Subject: [PATCH 29/36] add host as session cookie info --- src/authenticators/ldap_ynhuser.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 2add68cab..331cf9e25 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -115,7 +115,7 @@ class Authenticator(BaseAuthenticator): def set_session_cookie(self, infos): - from bottle import response + from bottle import response, request assert isinstance(infos, dict) @@ -126,7 +126,8 @@ class Authenticator(BaseAuthenticator): # See https://pyjwt.readthedocs.io/en/latest/usage.html#registered-claim-names # for explanations regarding nbf, exp "nbf": int(datetime.datetime.now().timestamp()), - "exp": int(datetime.datetime.now().timestamp()) + (7 * 24 * 3600) # One week validity # FIXME : does it mean the session suddenly expires after a week ? Can we somehow auto-renew it at every usage or something ? + "exp": int(datetime.datetime.now().timestamp()) + (7 * 24 * 3600), # One week validity # FIXME : does it mean the session suddenly expires after a week ? Can we somehow auto-renew it at every usage or something ? + "host": request.get_header('host'), } new_infos.update(infos) From 5562b61db07de881f72405198156eecc80a0be7b Mon Sep 17 00:00:00 2001 From: axolotle Date: Mon, 4 Sep 2023 16:20:29 +0200 Subject: [PATCH 30/36] add 'list_portal' AppOption modifier to add portal as a possible choice --- src/utils/configpanel.py | 1 + src/utils/form.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/utils/configpanel.py b/src/utils/configpanel.py index 42a030cbc..02454bd0b 100644 --- a/src/utils/configpanel.py +++ b/src/utils/configpanel.py @@ -382,6 +382,7 @@ class ConfigPanel: "filter", "readonly", "enabled", + "list_portal", # "confirm", # TODO: to ask confirmation before running an action ], "defaults": {}, diff --git a/src/utils/form.py b/src/utils/form.py index 1ca03373e..64155d8e2 100644 --- a/src/utils/form.py +++ b/src/utils/form.py @@ -914,6 +914,7 @@ class AppOption(BaseChoicesOption): super().__init__(question) self.filter = question.get("filter", None) + self.list_portal = question.get("list_portal", False) apps = app_list(full=True)["apps"] @@ -929,6 +930,8 @@ class AppOption(BaseChoicesOption): return app["label"] + domain_path_or_id self.choices = {"_none": "---"} + if self.list_portal: + self.choices["portal_public_apps"] = "Portal" self.choices.update({app["id"]: _app_display(app) for app in apps}) From a1a47e5221809751949f7bf17cf7c63148028164 Mon Sep 17 00:00:00 2001 From: axolotle Date: Mon, 4 Sep 2023 16:21:50 +0200 Subject: [PATCH 31/36] update config_domain.toml with portal options --- share/config_domain.toml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/share/config_domain.toml b/share/config_domain.toml index 82ef90c32..b6a0b51a7 100644 --- a/share/config_domain.toml +++ b/share/config_domain.toml @@ -4,11 +4,34 @@ i18n = "domain_config" [feature] name = "Features" + [feature.portal] + name = "Portal" + + [feature.portal.show_other_domains_apps] + type = "boolean" + default = 1 + + [feature.portal.portal_title] + type = "string" + default = "YunoHost" + + [feature.portal.portal_logo] + type = "string" + default = "" + + [feature.portal.portal_theme] + type = "select" + choices = ["system", "light", "dark", "cupcake", "bumblebee", "emerald", "corporate", "synthwave", "retro", "cyberpunk", "valentine", "halloween", "garden", "forest", "aqua", "lofi", "pastel", "fantasy", "wireframe", "black", "luxury", "dracula", "cmyk", "autumn", "business", "acid", "lemonade", "night", "coffee", "winter"] + default = "system" + + # FIXME link to GCU + [feature.app] [feature.app.default_app] type = "app" filter = "is_webapp" default = "_none" + list_portal = true [feature.mail] From 20d21b57e047863c421d5369cb710d33f7e53c0d Mon Sep 17 00:00:00 2001 From: axolotle Date: Mon, 4 Sep 2023 16:24:01 +0200 Subject: [PATCH 32/36] wip: save portal configpanel options in separate file .portal.yml --- src/domain.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/domain.py b/src/domain.py index 5f0e76d28..9ddb67a76 100644 --- a/src/domain.py +++ b/src/domain.py @@ -737,6 +737,29 @@ class DomainConfigPanel(ConfigPanel): ): app_ssowatconf() + portal_options = [ + "default_app", + "show_other_domains_apps", + "portal_title", + "portal_logo", + "portal_theme", + ] + if any( + option in self.future_values + and self.future_values[option] != self.values[option] + for option in portal_options + ): + # Portal options are also saved in a `domain.portal.yml` file + # that can be read by the portal API. + # FIXME remove those from the config panel saved values? + portal_values = { + option: self.future_values[option] for option in portal_options + } + # FIXME config file should be readable by the portal entity + write_to_yaml( + f"{DOMAIN_SETTINGS_DIR}/{self.entity}.portal.yml", portal_values + ) + stuff_to_regen_conf = [] if ( "xmpp" in self.future_values From 2136db32b6d7da0d711bc441588d872ca49a2ee0 Mon Sep 17 00:00:00 2001 From: axolotle Date: Mon, 4 Sep 2023 16:27:06 +0200 Subject: [PATCH 33/36] return domain from _get_user_infos --- src/portal.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/portal.py b/src/portal.py index e9c2b3f48..cdb6fb9dd 100644 --- a/src/portal.py +++ b/src/portal.py @@ -18,7 +18,7 @@ along with this program; if not, see http://www.gnu.org/licenses """ -from typing import Union +from typing import Any, Union import ldap from moulinette.utils.filesystem import read_json @@ -37,7 +37,9 @@ logger = getActionLogger("portal") ADMIN_ALIASES = ["root", "admin", "admins", "webmaster", "postmaster", "abuse"] -def _get_user_infos(user_attrs: list[str]): +def _get_user_infos( + user_attrs: list[str], +) -> tuple[str, str, dict[str, Any], LDAPInterface]: auth = Auth().get_session_cookie(decrypt_pwd=True) username = auth["user"] ldap_interface = LDAPInterface(username, auth["pwd"]) @@ -45,15 +47,14 @@ def _get_user_infos(user_attrs: list[str]): if not result: raise YunohostValidationError("user_unknown", user=username) - return username, result[0], ldap_interface + return username, auth["host"], result[0], ldap_interface def portal_me(): """ Get user informations """ - - username, user, _ = _get_user_infos( + username, domain, user, _ = _get_user_infos( ["cn", "mail", "maildrop", "mailuserquota", "memberOf", "permission"] ) @@ -106,7 +107,7 @@ def portal_update( from yunohost.domain import domain_list domains = domain_list()["domains"] - username, current_user, ldap_interface = _get_user_infos( + username, domain, current_user, ldap_interface = _get_user_infos( ["givenName", "sn", "cn", "mail", "maildrop", "memberOf"] ) new_attr_dict = {} @@ -176,8 +177,12 @@ def portal_update( # Ensure compatibility and sufficiently complex password try: assert_password_is_compatible(newpassword) - is_admin = "cn=admins,ou=groups,dc=yunohost,dc=org" in current_user["memberOf"] - assert_password_is_strong_enough("admin" if is_admin else "user", newpassword) + is_admin = ( + "cn=admins,ou=groups,dc=yunohost,dc=org" in current_user["memberOf"] + ) + assert_password_is_strong_enough( + "admin" if is_admin else "user", newpassword + ) except YunohostValidationError as e: raise YunohostValidationError(e.key, path="newpassword") From bfedf144b30e5f295bde724e2db0c69c1f85dbd6 Mon Sep 17 00:00:00 2001 From: axolotle Date: Mon, 4 Sep 2023 16:31:58 +0200 Subject: [PATCH 34/36] add settings getter + /public route to get settings and public apps --- src/portal.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/portal.py b/src/portal.py index cdb6fb9dd..61d0c301a 100644 --- a/src/portal.py +++ b/src/portal.py @@ -18,10 +18,11 @@ along with this program; if not, see http://www.gnu.org/licenses """ +from pathlib import Path from typing import Any, Union import ldap -from moulinette.utils.filesystem import read_json +from moulinette.utils.filesystem import read_json, read_yaml from moulinette.utils.log import getActionLogger from yunohost.authenticators.ldap_ynhuser import URI, USERDN, Authenticator as Auth from yunohost.user import _hash_user_password @@ -50,6 +51,56 @@ def _get_user_infos( return username, auth["host"], result[0], ldap_interface +def _get_portal_settings(domain: Union[str, None] = None): + from yunohost.domain import DOMAIN_SETTINGS_DIR + + if not domain: + from bottle import request + + domain = request.get_header("host") + + if Path(f"{DOMAIN_SETTINGS_DIR}/{domain}.portal.yml").exists(): + settings = read_yaml(f"{DOMAIN_SETTINGS_DIR}/{domain}.portal.yml") + else: + settings = { + "public": False, + "portal_logo": "", + "portal_theme": "system", + "portal_title": "YunoHost", + "show_other_domains_apps": 1, + } + + settings["domain"] = domain + + return settings + + +def portal_public(): + settings = _get_portal_settings() + settings["apps"] = {} + settings["public"] = settings.pop("default_app") == "portal_public_apps" + + if settings["public"]: + ssowat_conf = read_json("/etc/ssowat/conf.json") + settings["apps"] = { + perm.replace(".main", ""): { + "label": infos["label"], + "url": infos["uris"][0], + } + for perm, infos in ssowat_conf["permissions"].items() + if infos["show_tile"] and infos["public"] + } + + if not settings["show_other_domains_apps"]: + settings["apps"] = { + name: data + for name, data in settings["apps"].items() + if settings["domain"] in data["url"] + } + + return settings + + def portal_me(): """ Get user informations @@ -76,6 +127,10 @@ def portal_me(): if perm in permissions and infos["show_tile"] and username in infos["users"] } + settings = _get_portal_settings(domain=domain) + if not settings["show_other_domains_apps"]: + apps = {name: data for name, data in apps.items() if domain in data["url"]} + result_dict = { "username": username, "fullname": user["cn"][0], From c641f099c5ae8162a91a3e020c16aa944a1df221 Mon Sep 17 00:00:00 2001 From: axolotle Date: Thu, 7 Sep 2023 17:57:08 +0200 Subject: [PATCH 35/36] add temp messy file handling for portal custom logo --- share/config_domain.toml | 3 +-- src/domain.py | 46 +++++++++++++++++++++++++++++++--------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/share/config_domain.toml b/share/config_domain.toml index b6a0b51a7..27493b4e7 100644 --- a/share/config_domain.toml +++ b/share/config_domain.toml @@ -16,8 +16,7 @@ name = "Features" default = "YunoHost" [feature.portal.portal_logo] - type = "string" - default = "" + type = "file" [feature.portal.portal_theme] type = "select" diff --git a/src/domain.py b/src/domain.py index 9ddb67a76..a476112b2 100644 --- a/src/domain.py +++ b/src/domain.py @@ -728,15 +728,6 @@ class DomainConfigPanel(ConfigPanel): other_app=app_map(raw=True)[self.entity]["/"]["id"], ) - super()._apply() - - # Reload ssowat if default app changed - if ( - "default_app" in self.future_values - and self.future_values["default_app"] != self.values["default_app"] - ): - app_ssowatconf() - portal_options = [ "default_app", "show_other_domains_apps", @@ -755,11 +746,46 @@ class DomainConfigPanel(ConfigPanel): portal_values = { option: self.future_values[option] for option in portal_options } - # FIXME config file should be readable by the portal entity + if portal_values["portal_logo"].startswith("/tmp/ynh_filequestion_"): + # FIXME rework this whole mess + # currently only handling API sent images, need to adapt FileOption + # to handle file extensions and file saving since "bind" is only + # done in bash helpers which are not executed in domain config + if "portal_logo[name]" in self.args or self.values["portal_logo"]: + import mimetypes + import base64 + + if "portal_logo[name]" in self.args: + # FIXME choose where to save the file + filepath = os.path.join("/tmp", self.args["portal_logo[name]"]) + # move the temp file created by FileOption with proper name and extension + os.rename(self.new_values["portal_logo"], filepath) + mimetype = mimetypes.guess_type(filepath) + else: + # image has already been saved, do not overwrite it with the empty temp file created by the FileOption + filepath = self.values["portal_logo"] + mimetype = mimetypes.guess_type(filepath) + + # save the proper path to config panel settings + self.new_values["portal_logo"] = filepath + # save the base64 content with mimetype to portal settings + with open(filepath, "rb") as f: + portal_values["portal_logo"] = mimetype[0] + ":" + base64.b64encode(f.read()).decode("utf-8") + + # FIXME config file should be readable by non-root portal entity write_to_yaml( f"{DOMAIN_SETTINGS_DIR}/{self.entity}.portal.yml", portal_values ) + super()._apply() + + # Reload ssowat if default app changed + if ( + "default_app" in self.future_values + and self.future_values["default_app"] != self.values["default_app"] + ): + app_ssowatconf() + stuff_to_regen_conf = [] if ( "xmpp" in self.future_values From 9e87ea88df37bd7e924f5659c6ffe33ded8dbebf Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Wed, 27 Sep 2023 19:30:55 +0200 Subject: [PATCH 36/36] portal-api: improve semantic for yunohost public portal stuff --- share/config_domain.toml | 2 +- src/portal.py | 20 ++++++++++---------- src/utils/configpanel.py | 2 +- src/utils/form.py | 7 ++++--- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/share/config_domain.toml b/share/config_domain.toml index 27493b4e7..1239b1fea 100644 --- a/share/config_domain.toml +++ b/share/config_domain.toml @@ -30,7 +30,7 @@ name = "Features" type = "app" filter = "is_webapp" default = "_none" - list_portal = true + add_yunohost_portal_to_choices = true [feature.mail] diff --git a/src/portal.py b/src/portal.py index 61d0c301a..cc6c03e4b 100644 --- a/src/portal.py +++ b/src/portal.py @@ -76,13 +76,13 @@ def _get_portal_settings(domain: Union[str, None] = None): def portal_public(): - settings = _get_portal_settings() - settings["apps"] = {} - settings["public"] = settings.pop("default_app") == "portal_public_apps" + portal_settings = _get_portal_settings() + portal_settings["apps"] = {} + portal_settings["public"] = portal_settings.pop("default_app") == "_yunohost_portal_with_public_apps" - if settings["public"]: + if portal_settings["public"]: ssowat_conf = read_json("/etc/ssowat/conf.json") - settings["apps"] = { + portal_settings["apps"] = { perm.replace(".main", ""): { "label": infos["label"], "url": infos["uris"][0], @@ -91,14 +91,14 @@ def portal_public(): if infos["show_tile"] and infos["public"] } - if not settings["show_other_domains_apps"]: - settings["apps"] = { + if not portal_settings["show_other_domains_apps"]: + portal_settings["apps"] = { name: data - for name, data in settings["apps"].items() - if settings["domain"] in data["url"] + for name, data in portal_settings["apps"].items() + if portal_settings["domain"] in data["url"] } - return settings + return portal_settings def portal_me(): diff --git a/src/utils/configpanel.py b/src/utils/configpanel.py index 30a35e410..86dea2e7d 100644 --- a/src/utils/configpanel.py +++ b/src/utils/configpanel.py @@ -386,7 +386,7 @@ class ConfigPanel: "filter", "readonly", "enabled", - "list_portal", + "add_yunohost_portal_to_choices", # "confirm", # TODO: to ask confirmation before running an action ], "defaults": {}, diff --git a/src/utils/form.py b/src/utils/form.py index c7bc25305..f201f507b 100644 --- a/src/utils/form.py +++ b/src/utils/form.py @@ -914,7 +914,7 @@ class AppOption(BaseChoicesOption): super().__init__(question) self.filter = question.get("filter", None) - self.list_portal = question.get("list_portal", False) + self.add_yunohost_portal_to_choices = question.get("add_yunohost_portal_to_choices", False) apps = app_list(full=True)["apps"] @@ -930,8 +930,9 @@ class AppOption(BaseChoicesOption): return app["label"] + domain_path_or_id self.choices = {"_none": "---"} - if self.list_portal: - self.choices["portal_public_apps"] = "Portal" + if self.add_yunohost_portal_to_choices: + # FIXME: i18n + self.choices["_yunohost_portal_with_public_apps"] = "YunoHost's portal with public apps" self.choices.update({app["id"]: _app_display(app) for app in apps})