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/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) {
+# }
diff --git a/conf/nginx/plain/yunohost_sso.conf.inc b/conf/nginx/plain/yunohost_sso.conf.inc
index 308e5a9a4..fb5406cfc 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/ {
- # 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/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' 'unsafe-inline'; object-src 'none'; img-src 'self' data:;";
}
diff --git a/conf/nginx/yunohost_api.conf.inc b/conf/nginx/yunohost_api.conf.inc
index f434dbe96..9cb4ff00d 100644
--- a/conf/nginx/yunohost_api.conf.inc
+++ b/conf/nginx/yunohost_api.conf.inc
@@ -23,3 +23,24 @@ location = /yunohost/api/error/502 {
add_header Content-Type text/plain;
internal;
}
+
+location /yunohost/portalapi/ {
+
+ 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 $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/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
diff --git a/conf/yunohost/yunohost-portal-api.service b/conf/yunohost/yunohost-portal-api.service
new file mode 100644
index 000000000..006af0080
--- /dev/null
+++ b/conf/yunohost/yunohost-portal-api.service
@@ -0,0 +1,48 @@
+[Unit]
+Description=YunoHost Portal API
+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
diff --git a/debian/control b/debian/control
index 3674a62a4..9f156ddea 100644
--- a/debian/control
+++ b/debian/control
@@ -10,11 +10,12 @@ 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
- , python3-ldap, python3-zeroconf (>=0.47), python3-lexicon,
+ , python3-ldap, python3-zeroconf (>= 0.47), python3-lexicon,
+ , python3-cryptography, python3-jwt
, python-is-python3
, nginx, nginx-extras (>=1.22)
, apt, apt-transport-https, apt-utils, dirmngr
diff --git a/debian/postinst b/debian/postinst
index 238817cd7..37d6ff895 100644
--- a/debian/postinst
+++ b/debian/postinst
@@ -33,6 +33,8 @@ do_configure() {
yunohost tools update apps --output-as none || true
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 1d7a449e4..4d53997a5 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 --quiet
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
@@ -155,6 +162,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
cp proc-hidepid.service ${pending_dir}/etc/systemd/system/proc-hidepid.service
@@ -167,6 +175,18 @@ 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
+ # 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
+
+ touch /var/log/yunohost-portalapi.log
+ chown ynh-portal:root /var/log/yunohost-portalapi.log
+ chmod 600 /var/log/yunohost-portalapi.log
+
######################
# Enfore permissions #
######################
@@ -236,6 +256,7 @@ do_post_regen() {
systemctl restart ntp
}
fi
+
[[ ! "$regen_conf_files" =~ "nftables.service.d/ynh-override.conf" ]] || systemctl daemon-reload
[[ ! "$regen_conf_files" =~ "login.conf.d/ynh-override.conf" ]] || {
systemctl daemon-reload
@@ -243,6 +264,7 @@ do_post_regen() {
}
[[ ! "$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
@@ -255,6 +277,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;
diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml
new file mode 100644
index 000000000..6b02a061d
--- /dev/null
+++ b/share/actionsmap-portal.yml
@@ -0,0 +1,92 @@
+_global:
+ namespace: yunohost
+ authentication:
+ api: ldap_ynhuser
+ cli: null
+ lock: false
+ cache: false
+
+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 /update
+ arguments:
+ --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"
+ --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
+
+ ### 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/share/config_domain.toml b/share/config_domain.toml
index 82ef90c32..1239b1fea 100644
--- a/share/config_domain.toml
+++ b/share/config_domain.toml
@@ -4,11 +4,33 @@ 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 = "file"
+
+ [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"
+ add_yunohost_portal_to_choices = true
[feature.mail]
diff --git a/src/__init__.py b/src/__init__.py
index e23b62219..32ee0cbb9 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,6 +71,28 @@ 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")
+
+ ret = moulinette.api(
+ host=host,
+ port=port,
+ actionsmap="/usr/share/yunohost/actionsmap-portal.yml",
+ locales_dir="/usr/share/yunohost/locales/",
+ allowed_cors_origins=allowed_cors_origins,
)
sys.exit(ret)
@@ -132,6 +161,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",
@@ -157,7 +190,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/app.py b/src/app.py
index a07130c5a..e8317099d 100644
--- a/src/app.py
+++ b/src/app.py
@@ -1606,7 +1606,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
@@ -1625,6 +1625,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:^[^/]/502%.html$",
"re:^[^/]*/%.well%-known/ynh%-diagnosis/.*$",
@@ -1633,6 +1634,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/"
}
@@ -1698,18 +1701,12 @@ def app_ssowatconf():
}
conf_dict = {
+ "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/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
new file mode 100644
index 000000000..331cf9e25
--- /dev/null
+++ b/src/authenticators/ldap_ynhuser.py
@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+
+import jwt
+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
+from moulinette.utils.text import random_ascii
+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().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):
+
+ name = "ldap_ynhuser"
+
+ def _authenticate_credentials(self, credentials=None):
+
+ try:
+ username, password = credentials.split(":", 1)
+ except ValueError:
+ raise YunohostError("invalid_credentials")
+
+ 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:
+ # 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"))
+
+ # 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()
+
+ return {"user": username, "pwd": encrypt(password)}
+
+ def set_session_cookie(self, infos):
+
+ from bottle import response, request
+
+ 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 # 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)
+
+ 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, decrypt_pwd=False):
+
+ 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()}
+ # 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:
+ raise YunohostAuthenticationError("unable_authenticate")
+
+ if "id" not in infos:
+ infos["id"] = random_ascii()
+
+ if decrypt_pwd:
+ infos["pwd"] = decrypt(infos["pwd"])
+
+ # 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
+
+ def delete_session_cookie(self):
+
+ from bottle import response
+
+ response.delete_cookie("yunohost.portal", path="/")
diff --git a/src/domain.py b/src/domain.py
index af6814c5c..273fa9e6d 100644
--- a/src/domain.py
+++ b/src/domain.py
@@ -100,6 +100,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
@@ -709,6 +729,55 @@ class DomainConfigPanel(ConfigPanel):
other_app=app_map(raw=True)[self.entity]["/"]["id"],
)
+ 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
+ }
+ 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
diff --git a/src/portal.py b/src/portal.py
new file mode 100644
index 000000000..cc6c03e4b
--- /dev/null
+++ b/src/portal.py
@@ -0,0 +1,259 @@
+# -*- 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 pathlib import Path
+from typing import Any, Union
+
+import ldap
+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
+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,
+)
+
+logger = getActionLogger("portal")
+
+ADMIN_ALIASES = ["root", "admin", "admins", "webmaster", "postmaster", "abuse"]
+
+
+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"])
+ result = ldap_interface.search("ou=users", f"uid={username}", user_attrs)
+ if not result:
+ raise YunohostValidationError("user_unknown", user=username)
+
+ 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():
+ portal_settings = _get_portal_settings()
+ portal_settings["apps"] = {}
+ portal_settings["public"] = portal_settings.pop("default_app") == "_yunohost_portal_with_public_apps"
+
+ if portal_settings["public"]:
+ ssowat_conf = read_json("/etc/ssowat/conf.json")
+ portal_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 portal_settings["show_other_domains_apps"]:
+ portal_settings["apps"] = {
+ name: data
+ for name, data in portal_settings["apps"].items()
+ if portal_settings["domain"] in data["url"]
+ }
+
+ return portal_settings
+
+
+def portal_me():
+ """
+ Get user informations
+ """
+ username, domain, user, _ = _get_user_infos(
+ ["cn", "mail", "maildrop", "mailuserquota", "memberOf", "permission"]
+ )
+
+ 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"]
+ }
+
+ 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],
+ "mail": user["mail"][0],
+ "mailalias": user["mail"][1:],
+ "mailforward": user["maildrop"][1:],
+ "groups": groups,
+ "apps": apps,
+ }
+
+ # 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 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, domain, current_user, ldap_interface = _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] and mail not in mails:
+ mails.append(mail)
+ continue # already in mails, skip validation
+
+ local_part, domain = mail.split("@")
+ if local_part in ADMIN_ALIASES:
+ raise YunohostValidationError(
+ "mail_unavailable", path=f"mailalias[{index}]"
+ )
+
+ try:
+ 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 YunohostValidationError(
+ "mail_domain_unknown", domain=domain, path=f"mailalias[{index}]"
+ )
+
+ 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]
+ ]
+
+ 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_interface.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:],
+ }
diff --git a/src/utils/configpanel.py b/src/utils/configpanel.py
index 09e42a8ca..86dea2e7d 100644
--- a/src/utils/configpanel.py
+++ b/src/utils/configpanel.py
@@ -386,6 +386,7 @@ class ConfigPanel:
"filter",
"readonly",
"enabled",
+ "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 e2e01ca12..f201f507b 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.add_yunohost_portal_to_choices = question.get("add_yunohost_portal_to_choices", False)
apps = app_list(full=True)["apps"]
@@ -929,6 +930,9 @@ class AppOption(BaseChoicesOption):
return app["label"] + domain_path_or_id
self.choices = {"_none": "---"}
+ 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})
diff --git a/src/utils/ldap.py b/src/utils/ldap.py
index 6b41cdb22..11141dcb0 100644
--- a/src/utils/ldap.py
+++ b/src/utils/ldap.py
@@ -39,7 +39,7 @@ def _get_ldap_interface():
global _ldap_interface
if _ldap_interface is None:
- _ldap_interface = LDAPInterface()
+ _ldap_interface = LDAPInterface(user="root")
return _ldap_interface
@@ -68,22 +68,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=2
+ URI, retry_max=10, retry_delay=2
)
- con.sasl_non_interactive_bind_s("EXTERNAL")
+ self._connect(con)
return con
try:
@@ -110,7 +122,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
@@ -136,9 +148,9 @@ class LDAPInterface:
"""
if not base:
- base = self.basedn
+ base = BASEDN
else:
- base = base + "," + self.basedn
+ base = base + "," + BASEDN
try:
result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs)
@@ -185,7 +197,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):
@@ -216,7 +228,7 @@ class LDAPInterface:
Boolean | MoulinetteError
"""
- dn = rdn + "," + self.basedn
+ dn = f"{rdn},{BASEDN}"
try:
self.con.delete_s(dn)
except Exception as e:
@@ -241,7 +253,7 @@ class LDAPInterface:
Boolean | MoulinetteError
"""
- dn = rdn + "," + self.basedn
+ dn = f"{rdn},{BASEDN}"
actual_entry = self.search(rdn, attrs=None)
ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1)