1"""
2Provide authentication using simple LDAP binds
3
4:depends:   - ldap Python module
5"""
6import itertools
7import logging
8
9import salt.utils.data
10import salt.utils.stringutils
11from jinja2 import Environment
12from salt.exceptions import CommandExecutionError, SaltInvocationError
13
14log = logging.getLogger(__name__)
15
16try:
17    # pylint: disable=no-name-in-module
18    import ldap
19    import ldap.modlist
20    import ldap.filter
21
22    HAS_LDAP = True
23    # pylint: enable=no-name-in-module
24except ImportError:
25    HAS_LDAP = False
26
27# Defaults, override in master config
28__defopts__ = {
29    "auth.ldap.basedn": "",
30    "auth.ldap.uri": "",
31    "auth.ldap.server": "localhost",
32    "auth.ldap.port": "389",
33    "auth.ldap.starttls": False,
34    "auth.ldap.tls": False,
35    "auth.ldap.no_verify": False,
36    "auth.ldap.anonymous": False,
37    "auth.ldap.scope": 2,
38    "auth.ldap.groupou": "Groups",
39    "auth.ldap.accountattributename": "memberUid",
40    "auth.ldap.groupattribute": "memberOf",
41    "auth.ldap.persontype": "person",
42    "auth.ldap.groupclass": "posixGroup",
43    "auth.ldap.activedirectory": False,
44    "auth.ldap.freeipa": False,
45    "auth.ldap.minion_stripdomains": [],
46}
47
48
49def _config(key, mandatory=True, opts=None):
50    """
51    Return a value for 'name' from master config file options or defaults.
52    """
53    try:
54        if opts:
55            value = opts["auth.ldap.{}".format(key)]
56        else:
57            value = __opts__["auth.ldap.{}".format(key)]
58    except KeyError:
59        try:
60            value = __defopts__["auth.ldap.{}".format(key)]
61        except KeyError:
62            if mandatory:
63                msg = "missing auth.ldap.{} in master config".format(key)
64                raise SaltInvocationError(msg)
65            return False
66    return value
67
68
69def _render_template(param, username):
70    """
71    Render config template, substituting username where found.
72    """
73    env = Environment()
74    template = env.from_string(param)
75    variables = {"username": username}
76    return template.render(variables)
77
78
79class _LDAPConnection:
80    """
81    Setup an LDAP connection.
82    """
83
84    def __init__(
85        self,
86        uri,
87        server,
88        port,
89        starttls,
90        tls,
91        no_verify,
92        binddn,
93        bindpw,
94        anonymous,
95        accountattributename,
96        activedirectory=False,
97    ):
98        """
99        Bind to an LDAP directory using passed credentials.
100        """
101        self.uri = uri
102        self.server = server
103        self.port = port
104        self.starttls = starttls
105        self.tls = tls
106        self.binddn = binddn
107        self.bindpw = bindpw
108        if not HAS_LDAP:
109            raise CommandExecutionError(
110                "LDAP connection could not be made, the python-ldap module is "
111                "not installed. Install python-ldap to use LDAP external auth."
112            )
113        if self.starttls and self.tls:
114            raise CommandExecutionError(
115                "Cannot bind with both starttls and tls enabled."
116                "Please enable only one of the protocols"
117            )
118
119        schema = "ldaps" if tls else "ldap"
120        if self.uri == "":
121            self.uri = "{}://{}:{}".format(schema, self.server, self.port)
122
123        try:
124            if no_verify:
125                ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
126
127            self.ldap = ldap.initialize("{}".format(self.uri))
128            self.ldap.protocol_version = 3  # ldap.VERSION3
129            self.ldap.set_option(ldap.OPT_REFERRALS, 0)  # Needed for AD
130
131            if not anonymous:
132                if not self.bindpw:
133                    raise CommandExecutionError(
134                        "LDAP bind password is not set: password cannot be empty if auth.ldap.anonymous is False"
135                    )
136                if self.starttls:
137                    self.ldap.start_tls_s()
138                self.ldap.simple_bind_s(self.binddn, self.bindpw)
139        except Exception as ldap_error:  # pylint: disable=broad-except
140            raise CommandExecutionError(
141                "Failed to bind to LDAP server {} as {}: {}".format(
142                    self.uri, self.binddn, ldap_error
143                )
144            )
145
146
147def _bind_for_search(anonymous=False, opts=None):
148    """
149    Bind with binddn and bindpw only for searching LDAP
150    :param anonymous: Try binding anonymously
151    :param opts: Pass in when __opts__ is not available
152    :return: LDAPConnection object
153    """
154    # Get config params; create connection dictionary
155    connargs = {}
156    # config params (auth.ldap.*)
157    params = {
158        "mandatory": [
159            "uri",
160            "server",
161            "port",
162            "starttls",
163            "tls",
164            "no_verify",
165            "anonymous",
166            "accountattributename",
167            "activedirectory",
168        ],
169        "additional": [
170            "binddn",
171            "bindpw",
172            "filter",
173            "groupclass",
174            "auth_by_group_membership_only",
175        ],
176    }
177
178    paramvalues = {}
179
180    for param in params["mandatory"]:
181        paramvalues[param] = _config(param, opts=opts)
182
183    for param in params["additional"]:
184        paramvalues[param] = _config(param, mandatory=False, opts=opts)
185
186    paramvalues["anonymous"] = anonymous
187
188    # Only add binddn/bindpw to the connargs when they're set, as they're not
189    # mandatory for initializing the LDAP object, but if they're provided
190    # initially, a bind attempt will be done during the initialization to
191    # validate them
192    if paramvalues["binddn"]:
193        connargs["binddn"] = paramvalues["binddn"]
194        if paramvalues["bindpw"]:
195            params["mandatory"].append("bindpw")
196
197    for name in params["mandatory"]:
198        connargs[name] = paramvalues[name]
199
200    if not paramvalues["anonymous"]:
201        if paramvalues["binddn"] and paramvalues["bindpw"]:
202            # search for the user's DN to be used for the actual authentication
203            return _LDAPConnection(**connargs).ldap
204
205
206def _bind(username, password, anonymous=False, opts=None):
207    """
208    Authenticate via an LDAP bind
209    """
210    # Get config params; create connection dictionary
211    basedn = _config("basedn", opts=opts)
212    scope = _config("scope", opts=opts)
213    connargs = {}
214    # config params (auth.ldap.*)
215    params = {
216        "mandatory": [
217            "uri",
218            "server",
219            "port",
220            "starttls",
221            "tls",
222            "no_verify",
223            "anonymous",
224            "accountattributename",
225            "activedirectory",
226        ],
227        "additional": [
228            "binddn",
229            "bindpw",
230            "filter",
231            "groupclass",
232            "auth_by_group_membership_only",
233        ],
234    }
235
236    paramvalues = {}
237
238    for param in params["mandatory"]:
239        paramvalues[param] = _config(param, opts=opts)
240
241    for param in params["additional"]:
242        paramvalues[param] = _config(param, mandatory=False, opts=opts)
243
244    paramvalues["anonymous"] = anonymous
245    if paramvalues["binddn"]:
246        # the binddn can also be composited, e.g.
247        #   - {{ username }}@domain.com
248        #   - cn={{ username }},ou=users,dc=company,dc=tld
249        # so make sure to render it first before using it
250        paramvalues["binddn"] = _render_template(paramvalues["binddn"], username)
251        paramvalues["binddn"] = ldap.filter.escape_filter_chars(paramvalues["binddn"])
252
253    if paramvalues["filter"]:
254        escaped_username = ldap.filter.escape_filter_chars(username)
255        paramvalues["filter"] = _render_template(
256            paramvalues["filter"], escaped_username
257        )
258
259    # Only add binddn/bindpw to the connargs when they're set, as they're not
260    # mandatory for initializing the LDAP object, but if they're provided
261    # initially, a bind attempt will be done during the initialization to
262    # validate them
263    if paramvalues["binddn"]:
264        connargs["binddn"] = paramvalues["binddn"]
265        if paramvalues["bindpw"]:
266            params["mandatory"].append("bindpw")
267
268    for name in params["mandatory"]:
269        connargs[name] = paramvalues[name]
270
271    if not paramvalues["anonymous"]:
272        if paramvalues["binddn"] and paramvalues["bindpw"]:
273            # search for the user's DN to be used for the actual authentication
274            _ldap = _LDAPConnection(**connargs).ldap
275            log.debug(
276                "Running LDAP user dn search with filter:%s, dn:%s, scope:%s",
277                paramvalues["filter"],
278                basedn,
279                scope,
280            )
281            result = _ldap.search_s(basedn, int(scope), paramvalues["filter"])
282            if not result:
283                log.warning("Unable to find user %s", username)
284                return False
285            elif len(result) > 1:
286                # Active Directory returns something odd.  Though we do not
287                # chase referrals (ldap.set_option(ldap.OPT_REFERRALS, 0) above)
288                # it still appears to return several entries for other potential
289                # sources for a match.  All these sources have None for the
290                # CN (ldap array return items are tuples: (cn, ldap entry))
291                # But the actual CNs are at the front of the list.
292                # So with some list comprehension magic, extract the first tuple
293                # entry from all the results, create a list from those,
294                # and count the ones that are not None.  If that total is more than one
295                # we need to error out because the ldap filter isn't narrow enough.
296                cns = [tup[0] for tup in result]
297                total_not_none = sum(1 for c in cns if c is not None)
298                if total_not_none > 1:
299                    log.error(
300                        "LDAP lookup found multiple results for user %s", username
301                    )
302                    return False
303                elif total_not_none == 0:
304                    log.error(
305                        "LDAP lookup--unable to find CN matching user %s", username
306                    )
307                    return False
308
309            connargs["binddn"] = result[0][0]
310        if paramvalues["binddn"] and not paramvalues["bindpw"]:
311            connargs["binddn"] = paramvalues["binddn"]
312    elif paramvalues["binddn"] and not paramvalues["bindpw"]:
313        connargs["binddn"] = paramvalues["binddn"]
314
315    # Update connection dictionary with the user's password
316    connargs["bindpw"] = password
317
318    # Attempt bind with user dn and password
319    if paramvalues["anonymous"]:
320        log.debug("Attempting anonymous LDAP bind")
321    else:
322        log.debug("Attempting LDAP bind with user dn: %s", connargs["binddn"])
323    try:
324        ldap_conn = _LDAPConnection(**connargs).ldap
325    except Exception:  # pylint: disable=broad-except
326        connargs.pop("bindpw", None)  # Don't log the password
327        log.error("Failed to authenticate user dn via LDAP: %s", connargs)
328        log.debug("Error authenticating user dn via LDAP:", exc_info=True)
329        return False
330    log.debug("Successfully authenticated user dn via LDAP: %s", connargs["binddn"])
331    return ldap_conn
332
333
334def auth(username, password):
335    """
336    Simple LDAP auth
337    """
338    if not HAS_LDAP:
339        log.error("LDAP authentication requires python-ldap module")
340        return False
341
342    bind = None
343
344    # If bind credentials are configured, verify that we receive a valid bind
345    if _config("binddn", mandatory=False) and _config("bindpw", mandatory=False):
346        search_bind = _bind_for_search(anonymous=_config("anonymous", mandatory=False))
347
348        # If username & password are not None, attempt to verify they are valid
349        if search_bind and username and password:
350            bind = _bind(
351                username,
352                password,
353                anonymous=_config("auth_by_group_membership_only", mandatory=False)
354                and _config("anonymous", mandatory=False),
355            )
356    else:
357        bind = _bind(
358            username,
359            password,
360            anonymous=_config("auth_by_group_membership_only", mandatory=False)
361            and _config("anonymous", mandatory=False),
362        )
363
364    if bind:
365        log.debug("LDAP authentication successful")
366        return bind
367
368    log.error("LDAP _bind authentication FAILED")
369    return False
370
371
372def groups(username, **kwargs):
373    """
374    Authenticate against an LDAP group
375
376    Behavior is highly dependent on if Active Directory is in use.
377
378    AD handles group membership very differently than OpenLDAP.
379    See the :ref:`External Authentication <acl-eauth>` documentation for a thorough
380    discussion of available parameters for customizing the search.
381
382    OpenLDAP allows you to search for all groups in the directory
383    and returns members of those groups.  Then we check against
384    the username entered.
385
386    """
387    group_list = []
388
389    # If bind credentials are configured, use them instead of user's
390    if _config("binddn", mandatory=False) and _config("bindpw", mandatory=False):
391        bind = _bind_for_search(anonymous=_config("anonymous", mandatory=False))
392    else:
393        bind = _bind(
394            username,
395            kwargs.get("password", ""),
396            anonymous=_config("auth_by_group_membership_only", mandatory=False)
397            and _config("anonymous", mandatory=False),
398        )
399
400    if bind:
401        log.debug("ldap bind to determine group membership succeeded!")
402
403        if _config("activedirectory"):
404            try:
405                get_user_dn_search = "(&({}={})(objectClass={}))".format(
406                    _config("accountattributename"), username, _config("persontype")
407                )
408                user_dn_results = bind.search_s(
409                    _config("basedn"),
410                    ldap.SCOPE_SUBTREE,
411                    get_user_dn_search,
412                    ["distinguishedName"],
413                )
414            except Exception as e:  # pylint: disable=broad-except
415                log.error("Exception thrown while looking up user DN in AD: %s", e)
416                return group_list
417            if not user_dn_results:
418                log.error("Could not get distinguished name for user %s", username)
419                return group_list
420            # LDAP results are always tuples.  First entry in the tuple is the DN
421            dn = ldap.filter.escape_filter_chars(user_dn_results[0][0])
422            ldap_search_string = "(&(member={})(objectClass={}))".format(
423                dn, _config("groupclass")
424            )
425            log.debug("Running LDAP group membership search: %s", ldap_search_string)
426            try:
427                search_results = bind.search_s(
428                    _config("basedn"),
429                    ldap.SCOPE_SUBTREE,
430                    ldap_search_string,
431                    [
432                        salt.utils.stringutils.to_str(_config("accountattributename")),
433                        "cn",
434                    ],
435                )
436            except Exception as e:  # pylint: disable=broad-except
437                log.error(
438                    "Exception thrown while retrieving group membership in AD: %s", e
439                )
440                return group_list
441            for _, entry in search_results:
442                if "cn" in entry:
443                    group_list.append(salt.utils.stringutils.to_unicode(entry["cn"][0]))
444            log.debug("User %s is a member of groups: %s", username, group_list)
445
446        elif _config("freeipa"):
447            escaped_username = ldap.filter.escape_filter_chars(username)
448            search_base = _config("group_basedn")
449            search_string = _render_template(_config("group_filter"), escaped_username)
450            search_results = bind.search_s(
451                search_base,
452                ldap.SCOPE_SUBTREE,
453                search_string,
454                [
455                    salt.utils.stringutils.to_str(_config("accountattributename")),
456                    salt.utils.stringutils.to_str(_config("groupattribute")),
457                    "cn",
458                ],
459            )
460
461            for entry, result in search_results:
462                for user in itertools.chain(
463                    result.get(_config("accountattributename"), []),
464                    result.get(_config("groupattribute"), []),
465                ):
466                    if (
467                        username
468                        == salt.utils.stringutils.to_unicode(user)
469                        .split(",")[0]
470                        .split("=")[-1]
471                    ):
472                        group_list.append(entry.split(",")[0].split("=")[-1])
473
474            log.debug("User %s is a member of groups: %s", username, group_list)
475
476            if not auth(username, kwargs["password"]):
477                log.error("LDAP username and password do not match")
478                return []
479        else:
480            if _config("groupou"):
481                search_base = "ou={},{}".format(_config("groupou"), _config("basedn"))
482            else:
483                search_base = "{}".format(_config("basedn"))
484            search_string = "(&({}={})(objectClass={}))".format(
485                _config("accountattributename"), username, _config("groupclass")
486            )
487            search_results = bind.search_s(
488                search_base,
489                ldap.SCOPE_SUBTREE,
490                search_string,
491                [
492                    salt.utils.stringutils.to_str(_config("accountattributename")),
493                    "cn",
494                    salt.utils.stringutils.to_str(_config("groupattribute")),
495                ],
496            )
497            for _, entry in search_results:
498                if username in salt.utils.data.decode(
499                    entry[_config("accountattributename")]
500                ):
501                    group_list.append(salt.utils.stringutils.to_unicode(entry["cn"][0]))
502            for user, entry in search_results:
503                if (
504                    username
505                    == salt.utils.stringutils.to_unicode(user)
506                    .split(",")[0]
507                    .split("=")[-1]
508                ):
509                    for group in salt.utils.data.decode(
510                        entry[_config("groupattribute")]
511                    ):
512                        group_list.append(
513                            salt.utils.stringutils.to_unicode(group)
514                            .split(",")[0]
515                            .split("=")[-1]
516                        )
517            log.debug("User %s is a member of groups: %s", username, group_list)
518
519            # Only test user auth on first call for job.
520            # 'show_jid' only exists on first payload so we can use that for the conditional.
521            if "show_jid" in kwargs and not _bind(
522                username,
523                kwargs.get("password"),
524                anonymous=_config("auth_by_group_membership_only", mandatory=False)
525                and _config("anonymous", mandatory=False),
526            ):
527                log.error("LDAP username and password do not match")
528                return []
529    else:
530        log.error("ldap bind to determine group membership FAILED!")
531
532    return group_list
533
534
535def __expand_ldap_entries(entries, opts=None):
536    """
537
538    :param entries: ldap subtree in external_auth config option
539    :param opts: Opts to use when __opts__ not defined
540    :return: Dictionary with all allowed operations
541
542    Takes the ldap subtree in the external_auth config option and expands it
543    with actual minion names
544
545    webadmins%:  <all users in the AD 'webadmins' group>
546      - server1
547          - .*
548      - ldap(OU=webservers,dc=int,dc=bigcompany,dc=com)
549        - test.ping
550        - service.restart
551      - ldap(OU=Domain Controllers,dc=int,dc=bigcompany,dc=com)
552        - allowed_fn_list_attribute^
553
554    This function only gets called if auth.ldap.activedirectory = True
555    """
556    bind = _bind_for_search(opts=opts)
557    acl_tree = []
558    for user_or_group_dict in entries:
559        if not isinstance(user_or_group_dict, dict):
560            acl_tree.append(user_or_group_dict)
561            continue
562        for minion_or_ou, matchers in user_or_group_dict.items():
563            permissions = matchers
564            retrieved_minion_ids = []
565            if minion_or_ou.startswith("ldap("):
566                search_base = minion_or_ou.lstrip("ldap(").rstrip(")")
567
568                search_string = "(objectClass=computer)"
569                try:
570                    search_results = bind.search_s(
571                        search_base, ldap.SCOPE_SUBTREE, search_string, ["cn"]
572                    )
573                    for ldap_match in search_results:
574                        try:
575                            minion_id = ldap_match[1]["cn"][0].lower()
576                            # Some LDAP/AD trees only have the FQDN of machines
577                            # in their computer lists.  auth.minion_stripdomains
578                            # lets a user strip off configured domain names
579                            # and arrive at the basic minion_id
580                            if opts.get("auth.ldap.minion_stripdomains", None):
581                                for domain in opts["auth.ldap.minion_stripdomains"]:
582                                    if minion_id.endswith(domain):
583                                        minion_id = minion_id[: -len(domain)]
584                                        break
585                            retrieved_minion_ids.append(minion_id)
586                        except TypeError:
587                            # TypeError here just means that one of the returned
588                            # entries didn't match the format we expected
589                            # from LDAP.
590                            pass
591
592                    for minion_id in retrieved_minion_ids:
593                        acl_tree.append({minion_id: permissions})
594                    log.trace("Expanded acl_tree is: %s", acl_tree)
595                except ldap.NO_SUCH_OBJECT:
596                    pass
597            else:
598                acl_tree.append({minion_or_ou: matchers})
599
600    log.trace("__expand_ldap_entries: %s", acl_tree)
601    return acl_tree
602
603
604def process_acl(auth_list, opts=None):
605    """
606    Query LDAP, retrieve list of minion_ids from an OU or other search.
607    For each minion_id returned from the LDAP search, copy the perms
608    matchers into the auth dictionary
609    :param auth_list:
610    :param opts: __opts__ for when __opts__ is not injected
611    :return: Modified auth list.
612    """
613    ou_names = []
614    for item in auth_list:
615        if isinstance(item, str):
616            continue
617        ou_names.extend(
618            [
619                potential_ou
620                for potential_ou in item.keys()
621                if potential_ou.startswith("ldap(")
622            ]
623        )
624    if ou_names:
625        auth_list = __expand_ldap_entries(auth_list, opts)
626    return auth_list
627