portalapi: implement a proper expiration/prolong mechanism for session cookies

This commit is contained in:
Alexandre Aubin 2023-11-28 18:03:32 +01:00
parent 213d6416b6
commit 356c081a4f
5 changed files with 93 additions and 38 deletions

View file

@ -75,6 +75,12 @@ do_init_regen() {
chmod 550 /usr/share/yunohost/portallogos chmod 550 /usr/share/yunohost/portallogos
chown ynh-portal:www-data /usr/share/yunohost/portallogos chown ynh-portal:www-data /usr/share/yunohost/portallogos
mkdir -p /var/cache/yunohost-portal/sessions
chown ynh-portal /var/cache/yunohost-portal
chmod 500 /var/cache/yunohost-portal
chown ynh-portal /var/cache/yunohost-portal/sessions
chmod 700 /var/cache/yunohost-portal/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
@ -260,6 +266,12 @@ do_post_regen() {
chmod 550 /usr/share/yunohost/portallogos chmod 550 /usr/share/yunohost/portallogos
chown ynh-portal:www-data /usr/share/yunohost/portallogos chown ynh-portal:www-data /usr/share/yunohost/portallogos
mkdir -p /var/cache/yunohost-portal/sessions
chown ynh-portal /var/cache/yunohost-portal
chmod 500 /var/cache/yunohost-portal
chown ynh-portal /var/cache/yunohost-portal/sessions
chmod 700 /var/cache/yunohost-portal/sessions
# Domain settings # Domain settings
mkdir -p /etc/yunohost/domains mkdir -p /etc/yunohost/domains

View file

@ -1707,6 +1707,7 @@ def app_ssowatconf():
conf_dict = { conf_dict = {
"cookie_secret_file": "/etc/yunohost/.ssowat_cookie_secret", "cookie_secret_file": "/etc/yunohost/.ssowat_cookie_secret",
"session_folder": "/var/cache/yunohost-portal/sessions",
"cookie_name": "yunohost.portal", "cookie_name": "yunohost.portal",
"redirected_urls": redirected_urls, "redirected_urls": redirected_urls,
"domain_portal_urls": _get_domain_portal_dict(), "domain_portal_urls": _get_domain_portal_dict(),

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time
import jwt import jwt
import logging import logging
import ldap import ldap
@ -7,21 +8,21 @@ import ldap.sasl
import datetime import datetime
import base64 import base64
import os import os
import hashlib
import glob
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives import padding
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from moulinette import m18n from moulinette import m18n
from moulinette.authentication import BaseAuthenticator 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
# FIXME : we shall generate this somewhere if it doesnt exists yet SESSION_SECRET = open("/etc/yunohost/.ssowat_cookie_secret").read().strip()
# FIXME : fix permissions SESSION_FOLDER = "/var/cache/yunohost-portal/sessions"
session_secret = open("/etc/yunohost/.ssowat_cookie_secret").read().strip() SESSION_VALIDITY = 3 * 24 * 3600 # 3 days
logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser")
URI = "ldap://localhost:389" URI = "ldap://localhost:389"
@ -37,12 +38,12 @@ USERDN = "uid={username},ou=users,dc=yunohost,dc=org"
# To do so, we use AES-256-CBC. As it's a block encryption algorithm, it requires an IV, # 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. # 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) # 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> # The result is a string formatted as <password_enc_b64>|<iv_b64>
# For example: ctl8kk5GevYdaA5VZ2S88Q==|yTAzCx0Gd1+MCit4EQl9lA== # For example: ctl8kk5GevYdaA5VZ2S88Q==|yTAzCx0Gd1+MCit4EQl9lA==
def encrypt(data): def encrypt(data):
alg = algorithms.AES(session_secret.encode()) alg = algorithms.AES(SESSION_SECRET.encode())
iv = os.urandom(int(alg.block_size / 8)) iv = os.urandom(int(alg.block_size / 8))
E = Cipher(alg, modes.CBC(iv), default_backend()).encryptor() E = Cipher(alg, modes.CBC(iv), default_backend()).encryptor()
@ -59,7 +60,7 @@ def decrypt(data_enc_and_iv_b64):
data_enc = base64.b64decode(data_enc_b64) data_enc = base64.b64decode(data_enc_b64)
iv = base64.b64decode(iv_b64) iv = base64.b64decode(iv_b64)
alg = algorithms.AES(session_secret.encode()) alg = algorithms.AES(SESSION_SECRET.encode())
D = Cipher(alg, modes.CBC(iv), default_backend()).decryptor() D = Cipher(alg, modes.CBC(iv), default_backend()).decryptor()
p = padding.PKCS7(alg.block_size).unpadder() p = padding.PKCS7(alg.block_size).unpadder()
data_padded = D.update(data_enc) data_padded = D.update(data_enc)
@ -67,6 +68,10 @@ def decrypt(data_enc_and_iv_b64):
return data.decode() return data.decode()
def short_hash(data):
return hashlib.shake_256(data.encode()).hexdigest(20)
class Authenticator(BaseAuthenticator): class Authenticator(BaseAuthenticator):
name = "ldap_ynhuser" name = "ldap_ynhuser"
@ -113,64 +118,92 @@ class Authenticator(BaseAuthenticator):
from bottle import response, request from bottle import response, request
assert isinstance(infos, dict) assert isinstance(infos, dict)
assert "user" in infos
assert "pwd" 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 = { # (eg because the user gets deleted, or password gets changed)
"id": current_infos["id"], # User hashing not really meant for security, just to sort of anonymize/pseudonymize the session file name
# See https://pyjwt.readthedocs.io/en/latest/usage.html#registered-claim-names infos["id"] = short_hash(infos['user']) + random_ascii(20)
# for explanations regarding nbf, exp infos["host"] = request.get_header("host")
"nbf": int(datetime.datetime.now().timestamp()),
# FIXME : does it mean the session suddenly expires after a week ? Can we somehow auto-renew it at every usage or something ?
"exp": int(datetime.datetime.now().timestamp())
+ (7 * 24 * 3600), # One week validity
"host": request.get_header("host"),
}
new_infos.update(infos)
response.set_cookie( response.set_cookie(
"yunohost.portal", "yunohost.portal",
jwt.encode(new_infos, session_secret, algorithm="HS256"), jwt.encode(infos, SESSION_SECRET, algorithm="HS256"),
secure=True, secure=True,
httponly=True, httponly=True,
path="/", path="/",
# samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions samesite="strict", # Doesn't this cause issues ? May cause issue if the portal is on different subdomain than the portal API ? Will surely cause issue for development similar to CORS ?
# FIXME : add Expire clause
) )
def get_session_cookie(self, raise_if_no_session_exists=True, decrypt_pwd=False): # Create the session file (expiration mechanism)
session_file = f'{SESSION_FOLDER}/{infos["id"]}'
os.system(f'touch "{session_file}"')
def get_session_cookie(self, decrypt_pwd=False):
from bottle import request from bottle import request
try: try:
token = request.get_cookie("yunohost.portal", default="").encode() token = request.get_cookie("yunohost.portal", default="").encode()
infos = jwt.decode( infos = jwt.decode(
token, token,
session_secret, SESSION_SECRET,
algorithms="HS256", algorithms="HS256",
options={"require": ["id", "user", "exp", "nbf"]}, options={"require": ["id", "host", "user", "pwd"]},
) )
except Exception: 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") 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: if infos["host"] != request.get_header("host"):
infos["id"] = random_ascii() raise YunohostAuthenticationError("unable_authenticate")
self.purge_expired_session_files()
session_file = f'{SESSION_FOLDER}/{infos["id"]}'
if not os.path.exists(session_file):
response.delete_cookie("yunohost.portal", path="/")
raise YunohostAuthenticationError("session_expired")
# Otherwise, we 'touch' the file to extend the validity
os.system(f'touch "{session_file}"')
if decrypt_pwd: if decrypt_pwd:
infos["pwd"] = decrypt(infos["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 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}")
session_file = f'{SESSION_FOLDER}/{infos["id"]}'
os.system(f'touch "{session_file}"')
response.delete_cookie("yunohost.portal", path="/") response.delete_cookie("yunohost.portal", 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:
os.remove(path)
except Exception as e:
logger.debug(f"Failed to delete session file {path} ? {e}")

View file

@ -246,7 +246,6 @@ def portal_update(
except YunohostValidationError as e: except YunohostValidationError as e:
raise YunohostValidationError(e.key, path="newpassword") raise YunohostValidationError(e.key, path="newpassword")
Auth().delete_session_cookie()
new_attr_dict["userPassword"] = [_hash_user_password(newpassword)] new_attr_dict["userPassword"] = [_hash_user_password(newpassword)]
try: try:
@ -254,6 +253,9 @@ def portal_update(
except Exception as e: except Exception as e:
raise YunohostError("user_update_failed", user=username, error=e) raise YunohostError("user_update_failed", user=username, error=e)
if "userPassword" in new_attr_dict:
Auth.invalidate_all_sessions_for_user(username)
# FIXME: Here we could want to trigger "post_user_update" hook but hooks has to # FIXME: Here we could want to trigger "post_user_update" hook but hooks has to
# be run as root # be run as root
if all(field is not None for field in (fullname, mailalias, mailforward)): if all(field is not None for field in (fullname, mailalias, mailforward)):

View file

@ -306,6 +306,7 @@ def user_create(
def user_delete(operation_logger, username, purge=False, from_import=False): 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
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)
@ -334,6 +335,8 @@ def user_delete(operation_logger, username, purge=False, from_import=False):
except Exception as e: except Exception as e:
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)
# 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"])
@ -532,6 +535,10 @@ def user_update(
except Exception as e: except Exception as e:
raise YunohostError("user_update_failed", user=username, error=e) raise YunohostError("user_update_failed", user=username, error=e)
if "userPassword" in new_attr_dict:
from yunohost.authenticators.ldap_ynhuser import Authenticator as PortalAuth
PortalAuth.invalidate_all_sessions_for_user(username)
# Invalidate passwd and group to update the loginShell # Invalidate passwd and group to update the loginShell
subprocess.call(["nscd", "-i", "passwd"]) subprocess.call(["nscd", "-i", "passwd"])
subprocess.call(["nscd", "-i", "group"]) subprocess.call(["nscd", "-i", "group"])