Rework and externalize the authenticator system

This commit is contained in:
Alexandre Aubin 2021-03-09 05:43:09 +01:00
parent f7199f7a64
commit af0957c028
8 changed files with 83 additions and 608 deletions

View file

@ -465,38 +465,32 @@ class ActionsMap(object):
self.extraparser = ExtraArgumentParser(top_parser.interface)
self.parser = self._construct_parser(actionsmaps, top_parser)
def get_authenticator_for_profile(self, auth_profile):
def get_authenticator(self, auth_method):
# Fetch the configuration for the authenticator module as defined in the actionmap
try:
auth_conf = self.parser.global_conf["authenticator"][auth_profile]
except KeyError:
raise ValueError("Unknown authenticator profile '%s'" % auth_profile)
if auth_method == "default":
auth_method = self.default_authentication
# Load and initialize the authenticator module
auth_module = "%s.authenticators.%s" % (self.main_namespace, auth_method)
logger.debug(f"Loading auth module {auth_module}")
try:
mod = import_module("moulinette.authenticators.%s" % auth_conf["vendor"])
except ImportError:
error_message = (
"unable to load authenticator vendor module 'moulinette.authenticators.%s'"
% auth_conf["vendor"]
)
logger.exception(error_message)
raise MoulinetteError(error_message, raw_msg=True)
mod = import_module(auth_module)
except ImportError as e:
import traceback
traceback.print_exc()
raise MoulinetteError(f"unable to load authenticator {auth_module} : {e}", raw_msg=True)
else:
return mod.Authenticator(**auth_conf)
return mod.Authenticator()
def check_authentication_if_required(self, args, **kwargs):
auth_profile = self.parser.auth_required(args, **kwargs)
auth_method = self.parser.auth_method(args, **kwargs)
if not auth_profile:
if auth_method is None:
return
authenticator = self.get_authenticator_for_profile(auth_profile)
auth = msignals.authenticate(authenticator)
if not auth.is_authenticated:
authenticator = self.get_authenticator(auth_method)
if not msignals.authenticate(authenticator):
raise MoulinetteError("authentication_required_long")
def process(self, args, timeout=None, **kwargs):
@ -681,6 +675,8 @@ class ActionsMap(object):
logger.debug("building parser...")
start = time()
interface_type = top_parser.interface
# If loading from cache, extra were already checked when cache was
# loaded ? Not sure about this ... old code is a bit mysterious...
validate_extra = not self.from_cache
@ -694,25 +690,26 @@ class ActionsMap(object):
# Retrieve global parameters
_global = actionsmap.pop("_global", {})
# Set the global configuration to use for the parser.
top_parser.set_global_conf(_global["configuration"])
if _global:
if getattr(self, "main_namespace", None) is not None:
raise MoulinetteError("It's not possible to have several namespaces with a _global section")
else:
self.main_namespace = namespace
self.default_authentication = _global["authentication"][interface_type]
if top_parser.has_global_parser():
top_parser.add_global_arguments(_global["arguments"])
if not hasattr(self, "main_namespace"):
raise MoulinetteError("Did not found the main namespace")
for namespace, actionsmap in actionsmaps.items():
# category_name is stuff like "user", "domain", "hooks"...
# category_values is the values of this category (like actions)
for category_name, category_values in actionsmap.items():
if "actions" in category_values:
actions = category_values.pop("actions")
else:
actions = {}
if "subcategories" in category_values:
subcategories = category_values.pop("subcategories")
else:
subcategories = {}
actions = category_values.pop("actions", {})
subcategories = category_values.pop("subcategories", {})
# Get category parser
category_parser = top_parser.add_category_parser(
@ -723,6 +720,7 @@ class ActionsMap(object):
# action_options are the values
for action_name, action_options in actions.items():
arguments = action_options.pop("arguments", {})
authentication = action_options.pop("authentication", {})
tid = (namespace, category_name, action_name)
# Get action parser
@ -742,8 +740,9 @@ class ActionsMap(object):
validate_extra=validate_extra,
)
if "configuration" in action_options:
category_parser.set_conf(tid, action_options["configuration"])
action_parser.authentication = self.default_authentication
if interface_type in authentication:
action_parser.authentication = authentication[interface_type]
# subcategory_name is like "cert" in "domain cert status"
# subcategory_values is the values of this subcategory (like actions)
@ -760,6 +759,7 @@ class ActionsMap(object):
# action_options are the values
for action_name, action_options in actions.items():
arguments = action_options.pop("arguments", {})
authentication = action_options.pop("authentication", {})
tid = (namespace, category_name, subcategory_name, action_name)
try:
@ -780,10 +780,9 @@ class ActionsMap(object):
validate_extra=validate_extra,
)
if "configuration" in action_options:
category_parser.set_conf(
tid, action_options["configuration"]
)
action_parser.authentication = self.default_authentication
if interface_type in authentication:
action_parser.authentication = authentication[interface_type]
logger.debug("building parser took %.3fs", time() - start)
return top_parser

View file

@ -27,28 +27,8 @@ class BaseAuthenticator(object):
must be given on instantiation - with the corresponding vendor
configuration of the authenticator.
Keyword arguments:
- name -- The authenticator profile name
"""
def __init__(self, name, vendor, parameters, extra):
self._name = name
self.vendor = vendor
self.is_authenticated = False
self.extra = extra
@property
def name(self):
"""Return the name of the authenticator instance"""
return self._name
# Virtual properties
# Each authenticator classes must implement these properties.
"""The vendor name of the authenticator"""
vendor = None
# Virtual methods
# Each authenticator classes must implement these methods.
@ -82,12 +62,12 @@ class BaseAuthenticator(object):
- password -- A clear text password
- token -- The session token in the form of (id, hash)
Returns:
The authenticated instance
"""
if self.is_authenticated:
return self
if hasattr(self, "is_authenticated"):
return self.is_authenticated
is_authenticated = False
#
# Authenticate using the password
@ -99,15 +79,10 @@ class BaseAuthenticator(object):
except MoulinetteError:
raise
except Exception as e:
logger.exception(
"authentication (name: '%s', vendor: '%s') fails because '%s'",
self.name,
self.vendor,
e,
)
logger.exception("authentication {self.name} failed because '{e}'")
raise MoulinetteError("unable_authenticate")
self.is_authenticated = True
else:
is_authenticated = True
# Store session for later using the provided (new) token if any
if token:
@ -133,15 +108,10 @@ class BaseAuthenticator(object):
except MoulinetteError:
raise
except Exception as e:
logger.exception(
"authentication (name: '%s', vendor: '%s') fails because '%s'",
self.name,
self.vendor,
e,
)
logger.exception("authentication {self.name} failed because '{e}'")
raise MoulinetteError("unable_authenticate")
else:
self.is_authenticated = True
is_authenticated = True
#
# No credentials given, can't authenticate
@ -149,7 +119,8 @@ class BaseAuthenticator(object):
else:
raise MoulinetteError("unable_authenticate")
return self
self.is_authenticated = is_authenticated
return is_authenticated
# Private methods

View file

@ -1,28 +0,0 @@
# -*- coding: utf-8 -*-
import logging
from moulinette.core import MoulinetteError
from moulinette.authenticators import BaseAuthenticator
logger = logging.getLogger("moulinette.authenticator.dummy")
# Dummy authenticator implementation
class Authenticator(BaseAuthenticator):
"""Dummy authenticator used for tests"""
vendor = "dummy"
def __init__(self, name, vendor, parameters, extra):
logger.debug("initialize authenticator dummy")
super(Authenticator, self).__init__(name, vendor, parameters, extra)
def authenticate(self, password=None):
if not password == self.name:
raise MoulinetteError("invalid_password")
return self

View file

@ -1,311 +0,0 @@
# -*- coding: utf-8 -*-
# TODO: Use Python3 to remove this fix!
from __future__ import absolute_import
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, MoulinetteLdapIsDownError
from moulinette.authenticators import BaseAuthenticator
logger = logging.getLogger("moulinette.authenticator.ldap")
# LDAP Class Implementation --------------------------------------------
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
"""
def __init__(self, name, vendor, parameters, extra):
self.uri = parameters["uri"]
self.basedn = parameters["base_dn"]
self.userdn = parameters["user_rdn"]
self.extra = extra
self.sasldn = "cn=external,cn=auth"
self.adminuser = "admin"
self.admindn = "cn=%s,dc=yunohost,dc=org" % self.adminuser
self.admindn = "cn=%s,dc=yunohost,dc=org" % self.adminuser
logger.debug(
"initialize authenticator '%s' with: uri='%s', "
"base_dn='%s', user_rdn='%s'",
name,
self._get_uri(),
self.basedn,
self.userdn,
)
super(Authenticator, self).__init__(name, vendor, parameters, extra)
if self.userdn and self.sasldn in self.userdn:
self.authenticate(None)
else:
self.con = None
def __del__(self):
"""Disconnect and free ressources"""
if hasattr(self, "con") and self.con:
self.con.unbind_s()
# Implement virtual properties
vendor = "ldap"
# Implement virtual methods
def authenticate(self, password=None):
def _reconnect():
con = ldap.ldapobject.ReconnectLDAPObject(
self._get_uri(), retry_max=10, retry_delay=0.5
)
if self.userdn:
if self.sasldn in self.userdn:
con.sasl_non_interactive_bind_s("EXTERNAL")
else:
con.simple_bind_s(self.userdn, password)
else:
con.simple_bind_s()
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 MoulinetteLdapIsDownError("ldap_server_down")
# 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:
# FIXME: During SASL bind whoami from the test server return the admindn while userdn is returned normally :
if not (who == self.admindn or who == self.userdn):
raise MoulinetteError("Not logged in with the expected userdn ?!")
else:
self.con = con
# Additional LDAP methods
# TODO: Review these methods
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
def _get_uri(self):
return self.uri

View file

@ -314,22 +314,10 @@ class MoulinetteSignals(object):
signals = {"authenticate", "prompt", "display"}
def authenticate(self, authenticator):
"""Process the authentication
Attempt to authenticate to the given authenticator and return
it.
It is called when authentication is needed (e.g. to process an
action).
Keyword arguments:
- authenticator -- The authenticator object to use
Returns:
The authenticator object
"""
if authenticator.is_authenticated:
return authenticator
if hasattr(authenticator, "is_authenticated"):
return authenticator.is_authenticated
# self._authenticate corresponds to the stuff defined with
# msignals.set_handler("authenticate", ...) per interface...
return self._authenticate(authenticator)
def prompt(self, message, is_password=False, confirm=False, color="blue"):
@ -396,10 +384,6 @@ class MoulinetteError(Exception):
return self.strerror
class MoulinetteLdapIsDownError(MoulinetteError):
"""Used when ldap is down"""
class MoulinetteLock(object):
"""Locker for a moulinette instance

View file

@ -35,16 +35,10 @@ class BaseActionsMapParser(object):
"""
def __init__(self, parent=None, **kwargs):
if parent:
self._o = parent
else:
if not parent:
logger.debug("initializing base actions map parser for %s", self.interface)
msettings["interface"] = self.interface
self._o = self
self._global_conf = {}
self._conf = {}
# Virtual properties
# Each parser classes must implement these properties.
@ -121,7 +115,7 @@ class BaseActionsMapParser(object):
"derived class '%s' must override this method" % self.__class__.__name__
)
def auth_required(self, args, **kwargs):
def auth_method(self, args, **kwargs):
"""Check if authentication is required to run the requested action
Keyword arguments:
@ -172,130 +166,6 @@ class BaseActionsMapParser(object):
return namespace
# Configuration access
@property
def global_conf(self):
"""Return the global configuration of the parser"""
return self._o._global_conf
def set_global_conf(self, configuration):
"""Set global configuration
Set the global configuration to use for the parser.
Keyword arguments:
- configuration -- The global configuration
"""
self._o._global_conf.update(self._validate_conf(configuration, True))
def get_conf(self, action, name):
"""Get the value of an action configuration
Return the formated value of configuration 'name' for the action
identified by 'action'. If the configuration for the action is
not set, the default one is returned.
Keyword arguments:
- action -- An action identifier
- name -- The configuration name
"""
try:
return self._o._conf[action][name]
except KeyError:
return self.global_conf[name]
def set_conf(self, action, configuration):
"""Set configuration for an action
Set the configuration to use for a given action identified by
'action' which is specific to the parser.
Keyword arguments:
- action -- The action identifier
- configuration -- The configuration for the action
"""
self._o._conf[action] = self._validate_conf(configuration)
def _validate_conf(self, configuration, is_global=False):
"""Validate configuration for the parser
Return the validated configuration for the interface's actions
map parser.
Keyword arguments:
- configuration -- The configuration to pre-format
"""
# TODO: Create a class with a validator method for each configuration
conf = {}
# -- 'authenficate'
try:
ifaces = configuration["authenticate"]
except KeyError:
pass
else:
if ifaces == "all":
conf["authenticate"] = ifaces
elif ifaces is False:
conf["authenticate"] = False
elif isinstance(ifaces, list):
if "all" in ifaces:
conf["authenticate"] = "all"
else:
# Store only if authentication is needed
conf["authenticate"] = True if self.interface in ifaces else False
else:
error_message = (
"expecting 'all', 'False' or a list for "
"configuration 'authenticate', got %r" % ifaces,
)
logger.error(error_message)
raise MoulinetteError(error_message, raw_msg=True)
# -- 'authenticator'
auth = configuration.get("authenticator", "default")
if not is_global and isinstance(auth, str):
# Store needed authenticator profile
if auth not in self.global_conf["authenticator"]:
error_message = (
"requesting profile '%s' which is undefined in "
"global configuration of 'authenticator'" % auth,
)
logger.error(error_message)
raise MoulinetteError(error_message, raw_msg=True)
else:
conf["authenticator"] = auth
elif is_global and isinstance(auth, dict):
if len(auth) == 0:
logger.warning(
"no profile defined in global configuration " "for 'authenticator'"
)
else:
auths = {}
for auth_name, auth_conf in auth.items():
auths[auth_name] = {
"name": auth_name,
"vendor": auth_conf.get("vendor"),
"parameters": auth_conf.get("parameters", {}),
"extra": {"help": auth_conf.get("help", None)},
}
conf["authenticator"] = auths
else:
error_message = (
"expecting a dict of profile(s) or a profile name "
"for configuration 'authenticator', got %r",
auth,
)
logger.error(error_message)
raise MoulinetteError(error_message, raw_msg=True)
return conf
class BaseInterface(object):

View file

@ -254,7 +254,7 @@ class _ActionsMapPlugin(object):
except KeyError:
raise HTTPBadRequestResponse("Missing password parameter")
kwargs["profile"] = request.POST.get("profile", "default")
kwargs["profile"] = request.POST.get("profile", self.actionsmap.default_authentication)
return callback(**kwargs)
return wrapper
@ -263,7 +263,7 @@ class _ActionsMapPlugin(object):
def _logout(callback):
def wrapper():
kwargs = {}
kwargs["profile"] = request.POST.get("profile", "default")
kwargs["profile"] = request.POST.get("profile", self.actionsmap.default_authentication)
return callback(**kwargs)
return wrapper
@ -379,7 +379,7 @@ class _ActionsMapPlugin(object):
try:
# Attempt to authenticate
authenticator = self.actionsmap.get_authenticator_for_profile(profile)
authenticator = self.actionsmap.get_authenticator(profile)
authenticator(password, token=(s_id, s_new_token))
except MoulinetteError as e:
if len(s_tokens) > 0:
@ -423,7 +423,7 @@ class _ActionsMapPlugin(object):
raise HTTPUnauthorizedResponse(m18n.g("not_logged_in"))
else:
del self.secrets[s_id]
authenticator = self.actionsmap.get_authenticator_for_profile(profile)
authenticator = self.actionsmap.get_authenticator(profile)
authenticator._clean_session(s_id)
# TODO: Clean the session for profile only
# Delete cookie and clean the session
@ -481,6 +481,7 @@ class _ActionsMapPlugin(object):
- arguments -- A dict of arguments for the route
"""
try:
ret = self.actionsmap.process(arguments, timeout=30, route=_route)
except MoulinetteError as e:
@ -683,31 +684,17 @@ class ActionsMapParser(BaseActionsMapParser):
# Return the created parser
return parser
def auth_required(self, args, **kwargs):
def auth_method(self, args, route, **kwargs):
try:
# Retrieve the tid for the route
tid, _ = self._parsers[kwargs.get("route")]
_, parser = self._parsers[route]
except KeyError as e:
error_message = "no argument parser found for route '%s': %s" % (
kwargs.get("route"),
e,
)
error_message = "no argument parser found for route '%s': %s" % (route, e)
logger.error(error_message)
raise MoulinetteError(error_message, raw_msg=True)
if self.get_conf(tid, "authenticate"):
authenticator = self.get_conf(tid, "authenticator")
# If several authenticator, use the default one
if isinstance(authenticator, dict):
if "default" in authenticator:
authenticator = "default"
else:
# TODO which one should we use?
pass
return authenticator
else:
return False
return parser.authentication
def parse_args(self, args, route, **kwargs):
"""Parse arguments

View file

@ -398,7 +398,7 @@ class ActionsMapParser(BaseActionsMapParser):
self.global_parser.add_argument(*names, **argument_options)
def auth_required(self, args, **kwargs):
def auth_method(self, args, **kwargs):
# FIXME? idk .. this try/except is duplicated from parse_args below
# Just to be able to obtain the tid
try:
@ -414,19 +414,23 @@ class ActionsMapParser(BaseActionsMapParser):
raise MoulinetteError(error_message, raw_msg=True)
tid = getattr(ret, "_tid", None)
if self.get_conf(tid, "authenticate"):
authenticator = self.get_conf(tid, "authenticator")
# If several authenticator, use the default one
if isinstance(authenticator, dict):
if "default" in authenticator:
authenticator = "default"
else:
# TODO which one should we use?
pass
return authenticator
else:
return False
# Ugh that's for yunohost --version ...
if tid is None:
return None
# We go down in the subparser tree until we find the leaf
# corresponding to the tid with a defined authentication
# (yeah it's a mess because the datastructure is a mess..)
_p = self._subparsers
for word in tid[1:]:
_p = _p.choices[word]
if hasattr(_p, "authentication"):
return _p.authentication
else:
_p = _p._actions[1]
raise MoulinetteError(f"Authentication undefined for {tid} ?", raw_msg=True)
def parse_args(self, args, **kwargs):
try:
@ -533,8 +537,7 @@ class Interface(BaseInterface):
# I guess we could imagine some yunohost-independant use-case where
# moulinette is used to create a CLI for non-root user that needs to
# auth somehow but hmpf -.-
help = authenticator.extra.get("help")
msg = m18n.n(help) if help else m18n.g("password")
msg = m18n.g("password")
return authenticator(password=self._do_prompt(msg, True, False, color="yellow"))
def _do_prompt(self, message, is_password, confirm, color="blue"):