mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Rework the authenticator system, split the LDAP stuff into auth part and utils part
This commit is contained in:
parent
b85d959d7e
commit
50a42e1d87
3 changed files with 336 additions and 49 deletions
|
@ -33,18 +33,9 @@
|
|||
# Global parameters #
|
||||
#############################
|
||||
_global:
|
||||
configuration:
|
||||
authenticate:
|
||||
- api
|
||||
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
|
||||
authentication:
|
||||
api: ldap_admin
|
||||
cli: null
|
||||
arguments:
|
||||
-v:
|
||||
full: --version
|
||||
|
@ -1404,9 +1395,9 @@ tools:
|
|||
postinstall:
|
||||
action_help: YunoHost post-install
|
||||
api: POST /postinstall
|
||||
configuration:
|
||||
authentication:
|
||||
# We need to be able to run the postinstall without being authenticated, otherwise we can't run the postinstall
|
||||
authenticate: false
|
||||
api: null
|
||||
arguments:
|
||||
-d:
|
||||
full: --domain
|
||||
|
|
75
src/yunohost/authenticators/ldap_admin.py
Normal file
75
src/yunohost/authenticators/ldap_admin.py
Normal 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()
|
|
@ -1,5 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
""" License
|
||||
|
||||
Copyright (C) 2019 YunoHost
|
||||
|
@ -21,10 +20,18 @@
|
|||
|
||||
import os
|
||||
import atexit
|
||||
from moulinette.core import MoulinetteLdapIsDownError
|
||||
from moulinette.authenticators import ldap
|
||||
import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger("yunohost.utils.ldap")
|
||||
|
||||
# We use a global variable to do some caching
|
||||
# to avoid re-authenticating in case we call _get_ldap_authenticator multiple times
|
||||
_ldap_interface = None
|
||||
|
@ -35,47 +42,17 @@ def _get_ldap_interface():
|
|||
global _ldap_interface
|
||||
|
||||
if _ldap_interface is None:
|
||||
|
||||
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()
|
||||
_ldap_interface = LDAPInterface()
|
||||
|
||||
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
|
||||
# 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]
|
||||
#
|
||||
# e.g. using _ldap_path_extract(path, "foo") on the previous example will
|
||||
# return bar
|
||||
|
||||
|
||||
def _ldap_path_extract(path, info):
|
||||
for element in path.split(","):
|
||||
if element.startswith(info + "="):
|
||||
|
@ -93,3 +70,247 @@ def _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
|
||||
|
|
Loading…
Add table
Reference in a new issue