Settings. * * @author [A. Gianotto] [] * @since [v3.0] * @return connection */ public static function connectToLdap() { $ldap_host = Setting::getSettings()->ldap_server; $ldap_version = Setting::getSettings()->ldap_version ?: 3; $ldap_server_cert_ignore = Setting::getSettings()->ldap_server_cert_ignore; $ldap_use_tls = Setting::getSettings()->ldap_tls; // If we are ignoring the SSL cert we need to setup the environment variable // before we create the connection if ($ldap_server_cert_ignore == '1') { putenv('LDAPTLS_REQCERT=never'); } // If the user specifies where CA Certs are, make sure to use them if (env('LDAPTLS_CACERT')) { putenv('LDAPTLS_CACERT='.env('LDAPTLS_CACERT')); } $connection = @ldap_connect($ldap_host); if (! $connection) { throw new Exception('Could not connect to LDAP server at '.$ldap_host.'. Please check your LDAP server name and port number in your settings.'); } // Needed for AD ldap_set_option($connection, LDAP_OPT_REFERRALS, 0); ldap_set_option($connection, LDAP_OPT_PROTOCOL_VERSION, $ldap_version); ldap_set_option($connection, LDAP_OPT_NETWORK_TIMEOUT, 20); if (Setting::getSettings()->ldap_client_tls_cert && Setting::getSettings()->ldap_client_tls_key) { ldap_set_option(null, LDAP_OPT_X_TLS_CERTFILE, Setting::get_client_side_cert_path()); ldap_set_option(null, LDAP_OPT_X_TLS_KEYFILE, Setting::get_client_side_key_path()); } if ($ldap_use_tls=='1') { ldap_start_tls($connection); } return $connection; } /** * Binds/authenticates the user to LDAP, and returns their attributes. * * @author [A. Gianotto] [] * @since [v3.0] * @param $username * @param $password * @param bool|false $user * @return bool true if the username and/or password provided are valid * false if the username and/or password provided are invalid * array of ldap_attributes if $user is true */ public static function findAndBindUserLdap($username, $password) { $settings = Setting::getSettings(); $connection = self::connectToLdap(); $ldap_username_field = $settings->ldap_username_field; $baseDn = $settings->ldap_basedn; $userDn = $ldap_username_field.'='.$username.','.$settings->ldap_basedn; if ($settings->is_ad == '1') { // Check if they are using the userprincipalname for the username field. // If they are, we can skip building the UPN to authenticate against AD if ($ldap_username_field == 'userprincipalname') { $userDn = $username; } else { // TODO - we no longer respect the "add AD Domain to username" checkbox, but it still exists in settings. // We should probably just eliminate that checkbox to avoid confusion. // We let it sit in the DB, unused, to facilitate people downgrading (if they decide to). // Hopefully, in a later release, we can remove it from the settings. // This logic instead just means that if we're using UPN, we don't append ad_domain, if we aren't, then we do. // Hopefully that should handle all of our use cases, but if not we can backport our old logic. $userDn = ($settings->ad_domain != '') ? $username.'@'.$settings->ad_domain : $username.'@'.$settings->email_domain; } } $filterQuery = $settings->ldap_auth_filter_query.$username; $filter = Setting::getSettings()->ldap_filter; //FIXME - this *does* respect the ldap filter, but I believe that AdLdap2 did *not*. $filterQuery = "({$filter}({$filterQuery}))"; \Log::debug('Filter query: '.$filterQuery); if (! $ldapbind = @ldap_bind($connection, $userDn, $password)) { \Log::debug("Status of binding user: $userDn to directory: (directly!) ".($ldapbind ? "success" : "FAILURE")); if (! $ldapbind = self::bindAdminToLdap($connection)) { /* * TODO PLEASE: * * this isn't very clear, so it's important to note: the $ldapbind value is never correctly returned - we never 'return true' from self::bindAdminToLdap() (the function * just "falls off the end" without ever explictly returning 'true') * * but it *does* have an interesting side-effect of checking for the LDAP password being incorrectly encrypted with the wrong APP_KEY, so I'm leaving it in for now. * * If it *did* correctly return 'true' on a succesful bind, it would _probably_ allow users to log in with an incorrect password. Which would be horrible! * * Let's definitely fix this at the next refactor!!!! * */ \Log::debug("Status of binding Admin user: $userDn to directory instead: ".($ldapbind ? "success" : "FAILURE")); return false; } } if (! $results = ldap_search($connection, $baseDn, $filterQuery)) { throw new Exception('Could not search LDAP: '); } if (! $entry = ldap_first_entry($connection, $results)) { return false; } if (! $user = ldap_get_attributes($connection, $entry)) { return false; } return array_change_key_case($user); } /** * Binds/authenticates an admin to LDAP for LDAP searching/syncing. * Here we also return a better error if the app key is donked. * * @author [A. Gianotto] [] * @since [v3.0] * @param bool|false $user * @return bool true if the username and/or password provided are valid * false if the username and/or password provided are invalid */ public static function bindAdminToLdap($connection) { $ldap_username = Setting::getSettings()->ldap_uname; if ( $ldap_username ) { // Lets return some nicer messages for users who donked their app key, and disable LDAP try { $ldap_pass = \Crypt::decrypt(Setting::getSettings()->ldap_pword); } catch (Exception $e) { throw new Exception('Your app key has changed! Could not decrypt LDAP password using your current app key, so LDAP authentication has been disabled. Login with a local account, update the LDAP password and re-enable it in Admin > Settings.'); } if (! $ldapbind = @ldap_bind($connection, $ldap_username, $ldap_pass)) { throw new Exception('Could not bind to LDAP: '.ldap_error($connection)); } // TODO - this just "falls off the end" but the function states that it should return true or false // unfortunately, one of the use cases for this function is wrong and *needs* for that failure mode to fire // so I don't want to fix this right now. // this method MODIFIES STATE on the passed-in $connection and just returns true or false (or, in this case, undefined) // at the next refactor, this should be appropriately modified to be more consistent. } else { // LDAP should also work with anonymous bind (no dn, no password available) if (! $ldapbind = @ldap_bind($connection )) { throw new Exception('Could not bind to LDAP: '.ldap_error($connection)); } } } /** * Parse and map LDAP attributes based on settings * * @author [A. Gianotto] [] * @since [v3.0] * * @param $ldapatttibutes * @return array|bool */ public static function parseAndMapLdapAttributes($ldapattributes) { //Get LDAP attribute config $ldap_result_username = Setting::getSettings()->ldap_username_field; $ldap_result_emp_num = Setting::getSettings()->ldap_emp_num; $ldap_result_last_name = Setting::getSettings()->ldap_lname_field; $ldap_result_first_name = Setting::getSettings()->ldap_fname_field; $ldap_result_email = Setting::getSettings()->ldap_email; $ldap_result_phone = Setting::getSettings()->ldap_phone; $ldap_result_jobtitle = Setting::getSettings()->ldap_jobtitle; $ldap_result_country = Setting::getSettings()->ldap_country; $ldap_result_location = Setting::getSettings()->ldap_location; $ldap_result_dept = Setting::getSettings()->ldap_dept; $ldap_result_manager = Setting::getSettings()->ldap_manager; // Get LDAP user data $item = []; $item['username'] = $ldapattributes[$ldap_result_username][0] ?? ''; $item['employee_number'] = $ldapattributes[$ldap_result_emp_num][0] ?? ''; $item['lastname'] = $ldapattributes[$ldap_result_last_name][0] ?? ''; $item['firstname'] = $ldapattributes[$ldap_result_first_name][0] ?? ''; $item['email'] = $ldapattributes[$ldap_result_email][0] ?? ''; $item['telephone'] = $ldapattributes[$ldap_result_phone][0] ?? ''; $item['jobtitle'] = $ldapattributes[$ldap_result_jobtitle][0] ?? ''; $item['country'] = $ldapattributes[$ldap_result_country][0] ?? ''; $item['department'] = $ldapattributes[$ldap_result_dept][0] ?? ''; $item['manager'] = $ldapattributes[$ldap_result_manager][0] ?? ''; $item['location'] = $ldapattributes[$ldap_result_location][0] ?? ''; return $item; } /** * Create user from LDAP attributes * * @author [A. Gianotto] [] * @since [v3.0] * @param $ldapatttibutes * @return array|bool */ public static function createUserFromLdap($ldapatttibutes, $password) { $item = self::parseAndMapLdapAttributes($ldapatttibutes); // Create user from LDAP data if (! empty($item['username'])) { $user = new User; $user->first_name = $item['firstname']; $user->last_name = $item['lastname']; $user->username = $item['username']; $user->email = $item['email']; if (Setting::getSettings()->ldap_pw_sync == '1') { $user->password = bcrypt($password); } else { $pass = substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 25); $user->password = bcrypt($pass); } $user->activated = 1; $user->ldap_import = 1; $user->notes = 'Imported on first login from LDAP'; if ($user->save()) { return $user; } else { LOG::debug('Could not create user.'.$user->getErrors()); throw new Exception('Could not create user: '.$user->getErrors()); } } return false; } /** * Searches LDAP * * @author [A. Gianotto] [] * @since [v3.0] * @param $base_dn * @param $count * @param $filter * @return array|bool */ public static function findLdapUsers($base_dn = null, $count = -1, $filter = null) { $ldapconn = self::connectToLdap(); self::bindAdminToLdap($ldapconn); // Default to global base DN if nothing else is provided. if (is_null($base_dn)) { $base_dn = Setting::getSettings()->ldap_basedn; } if($filter === null) { $filter = Setting::getSettings()->ldap_filter; } // Set up LDAP pagination for very large databases $page_size = 500; $cookie = ''; $result_set = []; $global_count = 0; // Perform the search do { if ($filter != '' && substr($filter, 0, 1) != '(') { // wrap parens around NON-EMPTY filters that DON'T have them, for back-compatibility with AdLdap2-based filters $filter = "($filter)"; } elseif ($filter == '') { $filter = '(cn=*)'; } // HUGE thanks to this article: https://stackoverflow.com/questions/68275972/how-to-get-paged-ldap-queries-in-php-8-and-read-more-than-1000-entries // which helped me wrap my head around paged results! // if a $count is set and it's smaller than $page_size then use that as the page size $ldap_controls = []; //if($count == -1) { //count is -1 means we have to employ paging to query the entire directory $ldap_controls = [['oid' => LDAP_CONTROL_PAGEDRESULTS, 'iscritical' => false, 'value' => ['size'=> $count == -1||$count>$page_size ? $page_size : $count, 'cookie' => $cookie]]]; //} $search_results = ldap_search($ldapconn, $base_dn, $filter, [], 0, /* $page_size */ -1, -1, LDAP_DEREF_NEVER, $ldap_controls); // TODO - I hate the @, and I hate that we get a full page even if we ask for 10 records. Can we use an ldap_control? \Log::debug("LDAP search executed successfully."); if (! $search_results) { return redirect()->route('users.index')->with('error', trans('admin/users/message.error.ldap_could_not_search').ldap_error($ldapconn)); // TODO this is never called in any routed context - only from the Artisan command. So this redirect will never work. } $errcode = null; $matcheddn = null; $errmsg = null; $referrals = null; $controls = []; ldap_parse_result($ldapconn, $search_results, $errcode , $matcheddn , $errmsg , $referrals, $controls); if (isset($controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'])) { // You need to pass the cookie from the last call to the next one $cookie = $controls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie']; \Log::debug("okay, at least one more page to go!!!"); } else { \Log::debug("okay, we're out of pages - no cookie (or empty cookie) was passed"); $cookie = ''; } // Empty cookie means last page // Get results from page $results = ldap_get_entries($ldapconn, $search_results); if (! $results) { return redirect()->route('users.index')->with('error', trans('admin/users/message.error.ldap_could_not_get_entries').ldap_error($ldapconn)); // TODO this is never called in any routed context - only from the Artisan command. So this redirect will never work. } // Add results to result set $global_count += $results['count']; $result_set = array_merge($result_set, $results); \Log::debug("Total count is: $global_count"); } while ($cookie !== null && $cookie != '' && ($count == -1 || $global_count < $count)); // some servers don't even have pagination, and some will give you more results than you asked for, so just see if you have enough. // Clean up after search $result_set['count'] = $global_count; // TODO: I would've figured you could just count the array instead? $results = $result_set; return $results; } }