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