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
|
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
|
||||||
|
|
||||||
|
|
|
@ -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}/*")))
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue