mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Iterate on new portal API design: nginx config, cookie format, be able to open a non-root ldap session,
This commit is contained in:
parent
c01042b51d
commit
1efb50c7ab
7 changed files with 141 additions and 22 deletions
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
3
debian/control
vendored
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue