mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
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:
parent
d1022b1a6c
commit
3922ba9c68
4 changed files with 96 additions and 32 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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}/*")))
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue