Implement similar cookie mechanism for admin api (compared to portal) with static secret (cookies aint invalidated by api restart) and rolling session validity

This commit is contained in:
Alexandre Aubin 2023-12-19 20:01:25 +01:00
parent d1022b1a6c
commit 3922ba9c68
4 changed files with 96 additions and 32 deletions

View file

@ -60,12 +60,6 @@ do_init_regen() {
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 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 # Portal folder
mkdir -p /etc/yunohost/portal mkdir -p /etc/yunohost/portal
@ -81,6 +75,11 @@ do_init_regen() {
chown ynh-portal:www-data /var/cache/yunohost-portal/sessions chown ynh-portal:www-data /var/cache/yunohost-portal/sessions
chmod 710 /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 # YunoHost services
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-portal-api.service /etc/systemd/system/yunohost-portal-api.service
@ -201,6 +200,12 @@ EOF
do_post_regen() { do_post_regen() {
regen_conf_files=$1 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 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 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 # 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 chown ynh-portal:www-data /var/cache/yunohost-portal/sessions
chmod 710 /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 # Domain settings
mkdir -p /etc/yunohost/domains mkdir -p /etc/yunohost/domains

View file

@ -16,11 +16,14 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
import jwt
import os import os
import logging import logging
import ldap import ldap
import ldap.sasl import ldap.sasl
import time import time
import glob
import hashlib
from moulinette import m18n from moulinette import m18n
from moulinette.authentication import BaseAuthenticator 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.error import YunohostError, YunohostAuthenticationError
from yunohost.utils.ldap import _get_ldap_interface from yunohost.utils.ldap import _get_ldap_interface
session_secret = random_ascii()
logger = logging.getLogger("yunohost.authenticators.ldap_admin") 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" LDAP_URI = "ldap://localhost:389"
ADMIN_GROUP = "cn=admins,ou=groups" ADMIN_GROUP = "cn=admins,ou=groups"
AUTH_DN = "uid={uid},ou=users,dc=yunohost,dc=org" 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): class Authenticator(BaseAuthenticator):
name = "ldap_admin" name = "ldap_admin"
@ -122,55 +132,90 @@ class Authenticator(BaseAuthenticator):
if con: if con:
con.unbind_s() con.unbind_s()
return {"user": uid}
def set_session_cookie(self, infos): def set_session_cookie(self, infos):
from bottle import response from bottle import response, request
assert isinstance(infos, dict) assert isinstance(infos, dict)
assert "user" in infos
# This allows to generate a new session id or keep the existing one # Create a session id, built as <user_hash> + some random ascii
current_infos = self.get_session_cookie(raise_if_no_session_exists=False) # Prefixing with the user hash is meant to provide the ability to invalidate all this user's session
new_infos = {"id": current_infos["id"]} # (eg because the user gets deleted, or password gets changed)
new_infos.update(infos) # 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( response.set_cookie(
"yunohost.admin", "yunohost.admin",
new_infos, jwt.encode(infos, SESSION_SECRET, algorithm="HS256"),
secure=True, secure=True,
secret=session_secret,
httponly=True, httponly=True,
path="/" path="/",
# samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions 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): def get_session_cookie(self, raise_if_no_session_exists=True):
from bottle import request from bottle import request, response
try: try:
# N.B. : here we implicitly reauthenticate the cookie token = request.get_cookie("yunohost.admin", default="").encode()
# because it's signed via the session_secret infos = jwt.decode(
# If no session exists (or if session is invalid?) token,
# it's gonna return the default empty dict, SESSION_SECRET,
# which we interpret as an authentication failure algorithms="HS256",
infos = request.get_cookie( options={"require": ["id", "user"]},
"yunohost.admin", secret=session_secret, default={}
) )
except Exception: except Exception:
if not raise_if_no_session_exists:
return {"id": random_ascii()}
raise YunohostAuthenticationError("unable_authenticate") raise YunohostAuthenticationError("unable_authenticate")
if not infos and raise_if_no_session_exists: if not infos:
raise YunohostAuthenticationError("unable_authenticate") raise YunohostAuthenticationError("unable_authenticate")
if "id" not in infos: self.purge_expired_session_files()
infos["id"] = random_ascii() 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 # Otherwise, we 'touch' the file to extend the validity
# For example to check that the username authenticated is still in the admin group... os.system(f'touch "{session_file}"')
return infos return infos
def delete_session_cookie(self): def delete_session_cookie(self):
from bottle import response 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="/") 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}/*")))

View file

@ -20,10 +20,11 @@ from moulinette.authentication import BaseAuthenticator
from moulinette.utils.text import random_ascii from moulinette.utils.text import random_ascii
from yunohost.utils.error import YunohostError, YunohostAuthenticationError 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_SECRET = open("/etc/yunohost/.ssowat_cookie_secret").read().strip()
SESSION_FOLDER = "/var/cache/yunohost-portal/sessions" SESSION_FOLDER = "/var/cache/yunohost-portal/sessions"
SESSION_VALIDITY = 3 * 24 * 3600 # 3 days SESSION_VALIDITY = 3 * 24 * 3600 # 3 days
logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser")
URI = "ldap://localhost:389" URI = "ldap://localhost:389"
USERDN = "uid={username},ou=users,dc=yunohost,dc=org" USERDN = "uid={username},ou=users,dc=yunohost,dc=org"

View file

@ -306,6 +306,7 @@ def user_delete(operation_logger, username, purge=False, from_import=False):
from yunohost.hook import hook_callback from yunohost.hook import hook_callback
from yunohost.utils.ldap import _get_ldap_interface from yunohost.utils.ldap import _get_ldap_interface
from yunohost.authenticators.ldap_ynhuser import Authenticator as PortalAuth 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"]: if username not in user_list()["users"]:
raise YunohostValidationError("user_unknown", user=username) 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) raise YunohostError("user_deletion_failed", user=username, error=e)
PortalAuth.invalidate_all_sessions_for_user(username) PortalAuth.invalidate_all_sessions_for_user(username)
AdminAuth.invalidate_all_sessions_for_user(username)
# Invalidate passwd to take user deletion into account # Invalidate passwd to take user deletion into account
subprocess.call(["nscd", "-i", "passwd"]) subprocess.call(["nscd", "-i", "passwd"])
@ -535,6 +537,7 @@ def user_update(
raise YunohostError("user_update_failed", user=username, error=e) raise YunohostError("user_update_failed", user=username, error=e)
if "userPassword" in new_attr_dict: if "userPassword" in new_attr_dict:
logger.info("Invalidating sessions")
from yunohost.authenticators.ldap_ynhuser import Authenticator as PortalAuth from yunohost.authenticators.ldap_ynhuser import Authenticator as PortalAuth
PortalAuth.invalidate_all_sessions_for_user(username) PortalAuth.invalidate_all_sessions_for_user(username)
@ -1273,6 +1276,11 @@ def user_group_update(
except Exception as e: except Exception as e:
raise YunohostError("group_update_failed", group=groupname, error=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: if sync_perm:
permission_sync_to_user() permission_sync_to_user()