Iterate on new portal API design: nginx config, cookie format, be able to open a non-root ldap session,

This commit is contained in:
Alexandre Aubin 2021-12-25 15:44:14 +01:00
parent c01042b51d
commit 1efb50c7ab
7 changed files with 141 additions and 22 deletions

View file

@ -2,6 +2,6 @@
rewrite ^/yunohost/sso$ /yunohost/sso/ permanent; rewrite ^/yunohost/sso$ /yunohost/sso/ permanent;
location /yunohost/sso/ { location /yunohost/sso/ {
# This is an empty location, only meant to avoid other locations alias /usr/share/ssowat/portal/;
# from matching /yunohost/sso, such that it's correctly handled by ssowat index index.html;
} }

View file

@ -23,3 +23,23 @@ location = /yunohost/api/error/502 {
add_header Content-Type text/plain; add_header Content-Type text/plain;
internal; 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;
}

3
debian/control vendored
View file

@ -14,7 +14,8 @@ Depends: ${python3:Depends}, ${misc:Depends}
, python3-psutil, python3-requests, python3-dnspython, python3-openssl , python3-psutil, python3-requests, python3-dnspython, python3-openssl
, python3-miniupnpc, python3-dbus, python3-jinja2 , python3-miniupnpc, python3-dbus, python3-jinja2
, python3-toml, python3-packaging, python3-publicsuffix2 , python3-toml, python3-packaging, python3-publicsuffix2
, python3-ldap, python3-zeroconf, python3-lexicon, , python3-ldap, python3-zeroconf, python3-lexicon
, python3-jwt
, python-is-python3 , python-is-python3
, nginx, nginx-extras (>=1.18) , nginx, nginx-extras (>=1.18)
, apt, apt-transport-https, apt-utils, dirmngr , apt, apt-transport-https, apt-utils, dirmngr

View file

@ -1,9 +1,10 @@
_global: _global:
namespace: yunohost namespace: yunohost
cookie_name: yunohost.portal
authentication: authentication:
api: ldap_ynhuser api: ldap_ynhuser
cli: null cli: null
lock: false
cache: false
portal: portal:
category_help: Portal routes category_help: Portal routes

View file

@ -1,12 +1,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import jwt
import logging import logging
import ldap import ldap
import ldap.sasl import ldap.sasl
import datetime
from moulinette import m18n from moulinette import m18n
from moulinette.authentication import BaseAuthenticator 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") 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... # Free the connection, we don't really need it to keep it open as the point is only to check authentication...
if con: if con:
con.unbind_s() 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")

View file

@ -22,12 +22,14 @@
# from moulinette import Moulinette, m18n # from moulinette import Moulinette, m18n
from moulinette.utils.log import getActionLogger 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 from yunohost.utils.error import YunohostValidationError
logger = getActionLogger("yunohostportal.user") logger = getActionLogger("yunohostportal.user")
def me(): def portal_me():
""" """
Get user informations 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"] user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"]

View file

@ -42,7 +42,7 @@ def _get_ldap_interface():
global _ldap_interface global _ldap_interface
if _ldap_interface is None: if _ldap_interface is None:
_ldap_interface = LDAPInterface() _ldap_interface = LDAPInterface(user="root")
return _ldap_interface return _ldap_interface
@ -71,22 +71,34 @@ def _destroy_ldap_interface():
atexit.register(_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: class LDAPInterface:
def __init__(self):
logger.debug("initializing ldap interface")
self.uri = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi" def __init__(self, user="root", password=None):
self.basedn = "dc=yunohost,dc=org"
self.rootdn = "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" 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() self.connect()
def connect(self): def connect(self):
def _reconnect(): def _reconnect():
con = ldap.ldapobject.ReconnectLDAPObject( 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 return con
try: try:
@ -113,7 +125,7 @@ class LDAPInterface:
logger.warning("Error during ldap authentication process: %s", e) logger.warning("Error during ldap authentication process: %s", e)
raise raise
else: else:
if who != self.rootdn: if who != self.userdn:
raise MoulinetteError("Not logged in with the expected userdn ?!") raise MoulinetteError("Not logged in with the expected userdn ?!")
else: else:
self.con = con self.con = con
@ -139,7 +151,7 @@ class LDAPInterface:
""" """
if not base: if not base:
base = self.basedn base = BASEDN
try: try:
result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs) result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs)
@ -184,7 +196,7 @@ class LDAPInterface:
Boolean | MoulinetteError Boolean | MoulinetteError
""" """
dn = rdn + "," + self.basedn dn = f"{rdn},{BASEDN}"
ldif = modlist.addModlist(attr_dict) ldif = modlist.addModlist(attr_dict)
for i, (k, v) in enumerate(ldif): for i, (k, v) in enumerate(ldif):
if isinstance(v, list): if isinstance(v, list):
@ -215,7 +227,7 @@ class LDAPInterface:
Boolean | MoulinetteError Boolean | MoulinetteError
""" """
dn = rdn + "," + self.basedn dn = f"{rdn},{BASEDN}"
try: try:
self.con.delete_s(dn) self.con.delete_s(dn)
except Exception as e: except Exception as e:
@ -240,7 +252,7 @@ class LDAPInterface:
Boolean | MoulinetteError Boolean | MoulinetteError
""" """
dn = rdn + "," + self.basedn dn = f"{rdn},{BASEDN}"
actual_entry = self.search(base=dn, attrs=None) actual_entry = self.search(base=dn, attrs=None)
ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1) ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1)