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()