mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge pull request #1387 from YunoHost/portal-api
WIP : New portal API to partially replace SSOwat
This commit is contained in:
commit
a4366e88fc
20 changed files with 871 additions and 39 deletions
53
bin/yunohost-portal-api
Executable file
53
bin/yunohost-portal-api
Executable file
|
@ -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)
|
|
@ -1,8 +1,12 @@
|
||||||
# Insert YunoHost button + portal overlay
|
# This is some old code that worked with the old portal
|
||||||
sub_filter </head> '<script type="text/javascript" src="/ynh_portal.js"></script><link type="text/css" rel="stylesheet" href="/ynh_overlay.css"><script type="text/javascript" src="/ynhtheme/custom_portal.js"></script><link type="text/css" rel="stylesheet" href="/ynhtheme/custom_overlay.css"></head>';
|
# We need to rethink wether we want to keep something similar,
|
||||||
sub_filter_once on;
|
# or drop the feature
|
||||||
# Apply to other mime types than text/html
|
|
||||||
sub_filter_types application/xhtml+xml;
|
# # Insert YunoHost button + portal overlay
|
||||||
# Prevent YunoHost panel files from being blocked by specific app rules
|
# sub_filter </head> '<script type="text/javascript" src="/ynh_portal.js"></script><link type="text/css" rel="stylesheet" href="/ynh_overlay.css"><script type="text/javascript" src="/ynhtheme/custom_portal.js"></script><link type="text/css" rel="stylesheet" href="/ynhtheme/custom_overlay.css"></head>';
|
||||||
location ~ (ynh_portal.js|ynh_overlay.css|ynh_userinfo.json|ynhtheme/custom_portal.js|ynhtheme/custom_overlay.css) {
|
# 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) {
|
||||||
|
# }
|
||||||
|
|
|
@ -2,6 +2,16 @@
|
||||||
rewrite ^/yunohost/sso$ /yunohost/sso/ permanent;
|
rewrite ^/yunohost/sso$ /yunohost/sso/ permanent;
|
||||||
|
|
||||||
location /yunohost/sso/ {
|
location /yunohost/sso/ {
|
||||||
# This is an empty location, only meant to avoid other locations
|
alias /usr/share/yunohost/portal/;
|
||||||
# from matching /yunohost/sso, such that it's correctly handled by ssowat
|
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:;";
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,3 +23,24 @@ location = /yunohost/api/error/502 {
|
||||||
add_header Content-Type text/plain;
|
add_header Content-Type text/plain;
|
||||||
internal;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -51,6 +51,9 @@ ssh:
|
||||||
test_conf: sshd -t
|
test_conf: sshd -t
|
||||||
needs_exposed_ports: [22]
|
needs_exposed_ports: [22]
|
||||||
category: admin
|
category: admin
|
||||||
|
yunohost-portal-api:
|
||||||
|
log: /var/log/yunohost-portal-api.log
|
||||||
|
category: userportal
|
||||||
yunohost-api:
|
yunohost-api:
|
||||||
log: /var/log/yunohost/yunohost-api.log
|
log: /var/log/yunohost/yunohost-api.log
|
||||||
category: admin
|
category: admin
|
||||||
|
|
48
conf/yunohost/yunohost-portal-api.service
Normal file
48
conf/yunohost/yunohost-portal-api.service
Normal file
|
@ -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
|
5
debian/control
vendored
5
debian/control
vendored
|
@ -10,11 +10,12 @@ Package: yunohost
|
||||||
Essential: yes
|
Essential: yes
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: ${python3:Depends}, ${misc:Depends}
|
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-psutil, python3-requests, python3-dnspython, python3-openssl
|
||||||
, python3-miniupnpc, python3-dbus, python3-jinja2
|
, python3-miniupnpc, python3-dbus, python3-jinja2
|
||||||
, python3-toml, python3-packaging, python3-publicsuffix2
|
, 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
|
, python-is-python3
|
||||||
, nginx, nginx-extras (>=1.22)
|
, nginx, nginx-extras (>=1.22)
|
||||||
, apt, apt-transport-https, apt-utils, dirmngr
|
, apt, apt-transport-https, apt-utils, dirmngr
|
||||||
|
|
2
debian/postinst
vendored
2
debian/postinst
vendored
|
@ -33,6 +33,8 @@ do_configure() {
|
||||||
yunohost tools update apps --output-as none || true
|
yunohost tools update apps --output-as none || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
systemctl restart yunohost-portal-api
|
||||||
|
|
||||||
# Trick to let yunohost handle the restart of the API,
|
# Trick to let yunohost handle the restart of the API,
|
||||||
# to prevent the webadmin from cutting the branch it's sitting on
|
# to prevent the webadmin from cutting the branch it's sitting on
|
||||||
if systemctl is-enabled yunohost-api --quiet
|
if systemctl is-enabled yunohost-api --quiet
|
||||||
|
|
|
@ -56,7 +56,10 @@ do_init_regen() {
|
||||||
chown root:root /var/cache/yunohost
|
chown root:root /var/cache/yunohost
|
||||||
chmod 700 /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-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 yunohost-firewall.service /etc/systemd/system/yunohost-firewall.service
|
||||||
cp yunoprompt.service /etc/systemd/system/yunoprompt.service
|
cp yunoprompt.service /etc/systemd/system/yunoprompt.service
|
||||||
|
|
||||||
|
@ -64,6 +67,10 @@ do_init_regen() {
|
||||||
|
|
||||||
systemctl enable yunohost-api.service --quiet
|
systemctl enable yunohost-api.service --quiet
|
||||||
systemctl start 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
|
# Yunohost-firewall is enabled only during postinstall, not init, not 100% sure why
|
||||||
|
|
||||||
cp dpkg-origins /etc/dpkg/origins/yunohost
|
cp dpkg-origins /etc/dpkg/origins/yunohost
|
||||||
|
@ -155,6 +162,7 @@ HandleLidSwitchExternalPower=ignore
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cp yunohost-api.service ${pending_dir}/etc/systemd/system/yunohost-api.service
|
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 yunohost-firewall.service ${pending_dir}/etc/systemd/system/yunohost-firewall.service
|
||||||
cp yunoprompt.service ${pending_dir}/etc/systemd/system/yunoprompt.service
|
cp yunoprompt.service ${pending_dir}/etc/systemd/system/yunoprompt.service
|
||||||
cp proc-hidepid.service ${pending_dir}/etc/systemd/system/proc-hidepid.service
|
cp proc-hidepid.service ${pending_dir}/etc/systemd/system/proc-hidepid.service
|
||||||
|
@ -167,6 +175,18 @@ EOF
|
||||||
do_post_regen() {
|
do_post_regen() {
|
||||||
regen_conf_files=$1
|
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 #
|
# Enfore permissions #
|
||||||
######################
|
######################
|
||||||
|
@ -236,6 +256,7 @@ do_post_regen() {
|
||||||
systemctl restart ntp
|
systemctl restart ntp
|
||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ ! "$regen_conf_files" =~ "nftables.service.d/ynh-override.conf" ]] || systemctl daemon-reload
|
[[ ! "$regen_conf_files" =~ "nftables.service.d/ynh-override.conf" ]] || systemctl daemon-reload
|
||||||
[[ ! "$regen_conf_files" =~ "login.conf.d/ynh-override.conf" ]] || {
|
[[ ! "$regen_conf_files" =~ "login.conf.d/ynh-override.conf" ]] || {
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
@ -243,6 +264,7 @@ do_post_regen() {
|
||||||
}
|
}
|
||||||
[[ ! "$regen_conf_files" =~ "yunohost-firewall.service" ]] || 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-api.service" ]] || systemctl daemon-reload
|
||||||
|
[[ ! "$regen_conf_files" =~ "yunohost-portal-api.service" ]] || systemctl daemon-reload
|
||||||
|
|
||||||
if [[ "$regen_conf_files" =~ "yunoprompt.service" ]]; then
|
if [[ "$regen_conf_files" =~ "yunoprompt.service" ]]; then
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
@ -255,6 +277,9 @@ do_post_regen() {
|
||||||
systemctl $action proc-hidepid --quiet --now
|
systemctl $action proc-hidepid --quiet --now
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
systemctl enable yunohost-portal-api.service --quiet
|
||||||
|
systemctl is-active yunohost-portal-api --quiet || systemctl start yunohost-portal-api.service
|
||||||
|
|
||||||
# Change dpkg vendor
|
# Change dpkg vendor
|
||||||
# see https://wiki.debian.org/Derivatives/Guidelines#Vendor
|
# see https://wiki.debian.org/Derivatives/Guidelines#Vendor
|
||||||
if readlink -f /etc/dpkg/origins/default | grep -q debian;
|
if readlink -f /etc/dpkg/origins/default | grep -q debian;
|
||||||
|
|
92
share/actionsmap-portal.yml
Normal file
92
share/actionsmap-portal.yml
Normal file
|
@ -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
|
|
@ -4,11 +4,33 @@ i18n = "domain_config"
|
||||||
[feature]
|
[feature]
|
||||||
name = "Features"
|
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]
|
||||||
[feature.app.default_app]
|
[feature.app.default_app]
|
||||||
type = "app"
|
type = "app"
|
||||||
filter = "is_webapp"
|
filter = "is_webapp"
|
||||||
default = "_none"
|
default = "_none"
|
||||||
|
add_yunohost_portal_to_choices = true
|
||||||
|
|
||||||
[feature.mail]
|
[feature.mail]
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,13 @@ def cli(debug, quiet, output_as, timeout, args, parser):
|
||||||
|
|
||||||
|
|
||||||
def api(debug, host, port):
|
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)
|
init_logging(interface="api", debug=debug)
|
||||||
|
|
||||||
def is_installed_api():
|
def is_installed_api():
|
||||||
|
@ -64,6 +71,28 @@ def api(debug, host, port):
|
||||||
actionsmap="/usr/share/yunohost/actionsmap.yml",
|
actionsmap="/usr/share/yunohost/actionsmap.yml",
|
||||||
locales_dir="/usr/share/yunohost/locales/",
|
locales_dir="/usr/share/yunohost/locales/",
|
||||||
routes={("GET", "/installed"): is_installed_api},
|
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)
|
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",
|
"level": "DEBUG" if debug else "INFO",
|
||||||
"class": "moulinette.interfaces.api.APIQueueHandler",
|
"class": "moulinette.interfaces.api.APIQueueHandler",
|
||||||
},
|
},
|
||||||
|
"portalapi": {
|
||||||
|
"level": "DEBUG" if debug else "INFO",
|
||||||
|
"class": "moulinette.interfaces.api.APIQueueHandler",
|
||||||
|
},
|
||||||
"file": {
|
"file": {
|
||||||
"class": "logging.FileHandler",
|
"class": "logging.FileHandler",
|
||||||
"formatter": "precise",
|
"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...) #
|
# Logging configuration for CLI (or any other interface than api...) #
|
||||||
if interface != "api":
|
if interface not in ["api", "portalapi"]:
|
||||||
configure_logging(logging_configuration)
|
configure_logging(logging_configuration)
|
||||||
|
|
||||||
# Logging configuration for API #
|
# Logging configuration for API #
|
||||||
|
|
17
src/app.py
17
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.permission import user_permission_list
|
||||||
from yunohost.settings import settings_get
|
from yunohost.settings import settings_get
|
||||||
|
|
||||||
|
@ -1625,6 +1625,7 @@ def app_ssowatconf():
|
||||||
"public": True,
|
"public": True,
|
||||||
"uris": [domain + "/yunohost/admin" for domain in domains]
|
"uris": [domain + "/yunohost/admin" for domain in domains]
|
||||||
+ [domain + "/yunohost/api" for domain in domains]
|
+ [domain + "/yunohost/api" for domain in domains]
|
||||||
|
+ [domain + "/yunohost/portalapi" for domain in domains]
|
||||||
+ [
|
+ [
|
||||||
"re:^[^/]/502%.html$",
|
"re:^[^/]/502%.html$",
|
||||||
"re:^[^/]*/%.well%-known/ynh%-diagnosis/.*$",
|
"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 = {
|
redirected_regex = {
|
||||||
main_domain + r"/yunohost[\/]?$": "https://" + main_domain + "/yunohost/sso/"
|
main_domain + r"/yunohost[\/]?$": "https://" + main_domain + "/yunohost/sso/"
|
||||||
}
|
}
|
||||||
|
@ -1698,18 +1701,12 @@ def app_ssowatconf():
|
||||||
}
|
}
|
||||||
|
|
||||||
conf_dict = {
|
conf_dict = {
|
||||||
|
"cookie_secret_file": "/etc/yunohost/.ssowat_cookie_secret",
|
||||||
|
"cookie_name": "yunohost.portal",
|
||||||
"theme": settings_get("misc.portal.portal_theme"),
|
"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_urls": redirected_urls,
|
||||||
"redirected_regex": redirected_regex,
|
"redirected_regex": redirected_regex,
|
||||||
|
"domain_portal_urls": _get_domain_portal_dict(),
|
||||||
"permissions": permissions,
|
"permissions": permissions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -138,6 +138,7 @@ class Authenticator(BaseAuthenticator):
|
||||||
secure=True,
|
secure=True,
|
||||||
secret=session_secret,
|
secret=session_secret,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
|
path="/"
|
||||||
# samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions
|
# 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):
|
def delete_session_cookie(self):
|
||||||
from bottle import response
|
from bottle import response
|
||||||
|
|
||||||
response.set_cookie("yunohost.admin", "", max_age=-1)
|
response.delete_cookie("yunohost.admin", path="/")
|
||||||
response.delete_cookie("yunohost.admin")
|
|
||||||
|
|
176
src/authenticators/ldap_ynhuser.py
Normal file
176
src/authenticators/ldap_ynhuser.py
Normal file
|
@ -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 <password_enc_b64>|<iv_b64>
|
||||||
|
# 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="/")
|
|
@ -100,6 +100,26 @@ def _get_domains(exclude_subdomains=False):
|
||||||
return domain_list_cache
|
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=[]):
|
def domain_list(exclude_subdomains=False, tree=False, features=[]):
|
||||||
"""
|
"""
|
||||||
List domains
|
List domains
|
||||||
|
@ -709,6 +729,55 @@ class DomainConfigPanel(ConfigPanel):
|
||||||
other_app=app_map(raw=True)[self.entity]["/"]["id"],
|
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()
|
super()._apply()
|
||||||
|
|
||||||
# Reload ssowat if default app changed
|
# Reload ssowat if default app changed
|
||||||
|
|
259
src/portal.py
Normal file
259
src/portal.py
Normal file
|
@ -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:],
|
||||||
|
}
|
|
@ -386,6 +386,7 @@ class ConfigPanel:
|
||||||
"filter",
|
"filter",
|
||||||
"readonly",
|
"readonly",
|
||||||
"enabled",
|
"enabled",
|
||||||
|
"add_yunohost_portal_to_choices",
|
||||||
# "confirm", # TODO: to ask confirmation before running an action
|
# "confirm", # TODO: to ask confirmation before running an action
|
||||||
],
|
],
|
||||||
"defaults": {},
|
"defaults": {},
|
||||||
|
|
|
@ -914,6 +914,7 @@ class AppOption(BaseChoicesOption):
|
||||||
|
|
||||||
super().__init__(question)
|
super().__init__(question)
|
||||||
self.filter = question.get("filter", None)
|
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"]
|
apps = app_list(full=True)["apps"]
|
||||||
|
|
||||||
|
@ -929,6 +930,9 @@ class AppOption(BaseChoicesOption):
|
||||||
return app["label"] + domain_path_or_id
|
return app["label"] + domain_path_or_id
|
||||||
|
|
||||||
self.choices = {"_none": "---"}
|
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})
|
self.choices.update({app["id"]: _app_display(app) for app in apps})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,7 @@ def _get_ldap_interface():
|
||||||
global _ldap_interface
|
global _ldap_interface
|
||||||
|
|
||||||
if _ldap_interface is None:
|
if _ldap_interface is None:
|
||||||
_ldap_interface = LDAPInterface()
|
_ldap_interface = LDAPInterface(user="root")
|
||||||
|
|
||||||
return _ldap_interface
|
return _ldap_interface
|
||||||
|
|
||||||
|
@ -68,22 +68,34 @@ def _destroy_ldap_interface():
|
||||||
|
|
||||||
atexit.register(_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:
|
class LDAPInterface:
|
||||||
def __init__(self):
|
|
||||||
logger.debug("initializing ldap interface")
|
|
||||||
|
|
||||||
self.uri = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi"
|
def __init__(self, user="root", password=None):
|
||||||
self.basedn = "dc=yunohost,dc=org"
|
|
||||||
self.rootdn = "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth"
|
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()
|
self.connect()
|
||||||
|
|
||||||
def connect(self):
|
def connect(self):
|
||||||
|
|
||||||
def _reconnect():
|
def _reconnect():
|
||||||
con = ldap.ldapobject.ReconnectLDAPObject(
|
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
|
return con
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -110,7 +122,7 @@ class LDAPInterface:
|
||||||
logger.warning("Error during ldap authentication process: %s", e)
|
logger.warning("Error during ldap authentication process: %s", e)
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
if who != self.rootdn:
|
if who != self.userdn:
|
||||||
raise MoulinetteError("Not logged in with the expected userdn ?!")
|
raise MoulinetteError("Not logged in with the expected userdn ?!")
|
||||||
else:
|
else:
|
||||||
self.con = con
|
self.con = con
|
||||||
|
@ -136,9 +148,9 @@ class LDAPInterface:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not base:
|
if not base:
|
||||||
base = self.basedn
|
base = BASEDN
|
||||||
else:
|
else:
|
||||||
base = base + "," + self.basedn
|
base = base + "," + BASEDN
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs)
|
result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs)
|
||||||
|
@ -185,7 +197,7 @@ class LDAPInterface:
|
||||||
Boolean | MoulinetteError
|
Boolean | MoulinetteError
|
||||||
|
|
||||||
"""
|
"""
|
||||||
dn = rdn + "," + self.basedn
|
dn = f"{rdn},{BASEDN}"
|
||||||
ldif = modlist.addModlist(attr_dict)
|
ldif = modlist.addModlist(attr_dict)
|
||||||
for i, (k, v) in enumerate(ldif):
|
for i, (k, v) in enumerate(ldif):
|
||||||
if isinstance(v, list):
|
if isinstance(v, list):
|
||||||
|
@ -216,7 +228,7 @@ class LDAPInterface:
|
||||||
Boolean | MoulinetteError
|
Boolean | MoulinetteError
|
||||||
|
|
||||||
"""
|
"""
|
||||||
dn = rdn + "," + self.basedn
|
dn = f"{rdn},{BASEDN}"
|
||||||
try:
|
try:
|
||||||
self.con.delete_s(dn)
|
self.con.delete_s(dn)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -241,7 +253,7 @@ class LDAPInterface:
|
||||||
Boolean | MoulinetteError
|
Boolean | MoulinetteError
|
||||||
|
|
||||||
"""
|
"""
|
||||||
dn = rdn + "," + self.basedn
|
dn = f"{rdn},{BASEDN}"
|
||||||
actual_entry = self.search(rdn, attrs=None)
|
actual_entry = self.search(rdn, attrs=None)
|
||||||
ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1)
|
ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue