Rework the authenticator system, split the LDAP stuff into auth part and utils part

This commit is contained in:
Alexandre Aubin 2021-03-09 05:45:24 +01:00
parent b85d959d7e
commit 50a42e1d87
3 changed files with 336 additions and 49 deletions

View file

@ -33,18 +33,9 @@
# Global parameters # # Global parameters #
############################# #############################
_global: _global:
configuration: authentication:
authenticate: api: ldap_admin
- api cli: null
authenticator:
default:
vendor: ldap
help: admin_password
parameters:
uri: ldap://localhost:389
base_dn: dc=yunohost,dc=org
user_rdn: cn=admin,dc=yunohost,dc=org
argument_auth: false
arguments: arguments:
-v: -v:
full: --version full: --version
@ -1404,9 +1395,9 @@ tools:
postinstall: postinstall:
action_help: YunoHost post-install action_help: YunoHost post-install
api: POST /postinstall api: POST /postinstall
configuration: authentication:
# We need to be able to run the postinstall without being authenticated, otherwise we can't run the postinstall # We need to be able to run the postinstall without being authenticated, otherwise we can't run the postinstall
authenticate: false api: null
arguments: arguments:
-d: -d:
full: --domain full: --domain

View file

@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
import os
import logging
import ldap
import ldap.sasl
import time
import ldap.modlist as modlist
from moulinette import m18n
from moulinette.core import MoulinetteError
from moulinette.authentication import BaseAuthenticator
from yunohost.utils.error import YunohostError
logger = logging.getLogger("yunohost.authenticators.lpda_admin")
class Authenticator(BaseAuthenticator):
"""LDAP Authenticator
Initialize a LDAP connexion for the given arguments. It attempts to
authenticate a user if 'user_rdn' is given - by associating user_rdn
and base_dn - and provides extra methods to manage opened connexion.
Keyword arguments:
- uri -- The LDAP server URI
- base_dn -- The base dn
- user_rdn -- The user rdn to authenticate
"""
name = "ldap_admin"
def __init__(self, *args, **kwargs):
self.uri = "ldap://localhost:389"
self.basedn = "dc=yunohost,dc=org"
self.admindn = "cn=admin,dc=yunohost,dc=org"
def authenticate(self, password=None):
def _reconnect():
con = ldap.ldapobject.ReconnectLDAPObject(
self.uri, retry_max=10, retry_delay=0.5
)
con.simple_bind_s(self.admindn, password)
return con
try:
con = _reconnect()
except ldap.INVALID_CREDENTIALS:
raise MoulinetteError("invalid_password")
except ldap.SERVER_DOWN:
# ldap is down, attempt to restart it before really failing
logger.warning(m18n.g("ldap_server_is_down_restart_it"))
os.system("systemctl restart slapd")
time.sleep(10) # waits 10 secondes so we are sure that slapd has restarted
try:
con = _reconnect()
except ldap.SERVER_DOWN:
raise YunohostError("ldap_server_down")
# Check that we are indeed logged in with the expected identity
try:
# whoami_s return dn:..., then delete these 3 characters
who = con.whoami_s()[3:]
except Exception as e:
logger.warning("Error during ldap authentication process: %s", e)
raise
else:
if who != self.admindn:
raise MoulinetteError(f"Not logged with the appropriate identity ? Found {who}, expected {self.admindn} !?")
finally:
# 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()

View file

@ -1,5 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" License """ License
Copyright (C) 2019 YunoHost Copyright (C) 2019 YunoHost
@ -21,10 +20,18 @@
import os import os
import atexit import atexit
from moulinette.core import MoulinetteLdapIsDownError import logging
from moulinette.authenticators import ldap import ldap
import ldap.sasl
import time
import ldap.modlist as modlist
from moulinette import m18n
from moulinette.core import MoulinetteError
from yunohost.utils.error import YunohostError from yunohost.utils.error import YunohostError
logger = logging.getLogger("yunohost.utils.ldap")
# We use a global variable to do some caching # We use a global variable to do some caching
# to avoid re-authenticating in case we call _get_ldap_authenticator multiple times # to avoid re-authenticating in case we call _get_ldap_authenticator multiple times
_ldap_interface = None _ldap_interface = None
@ -35,47 +42,17 @@ def _get_ldap_interface():
global _ldap_interface global _ldap_interface
if _ldap_interface is None: if _ldap_interface is None:
_ldap_interface = LDAPInterface()
conf = {
"vendor": "ldap",
"name": "as-root",
"parameters": {
"uri": "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi",
"base_dn": "dc=yunohost,dc=org",
"user_rdn": "gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth",
},
"extra": {},
}
try:
_ldap_interface = ldap.Authenticator(**conf)
except MoulinetteLdapIsDownError:
raise YunohostError(
"Service slapd is not running but is required to perform this action ... You can try to investigate what's happening with 'systemctl status slapd'"
)
assert_slapd_is_running()
return _ldap_interface return _ldap_interface
def assert_slapd_is_running():
# Assert slapd is running...
if not os.system("pgrep slapd >/dev/null") == 0:
raise YunohostError(
"Service slapd is not running but is required to perform this action ... You can try to investigate what's happening with 'systemctl status slapd'"
)
# We regularly want to extract stuff like 'bar' in ldap path like # We regularly want to extract stuff like 'bar' in ldap path like
# foo=bar,dn=users.example.org,ou=example.org,dc=org so this small helper allow # foo=bar,dn=users.example.org,ou=example.org,dc=org so this small helper allow
# to do this without relying of dozens of mysterious string.split()[0] # to do this without relying of dozens of mysterious string.split()[0]
# #
# e.g. using _ldap_path_extract(path, "foo") on the previous example will # e.g. using _ldap_path_extract(path, "foo") on the previous example will
# return bar # return bar
def _ldap_path_extract(path, info): def _ldap_path_extract(path, info):
for element in path.split(","): for element in path.split(","):
if element.startswith(info + "="): if element.startswith(info + "="):
@ -93,3 +70,247 @@ def _destroy_ldap_interface():
atexit.register(_destroy_ldap_interface) atexit.register(_destroy_ldap_interface)
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"
self.connect()
def connect(self):
def _reconnect():
con = ldap.ldapobject.ReconnectLDAPObject(
self.uri, retry_max=10, retry_delay=0.5
)
con.sasl_non_interactive_bind_s("EXTERNAL")
return con
try:
con = _reconnect()
except ldap.SERVER_DOWN:
# ldap is down, attempt to restart it before really failing
logger.warning(m18n.g("ldap_server_is_down_restart_it"))
os.system("systemctl restart slapd")
time.sleep(10) # waits 10 secondes so we are sure that slapd has restarted
try:
con = _reconnect()
except ldap.SERVER_DOWN:
raise YunohostError(
"Service slapd is not running but is required to perform this action ... "
"You can try to investigate what's happening with 'systemctl status slapd'"
)
# Check that we are indeed logged in with the right identity
try:
# whoami_s return dn:..., then delete these 3 characters
who = con.whoami_s()[3:]
except Exception as e:
logger.warning("Error during ldap authentication process: %s", e)
raise
else:
if who != self.rootdn:
raise MoulinetteError("Not logged in with the expected userdn ?!")
else:
self.con = con
def __del__(self):
"""Disconnect and free ressources"""
if hasattr(self, "con") and self.con:
self.con.unbind_s()
def search(self, base=None, filter="(objectClass=*)", attrs=["dn"]):
"""Search in LDAP base
Perform an LDAP search operation with given arguments and return
results as a list.
Keyword arguments:
- base -- The dn to search into
- filter -- A string representation of the filter to apply
- attrs -- A list of attributes to fetch
Returns:
A list of all results
"""
if not base:
base = self.basedn
try:
result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs)
except Exception as e:
raise MoulinetteError(
"error during LDAP search operation with: base='%s', "
"filter='%s', attrs=%s and exception %s" % (base, filter, attrs, e),
raw_msg=True,
)
result_list = []
if not attrs or "dn" not in attrs:
result_list = [entry for dn, entry in result]
else:
for dn, entry in result:
entry["dn"] = [dn]
result_list.append(entry)
def decode(value):
if isinstance(value, bytes):
value = value.decode("utf-8")
return value
# result_list is for example :
# [{'virtualdomain': [b'test.com']}, {'virtualdomain': [b'yolo.test']},
for stuff in result_list:
if isinstance(stuff, dict):
for key, values in stuff.items():
stuff[key] = [decode(v) for v in values]
return result_list
def add(self, rdn, attr_dict):
"""
Add LDAP entry
Keyword arguments:
rdn -- DN without domain
attr_dict -- Dictionnary of attributes/values to add
Returns:
Boolean | MoulinetteError
"""
dn = rdn + "," + self.basedn
ldif = modlist.addModlist(attr_dict)
for i, (k, v) in enumerate(ldif):
if isinstance(v, list):
v = [a.encode("utf-8") for a in v]
elif isinstance(v, str):
v = [v.encode("utf-8")]
ldif[i] = (k, v)
try:
self.con.add_s(dn, ldif)
except Exception as e:
raise MoulinetteError(
"error during LDAP add operation with: rdn='%s', "
"attr_dict=%s and exception %s" % (rdn, attr_dict, e),
raw_msg=True,
)
else:
return True
def remove(self, rdn):
"""
Remove LDAP entry
Keyword arguments:
rdn -- DN without domain
Returns:
Boolean | MoulinetteError
"""
dn = rdn + "," + self.basedn
try:
self.con.delete_s(dn)
except Exception as e:
raise MoulinetteError(
"error during LDAP delete operation with: rdn='%s' and exception %s"
% (rdn, e),
raw_msg=True,
)
else:
return True
def update(self, rdn, attr_dict, new_rdn=False):
"""
Modify LDAP entry
Keyword arguments:
rdn -- DN without domain
attr_dict -- Dictionnary of attributes/values to add
new_rdn -- New RDN for modification
Returns:
Boolean | MoulinetteError
"""
dn = rdn + "," + self.basedn
actual_entry = self.search(base=dn, attrs=None)
ldif = modlist.modifyModlist(actual_entry[0], attr_dict, ignore_oldexistent=1)
if ldif == []:
logger.debug("Nothing to update in LDAP")
return True
try:
if new_rdn:
self.con.rename_s(dn, new_rdn)
new_base = dn.split(",", 1)[1]
dn = new_rdn + "," + new_base
for i, (a, k, vs) in enumerate(ldif):
if isinstance(vs, list):
vs = [v.encode("utf-8") for v in vs]
elif isinstance(vs, str):
vs = [vs.encode("utf-8")]
ldif[i] = (a, k, vs)
self.con.modify_ext_s(dn, ldif)
except Exception as e:
raise MoulinetteError(
"error during LDAP update operation with: rdn='%s', "
"attr_dict=%s, new_rdn=%s and exception: %s"
% (rdn, attr_dict, new_rdn, e),
raw_msg=True,
)
else:
return True
def validate_uniqueness(self, value_dict):
"""
Check uniqueness of values
Keyword arguments:
value_dict -- Dictionnary of attributes/values to check
Returns:
Boolean | MoulinetteError
"""
attr_found = self.get_conflict(value_dict)
if attr_found:
logger.info(
"attribute '%s' with value '%s' is not unique",
attr_found[0],
attr_found[1],
)
raise MoulinetteError(
"ldap_attribute_already_exists",
attribute=attr_found[0],
value=attr_found[1],
)
return True
def get_conflict(self, value_dict, base_dn=None):
"""
Check uniqueness of values
Keyword arguments:
value_dict -- Dictionnary of attributes/values to check
Returns:
None | tuple with Fist conflict attribute name and value
"""
for attr, value in value_dict.items():
if not self.search(base=base_dn, filter=attr + "=" + value):
continue
else:
return (attr, value)
return None