From 3922ba9c68bd48aaa5cc630c86f2a5b3877a61eb Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Tue, 19 Dec 2023 20:01:25 +0100 Subject: [PATCH] Implement similar cookie mechanism for admin api (compared to portal) with static secret (cookies aint invalidated by api restart) and rolling session validity --- hooks/conf_regen/01-yunohost | 22 +++++-- src/authenticators/ldap_admin.py | 95 ++++++++++++++++++++++-------- src/authenticators/ldap_ynhuser.py | 3 +- src/user.py | 8 +++ 4 files changed, 96 insertions(+), 32 deletions(-) diff --git a/hooks/conf_regen/01-yunohost b/hooks/conf_regen/01-yunohost index 45d0b58f6..e4bf2f0f2 100755 --- a/hooks/conf_regen/01-yunohost +++ b/hooks/conf_regen/01-yunohost @@ -60,12 +60,6 @@ do_init_regen() { chmod 700 /var/cache/yunohost 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 # Portal folder mkdir -p /etc/yunohost/portal @@ -81,6 +75,11 @@ do_init_regen() { chown ynh-portal:www-data /var/cache/yunohost-portal/sessions chmod 710 /var/cache/yunohost-portal/sessions + # Admin sessions + mkdir -p /var/cache/yunohost/sessions + chown root:root /var/cache/yunohost/sessions + chmod 700 /var/cache/yunohost/sessions + # YunoHost services cp yunohost-api.service /etc/systemd/system/yunohost-api.service cp yunohost-portal-api.service /etc/systemd/system/yunohost-portal-api.service @@ -201,6 +200,12 @@ EOF do_post_regen() { regen_conf_files=$1 + if [ ! -e /etc/yunohost/.admin_cookie_secret ]; then + dd if=/dev/urandom bs=1 count=1000 2>/dev/null | tr --complement --delete 'A-Za-z0-9' | head -c 64 > /etc/yunohost/.admin_cookie_secret + fi + chown root:root /etc/yunohost/.admin_cookie_secret + chmod 400 /etc/yunohost/.admin_cookie_secret + 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 @@ -272,6 +277,11 @@ do_post_regen() { chown ynh-portal:www-data /var/cache/yunohost-portal/sessions chmod 710 /var/cache/yunohost-portal/sessions + # Admin sessions + mkdir -p /var/cache/yunohost/sessions + chown root:root /var/cache/yunohost/sessions + chmod 700 /var/cache/yunohost/sessions + # Domain settings mkdir -p /etc/yunohost/domains diff --git a/src/authenticators/ldap_admin.py b/src/authenticators/ldap_admin.py index 155e84127..40416b07a 100644 --- a/src/authenticators/ldap_admin.py +++ b/src/authenticators/ldap_admin.py @@ -16,11 +16,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # +import jwt import os import logging import ldap import ldap.sasl import time +import glob +import hashlib from moulinette import m18n from moulinette.authentication import BaseAuthenticator @@ -29,14 +32,21 @@ from moulinette.utils.text import random_ascii from yunohost.utils.error import YunohostError, YunohostAuthenticationError from yunohost.utils.ldap import _get_ldap_interface -session_secret = random_ascii() logger = logging.getLogger("yunohost.authenticators.ldap_admin") +SESSION_SECRET = open("/etc/yunohost/.admin_cookie_secret").read().strip() +SESSION_FOLDER = "/var/cache/yunohost/sessions" +SESSION_VALIDITY = 3 * 24 * 3600 # 3 days + LDAP_URI = "ldap://localhost:389" ADMIN_GROUP = "cn=admins,ou=groups" AUTH_DN = "uid={uid},ou=users,dc=yunohost,dc=org" +def short_hash(data): + return hashlib.shake_256(data.encode()).hexdigest(20) + + class Authenticator(BaseAuthenticator): name = "ldap_admin" @@ -122,55 +132,90 @@ class Authenticator(BaseAuthenticator): if con: con.unbind_s() + return {"user": uid} + def set_session_cookie(self, infos): - from bottle import response + from bottle import response, request assert isinstance(infos, dict) + assert "user" in infos - # 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"]} - new_infos.update(infos) + # Create a session id, built as + some random ascii + # Prefixing with the user hash is meant to provide the ability to invalidate all this user's session + # (eg because the user gets deleted, or password gets changed) + # User hashing not really meant for security, just to sort of anonymize/pseudonymize the session file name + infos["id"] = short_hash(infos['user']) + random_ascii(20) response.set_cookie( "yunohost.admin", - new_infos, + jwt.encode(infos, SESSION_SECRET, algorithm="HS256"), secure=True, - secret=session_secret, httponly=True, - path="/" - # samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions + path="/", + samesite="strict", ) + # Create the session file (expiration mechanism) + session_file = f'{SESSION_FOLDER}/{infos["id"]}' + os.system(f'touch "{session_file}"') + def get_session_cookie(self, raise_if_no_session_exists=True): - from bottle import request + from bottle import request, response try: - # N.B. : here we implicitly reauthenticate the cookie - # because it's signed via the session_secret - # If no session exists (or if session is invalid?) - # it's gonna return the default empty dict, - # which we interpret as an authentication failure - infos = request.get_cookie( - "yunohost.admin", secret=session_secret, default={} + token = request.get_cookie("yunohost.admin", default="").encode() + infos = jwt.decode( + token, + SESSION_SECRET, + algorithms="HS256", + options={"require": ["id", "user"]}, ) except Exception: - if not raise_if_no_session_exists: - return {"id": random_ascii()} raise YunohostAuthenticationError("unable_authenticate") - if not infos and raise_if_no_session_exists: + if not infos: raise YunohostAuthenticationError("unable_authenticate") - if "id" not in infos: - infos["id"] = random_ascii() + self.purge_expired_session_files() + session_file = f'{SESSION_FOLDER}/{infos["id"]}' + if not os.path.exists(session_file): + response.delete_cookie("yunohost.admin", path="/") + raise YunohostAuthenticationError("session_expired") - # FIXME: Here, maybe we want to re-authenticate the session via the authenticator - # For example to check that the username authenticated is still in the admin group... + # Otherwise, we 'touch' the file to extend the validity + os.system(f'touch "{session_file}"') return infos def delete_session_cookie(self): from bottle import response + try: + infos = self.get_session_cookie() + session_file = f'{SESSION_FOLDER}/{infos["id"]}' + os.remove(session_file) + except Exception as e: + logger.debug(f"User logged out, but failed to properly invalidate the session : {e}") + response.delete_cookie("yunohost.admin", path="/") + + def purge_expired_session_files(self): + + for session_id in os.listdir(SESSION_FOLDER): + session_file = f"{SESSION_FOLDER}/{session_id}" + if abs(os.path.getctime(session_file) - time.time()) > SESSION_VALIDITY: + try: + os.remove(session_file) + except Exception as e: + logger.debug(f"Failed to delete session file {session_file} ? {e}") + + @staticmethod + def invalidate_all_sessions_for_user(user): + for path in glob.glob(f"{SESSION_FOLDER}/{short_hash(user)}*"): + try: + logger.info(path) + os.remove(path) + except Exception as e: + logger.debug(f"Failed to delete session file {path} ? {e}") + + logger.info(str(glob.glob(f"{SESSION_FOLDER}/*"))) diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 0a4ab0b75..8b14be0af 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -20,10 +20,11 @@ from moulinette.authentication import BaseAuthenticator from moulinette.utils.text import random_ascii from yunohost.utils.error import YunohostError, YunohostAuthenticationError +logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") + SESSION_SECRET = open("/etc/yunohost/.ssowat_cookie_secret").read().strip() SESSION_FOLDER = "/var/cache/yunohost-portal/sessions" SESSION_VALIDITY = 3 * 24 * 3600 # 3 days -logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") URI = "ldap://localhost:389" USERDN = "uid={username},ou=users,dc=yunohost,dc=org" diff --git a/src/user.py b/src/user.py index 46228805f..1b31df25a 100644 --- a/src/user.py +++ b/src/user.py @@ -306,6 +306,7 @@ def user_delete(operation_logger, username, purge=False, from_import=False): from yunohost.hook import hook_callback from yunohost.utils.ldap import _get_ldap_interface from yunohost.authenticators.ldap_ynhuser import Authenticator as PortalAuth + from yunohost.authenticators.ldap_admin import Authenticator as AdminAuth if username not in user_list()["users"]: raise YunohostValidationError("user_unknown", user=username) @@ -335,6 +336,7 @@ def user_delete(operation_logger, username, purge=False, from_import=False): raise YunohostError("user_deletion_failed", user=username, error=e) PortalAuth.invalidate_all_sessions_for_user(username) + AdminAuth.invalidate_all_sessions_for_user(username) # Invalidate passwd to take user deletion into account subprocess.call(["nscd", "-i", "passwd"]) @@ -535,6 +537,7 @@ def user_update( raise YunohostError("user_update_failed", user=username, error=e) if "userPassword" in new_attr_dict: + logger.info("Invalidating sessions") from yunohost.authenticators.ldap_ynhuser import Authenticator as PortalAuth PortalAuth.invalidate_all_sessions_for_user(username) @@ -1273,6 +1276,11 @@ def user_group_update( except Exception as e: raise YunohostError("group_update_failed", group=groupname, error=e) + if groupname == "admins" and remove: + from yunohost.authenticators.ldap_admin import Authenticator as AdminAuth + for user in users_to_remove: + AdminAuth.invalidate_all_sessions_for_user(user) + if sync_perm: permission_sync_to_user()