From 1efb50c7abc0141b0f6325ae155a653ed9027ff0 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 25 Dec 2021 15:44:14 +0100 Subject: [PATCH] Iterate on new portal API design: nginx config, cookie format, be able to open a non-root ldap session, --- conf/nginx/plain/yunohost_sso.conf.inc | 4 +- conf/nginx/yunohost_api.conf.inc | 20 +++++++ debian/control | 3 +- share/actionsmap-portal.yml | 3 +- src/authenticators/ldap_ynhuser.py | 83 +++++++++++++++++++++++++- src/portal.py | 12 ++-- src/utils/ldap.py | 38 ++++++++---- 7 files changed, 141 insertions(+), 22 deletions(-) diff --git a/conf/nginx/plain/yunohost_sso.conf.inc b/conf/nginx/plain/yunohost_sso.conf.inc index 308e5a9a4..984440679 100644 --- a/conf/nginx/plain/yunohost_sso.conf.inc +++ b/conf/nginx/plain/yunohost_sso.conf.inc @@ -2,6 +2,6 @@ rewrite ^/yunohost/sso$ /yunohost/sso/ permanent; location /yunohost/sso/ { - # This is an empty location, only meant to avoid other locations - # from matching /yunohost/sso, such that it's correctly handled by ssowat + alias /usr/share/ssowat/portal/; + index index.html; } diff --git a/conf/nginx/yunohost_api.conf.inc b/conf/nginx/yunohost_api.conf.inc index c9ae34f82..3a463c23b 100644 --- a/conf/nginx/yunohost_api.conf.inc +++ b/conf/nginx/yunohost_api.conf.inc @@ -23,3 +23,23 @@ location = /yunohost/api/error/502 { add_header Content-Type text/plain; internal; } + +location /yunohost/portalapi/ { + proxy_read_timeout 3600s; + proxy_pass http://127.0.0.1:6788/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + + # Custom 502 error page + error_page 502 /yunohost/portalapi/error/502; +} + + +# Yunohost admin output complete 502 error page, so use only plain text. +location = /yunohost/portalapi/error/502 { + return 502 '502 - Bad Gateway'; + add_header Content-Type text/plain; + internal; +} diff --git a/debian/control b/debian/control index 31204a180..8a9d841c5 100644 --- a/debian/control +++ b/debian/control @@ -14,7 +14,8 @@ Depends: ${python3:Depends}, ${misc:Depends} , python3-psutil, python3-requests, python3-dnspython, python3-openssl , python3-miniupnpc, python3-dbus, python3-jinja2 , python3-toml, python3-packaging, python3-publicsuffix2 - , python3-ldap, python3-zeroconf, python3-lexicon, + , python3-ldap, python3-zeroconf, python3-lexicon + , python3-jwt , python-is-python3 , nginx, nginx-extras (>=1.18) , apt, apt-transport-https, apt-utils, dirmngr diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml index 3d07656ae..761d5a6ce 100644 --- a/share/actionsmap-portal.yml +++ b/share/actionsmap-portal.yml @@ -1,9 +1,10 @@ _global: namespace: yunohost - cookie_name: yunohost.portal authentication: api: ldap_ynhuser cli: null + lock: false + cache: false portal: category_help: Portal routes diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py index 50dca3cc9..28b8c49fd 100644 --- a/src/authenticators/ldap_ynhuser.py +++ b/src/authenticators/ldap_ynhuser.py @@ -1,12 +1,17 @@ # -*- coding: utf-8 -*- +import jwt import logging import ldap import ldap.sasl +import datetime from moulinette import m18n from moulinette.authentication import BaseAuthenticator -from yunohost.utils.error import YunohostError +from moulinette.utils.text import random_ascii +from yunohost.utils.error import YunohostError, YunohostAuthenticationError + +session_secret = random_ascii() logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") @@ -57,3 +62,79 @@ class Authenticator(BaseAuthenticator): # Free the connection, we don't really need it to keep it open as the point is only to check authentication... if con: con.unbind_s() + + + + + + + + # FIXME FIXME FIXME : the password is to be encrypted to not expose it in the JWT cookie which is only signed and base64 encoded but not encrypted + + + + + + + + + + return {"user": username, "password": password} + + def set_session_cookie(self, infos): + + from bottle import response + + assert isinstance(infos, dict) + + # 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"], + # See https://pyjwt.readthedocs.io/en/latest/usage.html#registered-claim-names + # for explanations regarding nbf, exp + "nbf": int(datetime.datetime.now().timestamp()), + "exp": int(datetime.datetime.now().timestamp()) + (7 * 24 * 3600) # One week validity + } + new_infos.update(infos) + + response.set_cookie( + "yunohost.portal", + jwt.encode(new_infos, session_secret, algorithm="HS256").decode(), + secure=True, + httponly=True, + path="/", + # samesite="strict", # Bottle 0.12 doesn't support samesite, to be added in next versions + # FIXME : add Expire clause + ) + + def get_session_cookie(self, raise_if_no_session_exists=True): + + from bottle import request + + try: + token = request.get_cookie("yunohost.portal", default="").encode() + infos = jwt.decode(token, session_secret, algorithms="HS256", options={"require": ["id", "user", "exp", "nbf"]}) + 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: + raise YunohostAuthenticationError("unable_authenticate") + + if "id" not in infos: + infos["id"] = random_ascii() + + # 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... + + return infos + + @staticmethod + def delete_session_cookie(self): + + from bottle import response + + response.set_cookie("yunohost.portal", "", max_age=-1) + response.delete_cookie("yunohost.portal") diff --git a/src/portal.py b/src/portal.py index 4a2b449b2..2eaa59dd4 100644 --- a/src/portal.py +++ b/src/portal.py @@ -22,12 +22,14 @@ # from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger +from yunohost.authenticators.ldap_ynhuser import Authenticator as Auth +from yunohost.utils.ldap import LDAPInterface from yunohost.utils.error import YunohostValidationError logger = getActionLogger("yunohostportal.user") -def me(): +def portal_me(): """ Get user informations @@ -36,11 +38,13 @@ def me(): """ - username = None # FIXME : this info should come from the authentication layer + import pdb; pdb.set_trace() - from yunohost.utils.ldap import _get_ldap_interface + auth = Auth().get_session_cookie() + username = auth["user"] + password = auth["password"] - ldap = _get_ldap_interface() + ldap = LDAPInterface(username, password) user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"] diff --git a/src/utils/ldap.py b/src/utils/ldap.py index 651d09f75..852fa89c2 100644 --- a/src/utils/ldap.py +++ b/src/utils/ldap.py @@ -42,7 +42,7 @@ def _get_ldap_interface(): global _ldap_interface if _ldap_interface is None: - _ldap_interface = LDAPInterface() + _ldap_interface = LDAPInterface(user="root") return _ldap_interface @@ -71,22 +71,34 @@ def _destroy_ldap_interface(): atexit.register(_destroy_ldap_interface) +URI = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi" +BASEDN = "dc=yunohost,dc=org" +ROOTDN = "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" +USERDN = "uid={username},ou=users,dc=yunohost,dc=org" + class LDAPInterface: - def __init__(self): - logger.debug("initializing ldap interface") - self.uri = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi" - self.basedn = "dc=yunohost,dc=org" - self.rootdn = "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" + def __init__(self, user="root", password=None): + + if user == "root": + logger.debug("initializing root ldap interface") + self.userdn = ROOTDN + self._connect = lambda con: con.sasl_non_interactive_bind_s("EXTERNAL") + else: + logger.debug("initializing user ldap interface") + self.userdn = USERDN.format(username=user) + self._connect = lambda con: con.simple_bind_s(self.userdn, password) + self.connect() def connect(self): + def _reconnect(): con = ldap.ldapobject.ReconnectLDAPObject( - self.uri, retry_max=10, retry_delay=0.5 + URI, retry_max=10, retry_delay=0.5 ) - con.sasl_non_interactive_bind_s("EXTERNAL") + self._connect(con) return con try: @@ -113,7 +125,7 @@ class LDAPInterface: logger.warning("Error during ldap authentication process: %s", e) raise else: - if who != self.rootdn: + if who != self.userdn: raise MoulinetteError("Not logged in with the expected userdn ?!") else: self.con = con @@ -139,7 +151,7 @@ class LDAPInterface: """ if not base: - base = self.basedn + base = BASEDN try: result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs) @@ -184,7 +196,7 @@ class LDAPInterface: Boolean | MoulinetteError """ - dn = rdn + "," + self.basedn + dn = f"{rdn},{BASEDN}" ldif = modlist.addModlist(attr_dict) for i, (k, v) in enumerate(ldif): if isinstance(v, list): @@ -215,7 +227,7 @@ class LDAPInterface: Boolean | MoulinetteError """ - dn = rdn + "," + self.basedn + dn = f"{rdn},{BASEDN}" try: self.con.delete_s(dn) except Exception as e: @@ -240,7 +252,7 @@ class LDAPInterface: Boolean | MoulinetteError """ - dn = rdn + "," + self.basedn + dn = f"{rdn},{BASEDN}" actual_entry = self.search(base=dn, attrs=None) ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1)