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;
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;
}

View file

@ -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;
}

3
debian/control vendored
View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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"]

View file

@ -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)