mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
portalapi: implement a proper expiration/prolong mechanism for session cookies
This commit is contained in:
parent
213d6416b6
commit
356c081a4f
5 changed files with 93 additions and 38 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
|
@ -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)):
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
Loading…
Add table
Reference in a new issue