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

View file

@ -16,11 +16,14 @@
# 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/>.
#
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 <user_hash> + 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}/*")))

View file

@ -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"

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