From e83bc03d97263370da6b750eb040ac1e1c71a0e6 Mon Sep 17 00:00:00 2001 From: snipe Date: Mon, 30 Nov 2020 14:46:10 -0800 Subject: [PATCH 1/2] Switch backup files array order to show latest first [ch15486]' --- app/Http/Controllers/SettingsController.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index e56a8233d5..c0903c98f1 100755 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -1015,7 +1015,7 @@ class SettingsController extends Controller $path = 'app/backups'; $backup_files = Storage::files($path); - $files = []; + $files_raw = []; if (count($backup_files) > 0) { for ($f = 0; $f < count($backup_files); ++$f) { @@ -1023,7 +1023,7 @@ class SettingsController extends Controller // Skip dotfiles like .gitignore and .DS_STORE if ((substr(basename($backup_files[$f]), 0, 1) != '.')) { - $files[] = [ + $files_raw[] = [ 'filename' => basename($backup_files[$f]), 'filesize' => Setting::fileSizeConvert(Storage::size($backup_files[$f])), 'modified' => Storage::lastModified($backup_files[$f]), @@ -1035,6 +1035,9 @@ class SettingsController extends Controller } } + // Reverse the array so it lists oldest first + $files = array_reverse($files_raw); + return view('settings/backups', compact('path', 'files')); } From 93cf8d4e0afb13e818b40cbe84f323899685993c Mon Sep 17 00:00:00 2001 From: Brady Wetherington Date: Mon, 30 Nov 2020 17:11:44 -0800 Subject: [PATCH 2/2] Forward-port of the old LDAP sync system (#8801) * Forward-port of the old LDAP sync system * Need to rename the class to avoid classname conflicts * Make 'classic' LDAP sync not add surrounding parens to filters that already have them * Re-work Test LDAP button to return 10 sample users * Remove useless debugging code --- app/Console/Commands/LdapSync.php | 550 +++++++----------- app/Console/Commands/LdapSyncNg.php | 399 +++++++++++++ .../Controllers/Api/SettingsController.php | 16 +- app/Models/Ldap.php | 297 ++++++++++ app/Services/LdapAd.php | 2 + 5 files changed, 938 insertions(+), 326 deletions(-) mode change 100644 => 100755 app/Console/Commands/LdapSync.php create mode 100644 app/Console/Commands/LdapSyncNg.php create mode 100644 app/Models/Ldap.php diff --git a/app/Console/Commands/LdapSync.php b/app/Console/Commands/LdapSync.php old mode 100644 new mode 100755 index 40349bccbe..aba23151aa --- a/app/Console/Commands/LdapSync.php +++ b/app/Console/Commands/LdapSync.php @@ -1,24 +1,14 @@ - * - * @since 5.0.0 - */ class LdapSync extends Command { /** @@ -26,79 +16,23 @@ class LdapSync extends Command * * @var string */ - protected $signature = 'snipeit:ldap-sync - {--location= : A location name } - {--location_id= : A location id} - {--base_dn= : A diffrent base DN to use } - {--summary : Print summary } - {--json_summary : Print summary in json format } - {--dryrun : Run the sync process but don\'t update the database}'; + protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=} {--base_dn=} {--summary} {--json_summary}'; /** * The console command description. * * @var string */ - protected $description = 'Command line LDAP/AD sync'; - - /** - * An LdapAd instance. - * - * @var \App\Models\LdapAd - */ - private $ldap; - - /** - * LDAP settings collection. - * - * @var \Illuminate\Support\Collection - */ - private $settings = null; - - /** - * A default location collection. - * - * @var \Illuminate\Support\Collection - */ - private $defaultLocation = null; - - /** - * Mapped locations collection. - * - * @var \Illuminate\Support\Collection - */ - private $mappedLocations = null; - - /** - * The summary collection. - * - * @var \Illuminate\Support\Collection - */ - private $summary; - - /** - * Is dry-run? - * - * @var bool - */ - private $dryrun = false; - - /** - * Show users to be imported. - * - * @var array - */ - private $userlist = []; + protected $description = 'Command line LDAP sync'; /** * Create a new command instance. + * + * @return void */ - public function __construct(LdapAd $ldap) + public function __construct() { parent::__construct(); - $this->ldap = $ldap; - $this->settings = $this->ldap->ldapSettings; - $this->summary = collect(); } /** @@ -108,275 +42,241 @@ class LdapSync extends Command */ public function handle() { - ini_set('max_execution_time', env('LDAP_TIME_LIM', "600")); //600 seconds = 10 minutes - ini_set('memory_limit', '500M'); - $old_error_reporting = error_reporting(); // grab old error_reporting .ini setting, for later re-enablement - error_reporting($old_error_reporting & ~E_DEPRECATED); // disable deprecation warnings, for LDAP in PHP 7.4 (and greater) + ini_set('max_execution_time', env('LDAP_TIME_LIM', 600)); //600 seconds = 10 minutes + ini_set('memory_limit', env('LDAP_MEM_LIM', '500M')); + $ldap_result_username = Setting::getSettings()->ldap_username_field; + $ldap_result_last_name = Setting::getSettings()->ldap_lname_field; + $ldap_result_first_name = Setting::getSettings()->ldap_fname_field; - if ($this->option('dryrun')) { - $this->dryrun = true; - } - $this->checkIfLdapIsEnabled(); - $this->checkLdapConnection(); - $this->setBaseDn(); - $this->getUserDefaultLocation(); - /* - * Use the default location if set, this is needed for the LDAP users sync page - */ - if (!$this->option('base_dn') && null == $this->defaultLocation) { - $this->getMappedLocations(); - } - $this->processLdapUsers(); - // Print table of users - if ($this->dryrun) { - $this->info('The following users will be synced!'); - $headers = ['First Name', 'Last Name', 'Username', 'Email', 'Employee #', 'Location Id', 'Status']; - $this->table($headers, $this->summary->toArray()); + $ldap_result_active_flag = Setting::getSettings()->ldap_active_flag_field; + $ldap_result_emp_num = Setting::getSettings()->ldap_emp_num; + $ldap_result_email = Setting::getSettings()->ldap_email; + + try { + $ldapconn = Ldap::connectToLdap(); + Ldap::bindAdminToLdap($ldapconn); + } catch (\Exception $e) { + if ($this->option('json_summary')) { + $json_summary = [ "error" => true, "error_message" => $e->getMessage(), "summary" => [] ]; + $this->info(json_encode($json_summary)); + } + LOG::info($e); + return []; } - error_reporting($old_error_reporting); // re-enable deprecation warnings. - return $this->getSummary(); - } + $summary = array(); - /** - * Generate the LDAP sync summary. - * - * @author Wes Hulette - * - * @since 5.0.0 - * - * @return string - */ - private function getSummary(): string - { - if ($this->option('summary') && null === $this->dryrun) { - $this->summary->each(function ($item) { - $this->info('USER: '.$item['note']); - - if ('ERROR' === $item['status']) { - $this->error('ERROR: '.$item['note']); - } - }); - } elseif ($this->option('json_summary')) { - $json_summary = [ - 'error' => false, - 'error_message' => '', - 'summary' => $this->summary->toArray(), - ]; - $this->info(json_encode($json_summary)); + try { + if ($this->option('base_dn') != '') { + $search_base = $this->option('base_dn'); + LOG::debug('Importing users from specified base DN: \"'.$search_base.'\".'); + } else { + $search_base = null; + } + $results = Ldap::findLdapUsers($search_base); + } catch (\Exception $e) { + if ($this->option('json_summary')) { + $json_summary = [ "error" => true, "error_message" => $e->getMessage(), "summary" => [] ]; + $this->info(json_encode($json_summary)); + } + LOG::info($e); + return []; } - return ''; - } + /* Determine which location to assign users to by default. */ + $location = NULL; - /** - * Create a new user or update an existing user. - * - * @author Wes Hulette - * - * @since 5.0.0 - * - * @param \Adldap\Models\User $snipeUser - */ - private function updateCreateUser(AdldapUser $snipeUser): void - { - $user = $this->ldap->processUser($snipeUser, $this->defaultLocation, $this->mappedLocations); - $summary = [ - 'firstname' => $user->first_name, - 'lastname' => $user->last_name, - 'username' => $user->username, - 'employee_number' => $user->employee_num, - 'email' => $user->email, - 'location_id' => $user->location_id, - ]; - // Only update the database if is not a dry run - if (!$this->dryrun) { - if ($user->isDirty()) { //if nothing on the user changed, don't bother trying to save anything nor put anything in the summary - if ($user->save()) { - $summary['note'] = ($user->wasRecentlyCreated ? 'CREATED' : 'UPDATED'); - $summary['status'] = 'SUCCESS'; - } else { - $errors = ''; - foreach ($user->getErrors()->getMessages() as $error) { - $errors .= implode(", ",$error); + if ($this->option('location')!='') { + $location = Location::where('name', '=', $this->option('location'))->first(); + LOG::debug('Location name '.$this->option('location').' passed'); + LOG::debug('Importing to '.$location->name.' ('.$location->id.')'); + } elseif ($this->option('location_id')!='') { + $location = Location::where('id', '=', $this->option('location_id'))->first(); + LOG::debug('Location ID '.$this->option('location_id').' passed'); + LOG::debug('Importing to '.$location->name.' ('.$location->id.')'); + } + + if (!isset($location)) { + LOG::debug('That location is invalid or a location was not provided, so no location will be assigned by default.'); + } + + /* Process locations with explicitly defined OUs, if doing a full import. */ + if ($this->option('base_dn')=='') { + // Retrieve locations with a mapped OU, and sort them from the shallowest to deepest OU (see #3993) + $ldap_ou_locations = Location::where('ldap_ou', '!=', '')->get()->toArray(); + $ldap_ou_lengths = array(); + + foreach ($ldap_ou_locations as $location) { + $ldap_ou_lengths[] = strlen($location["ldap_ou"]); + } + + array_multisort($ldap_ou_lengths, SORT_ASC, $ldap_ou_locations); + + if (sizeof($ldap_ou_locations) > 0) { + LOG::debug('Some locations have special OUs set. Locations will be automatically set for users in those OUs.'); + } + + // Inject location information fields + for ($i = 0; $i < $results["count"]; $i++) { + $results[$i]["ldap_location_override"] = false; + $results[$i]["location_id"] = 0; + } + + // Grab subsets based on location-specific DNs, and overwrite location for these users. + foreach ($ldap_ou_locations as $ldap_loc) { + try { + $location_users = Ldap::findLdapUsers($ldap_loc["ldap_ou"]); + } catch (\Exception $e) { // FIXME: this is stolen from line 77 or so above + if ($this->option('json_summary')) { + $json_summary = [ "error" => true, "error_message" => trans('admin/users/message.error.ldap_could_not_search')." Location: ".$ldap_loc['name']." (ID: ".$ldap_loc['id'].") cannot connect to \"".$ldap_loc["ldap_ou"]."\" - ".$e->getMessage(), "summary" => [] ]; + $this->info(json_encode($json_summary)); } - $summary['note'] = $snipeUser->getDN().' was not imported. REASON: '.$errors; - $summary['status'] = 'ERROR'; + LOG::info($e); + return []; } - } else { - $summary = null; - } - } + $usernames = array(); + for ($i = 0; $i < $location_users["count"]; $i++) { - // $summary['note'] = ($user->getOriginal('username') ? 'UPDATED' : 'CREATED'); // this seems, kinda, like, superfluous, relative to the $summary['note'] thing above, yeah? - if($summary) { //if the $user wasn't dirty, $summary was set to null so that we will skip the following push() - $this->summary->push($summary); - } - } + if (array_key_exists($ldap_result_username, $location_users[$i])) { + $location_users[$i]["ldap_location_override"] = true; + $location_users[$i]["location_id"] = $ldap_loc["id"]; + $usernames[] = $location_users[$i][$ldap_result_username][0]; + } - /** - * Process the users to update / create. - * - * @author Wes Hulette - * - * @since 5.0.0 - * - */ - private function processLdapUsers(): void - { - try { - $ldapUsers = $this->ldap->getLdapUsers(); - } catch (Exception $e) { - $this->outputError($e); - exit($e->getMessage()); - } - - if (0 == $ldapUsers->count()) { - $msg = 'ERROR: No users found!'; - Log::error($msg); - if ($this->dryrun) { - $this->error($msg); - } - exit($msg); - } - - // Process each individual users - foreach ($ldapUsers->getResults() as $user) { // AdLdap2's paginate() method is weird, it gets *everything* and ->getResults() returns *everything* - $this->updateCreateUser($user); - } - } - - /** - * Get the mapped locations if a base_dn is provided. - * - * @author Wes Hulette - * - * @since 5.0.0 - */ - private function getMappedLocations() - { - $ldapOuLocation = Location::where('ldap_ou', '!=', '')->select(['id', 'ldap_ou'])->get(); - $locations = $ldapOuLocation->sortBy(function ($ou, $key) { - return strlen($ou->ldap_ou); - }); - if ($locations->count() > 0) { - $msg = 'Some locations have special OUs set. Locations will be automatically set for users in those OUs.'; - LOG::debug($msg); - if ($this->dryrun) { - $this->info($msg); - } - - $this->mappedLocations = $locations->pluck('ldap_ou', 'id'); // TODO: this seems ok-ish, but the key-> value is going location_id -> OU name, and the primary action here is the opposite of that - going from OU's to location ID's. - } - } - - /** - * Set the base dn if supplied. - * - * @author Wes Hulette - * - * @since 5.0.0 - */ - private function setBaseDn(): void - { - if ($this->option('base_dn')) { - $this->ldap->baseDn = $this->option('base_dn'); - $msg = sprintf('Importing users from specified base DN: "%s"', $this->ldap->baseDn); - LOG::debug($msg); - if ($this->dryrun) { - $this->info($msg); - } - } - } - - /** - * Get a default location id for imported users. - * - * @author Wes Hulette - * - * @since 5.0.0 - */ - private function getUserDefaultLocation(): void - { - $location = $this->option('location_id') ?? $this->option('location'); - if ($location) { - $userLocation = Location::where('name', '=', $location) - ->orWhere('id', '=', intval($location)) - ->select(['name', 'id']) - ->first(); - if ($userLocation) { - $msg = 'Importing users with default location: '.$userLocation->name.' ('.$userLocation->id.')'; - LOG::debug($msg); - - if ($this->dryrun) { - $this->info($msg); } - $this->defaultLocation = collect([ - $userLocation->id => $userLocation->name, - ]); - } else { - $msg = 'The supplied location is invalid!'; - LOG::error($msg); - if ($this->dryrun) { - $this->error($msg); + // Delete located users from the general group. + foreach ($results as $key => $generic_entry) { + if ((is_array($generic_entry)) && (array_key_exists($ldap_result_username, $generic_entry))) { + if (in_array($generic_entry[$ldap_result_username][0], $usernames)) { + unset($results[$key]); + } + } } - exit(0); + + $global_count = $results['count']; + $results = array_merge($location_users, $results); + $results['count'] = $global_count; } } - } - /** - * Check if LDAP intergration is enabled. - * - * @author Wes Hulette - * - * @since 5.0.0 - */ - private function checkIfLdapIsEnabled(): void - { - if (false === $this->settings['ldap_enabled']) { - $msg = 'LDAP intergration is not enabled. Exiting sync process.'; - $this->info($msg); - Log::info($msg); - exit(0); + /* Create user account entries in Snipe-IT */ + $tmp_pass = substr(str_shuffle("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"), 0, 20); + $pass = bcrypt($tmp_pass); + + for ($i = 0; $i < $results["count"]; $i++) { + if (empty($ldap_result_active_flag) || $results[$i][$ldap_result_active_flag][0] == "TRUE") { + + $item = array(); + $item["username"] = isset($results[$i][$ldap_result_username][0]) ? $results[$i][$ldap_result_username][0] : ""; + $item["employee_number"] = isset($results[$i][$ldap_result_emp_num][0]) ? $results[$i][$ldap_result_emp_num][0] : ""; + $item["lastname"] = isset($results[$i][$ldap_result_last_name][0]) ? $results[$i][$ldap_result_last_name][0] : ""; + $item["firstname"] = isset($results[$i][$ldap_result_first_name][0]) ? $results[$i][$ldap_result_first_name][0] : ""; + $item["email"] = isset($results[$i][$ldap_result_email][0]) ? $results[$i][$ldap_result_email][0] : "" ; + $item["ldap_location_override"] = isset($results[$i]["ldap_location_override"]) ? $results[$i]["ldap_location_override"]:""; + $item["location_id"] = isset($results[$i]["location_id"]) ? $results[$i]["location_id"]:""; + + $user = User::where('username', $item["username"])->first(); + if ($user) { + // Updating an existing user. + $item["createorupdate"] = 'updated'; + } else { + // Creating a new user. + $user = new User; + $user->password = $pass; + $user->activated = 0; + $item["createorupdate"] = 'created'; + } + + $user->first_name = $item["firstname"]; + $user->last_name = $item["lastname"]; + $user->username = $item["username"]; + $user->email = $item["email"]; + $user->employee_num = e($item["employee_number"]); + + // Sync activated state for Active Directory. + if ( array_key_exists('useraccountcontrol', $results[$i]) ) { + /* The following is _probably_ the correct logic, but we can't use it because + some users may have been dependent upon the previous behavior, and this + could cause additional access to be available to users they don't want + to allow to log in. + + $useraccountcontrol = $results[$i]['useraccountcontrol'][0]; + if( + // based on MS docs at: https://support.microsoft.com/en-us/help/305144/how-to-use-useraccountcontrol-to-manipulate-user-account-properties + ($useraccountcontrol & 0x200) && // is a NORMAL_ACCOUNT + !($useraccountcontrol & 0x02) && // *and* _not_ ACCOUNTDISABLE + !($useraccountcontrol & 0x10) // *and* _not_ LOCKOUT + ) { + $user->activated = 1; + } else { + $user->activated = 0; + } */ + $enabled_accounts = [ + '512', // 0x200 NORMAL_ACCOUNT + '544', // 0x220 NORMAL_ACCOUNT, PASSWD_NOTREQD + '66048', // 0x10200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD + '66080', // 0x10220 NORMAL_ACCOUNT, PASSWD_NOTREQD, DONT_EXPIRE_PASSWORD + '262656', // 0x40200 NORMAL_ACCOUNT, SMARTCARD_REQUIRED + '262688', // 0x40220 NORMAL_ACCOUNT, PASSWD_NOTREQD, SMARTCARD_REQUIRED + '328192', // 0x50200 NORMAL_ACCOUNT, SMARTCARD_REQUIRED, DONT_EXPIRE_PASSWORD + '328224', // 0x50220 NORMAL_ACCOUNT, PASSWD_NOT_REQD, SMARTCARD_REQUIRED, DONT_EXPIRE_PASSWORD + '4260352',// 0x410200 NORMAL_ACCOUNT, DONT_EXPIRE_PASSWORD, DONT_REQ_PREAUTH + '1049088',// 0x100200 NORMAL_ACCOUNT, NOT_DELEGATED + ]; + $user->activated = ( in_array($results[$i]['useraccountcontrol'][0], $enabled_accounts) ) ? 1 : 0; + } + + // If we're not using AD, and there isn't an activated flag set, activate all users + elseif (empty($ldap_result_active_flag)) { + $user->activated = 1; + } + + if ($item['ldap_location_override'] == true) { + $user->location_id = $item['location_id']; + } elseif ((isset($location)) && (!empty($location))) { + + if ((is_array($location)) && (array_key_exists('id', $location))) { + $user->location_id = $location['id']; + } elseif (is_object($location)) { + $user->location_id = $location->id; + } + + } + + $user->ldap_import = 1; + + $errors = ''; + + if ($user->save()) { + $item["note"] = $item["createorupdate"]; + $item["status"]='success'; + } else { + foreach ($user->getErrors()->getMessages() as $key => $err) { + $errors .= $err[0]; + } + $item["note"] = $errors; + $item["status"]='error'; + } + + array_push($summary, $item); + } + } - } - /** - * Check to make sure we can access the server. - * - * @author Wes Hulette - * - * @since 5.0.0 - */ - private function checkLdapConnection(): void - { - try { - $this->ldap->testLdapAdUserConnection(); - $this->ldap->testLdapAdBindConnection(); - } catch (Exception $e) { - $this->outputError($e); - exit(0); - } - } - - /** - * Output the json summary to the screen if enabled. - * - * @param Exception $error - */ - private function outputError($error): void - { - if ($this->option('json_summary')) { - $json_summary = [ - 'error' => true, - 'error_message' => $error->getMessage(), - 'summary' => [], - ]; + if ($this->option('summary')) { + for ($x = 0; $x < count($summary); $x++) { + if ($summary[$x]['status']=='error') { + $this->error('ERROR: '.$summary[$x]['firstname'].' '.$summary[$x]['lastname'].' (username: '.$summary[$x]['username'].') was not imported: '.$summary[$x]['note']); + } else { + $this->info('User '.$summary[$x]['firstname'].' '.$summary[$x]['lastname'].' (username: '.$summary[$x]['username'].') was '.strtoupper($summary[$x]['createorupdate']).'.'); + } + } + } else if ($this->option('json_summary')) { + $json_summary = [ "error" => false, "error_message" => "", "summary" => $summary ]; // hardcoding the error to false and the error_message to blank seems a bit weird $this->info(json_encode($json_summary)); + } else { + return $summary; } - $this->error($error->getMessage()); - LOG::error($error); } } diff --git a/app/Console/Commands/LdapSyncNg.php b/app/Console/Commands/LdapSyncNg.php new file mode 100644 index 0000000000..527a99777c --- /dev/null +++ b/app/Console/Commands/LdapSyncNg.php @@ -0,0 +1,399 @@ + + * + * @since 5.0.0 + */ +class LdapSyncNg extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'snipeit:ldap-sync-ng + {--location= : A location name } + {--location_id= : A location id} + {--base_dn= : A diffrent base DN to use } + {--summary : Print summary } + {--json_summary : Print summary in json format } + {--dryrun : Run the sync process but don\'t update the database}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Command line LDAP/AD sync'; + + /** + * An LdapAd instance. + * + * @var \App\Models\LdapAd + */ + private $ldap; + + /** + * LDAP settings collection. + * + * @var \Illuminate\Support\Collection + */ + private $settings = null; + + /** + * A default location collection. + * + * @var \Illuminate\Support\Collection + */ + private $defaultLocation = null; + + /** + * Mapped locations collection. + * + * @var \Illuminate\Support\Collection + */ + private $mappedLocations = null; + + /** + * The summary collection. + * + * @var \Illuminate\Support\Collection + */ + private $summary; + + /** + * Is dry-run? + * + * @var bool + */ + private $dryrun = false; + + /** + * Show users to be imported. + * + * @var array + */ + private $userlist = []; + + /** + * Create a new command instance. + */ + public function __construct(LdapAd $ldap) + { + parent::__construct(); + $this->ldap = $ldap; + $this->settings = $this->ldap->ldapSettings; + $this->summary = collect(); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + + $dispatcher = \Adldap\Adldap::getEventDispatcher(); + + // Listen for all model events. + $dispatcher->listen('Adldap\Models\Events\*', function ($eventName, array $data) { + echo $eventName; // Returns 'Adldap\Models\Events\Updating' + var_dump($data); // Returns [0] => (object) Adldap\Models\Events\Updating; + \Log::debug("Event: ".$eventName." data - ".print_r($data, true)); + }); + $dispatcher->listen('Adldap\Auth\Events\*', function ($eventName, array $data) { + echo $eventName; // Returns 'Adldap\Models\Events\Updating' + var_dump($data); // Returns [0] => (object) Adldap\Models\Events\Updating; + \Log::debug("Event: ".$eventName." data - ".print_r($data, true)); + }); + + ini_set('max_execution_time', env('LDAP_TIME_LIM', "600")); //600 seconds = 10 minutes + ini_set('memory_limit', '500M'); + $old_error_reporting = error_reporting(); // grab old error_reporting .ini setting, for later re-enablement + error_reporting($old_error_reporting & ~E_DEPRECATED); // disable deprecation warnings, for LDAP in PHP 7.4 (and greater) + + if ($this->option('dryrun')) { + $this->dryrun = true; + } + $this->checkIfLdapIsEnabled(); + $this->checkLdapConnection(); + $this->setBaseDn(); + $this->getUserDefaultLocation(); + /* + * Use the default location if set, this is needed for the LDAP users sync page + */ + if (!$this->option('base_dn') && null == $this->defaultLocation) { + $this->getMappedLocations(); + } + $this->processLdapUsers(); + // Print table of users + if ($this->dryrun) { + $this->info('The following users will be synced!'); + $headers = ['First Name', 'Last Name', 'Username', 'Email', 'Employee #', 'Location Id', 'Status']; + $this->table($headers, $this->summary->toArray()); + } + + error_reporting($old_error_reporting); // re-enable deprecation warnings. + return $this->getSummary(); + } + + /** + * Generate the LDAP sync summary. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return string + */ + private function getSummary(): string + { + if ($this->option('summary') && null === $this->dryrun) { + $this->summary->each(function ($item) { + $this->info('USER: '.$item['note']); + + if ('ERROR' === $item['status']) { + $this->error('ERROR: '.$item['note']); + } + }); + } elseif ($this->option('json_summary')) { + $json_summary = [ + 'error' => false, + 'error_message' => '', + 'summary' => $this->summary->toArray(), + ]; + $this->info(json_encode($json_summary)); + } + + return ''; + } + + /** + * Create a new user or update an existing user. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @param \Adldap\Models\User $snipeUser + */ + private function updateCreateUser(AdldapUser $snipeUser): void + { + $user = $this->ldap->processUser($snipeUser, $this->defaultLocation, $this->mappedLocations); + $summary = [ + 'firstname' => $user->first_name, + 'lastname' => $user->last_name, + 'username' => $user->username, + 'employee_number' => $user->employee_num, + 'email' => $user->email, + 'location_id' => $user->location_id, + ]; + // Only update the database if is not a dry run + if (!$this->dryrun) { + if ($user->isDirty()) { //if nothing on the user changed, don't bother trying to save anything nor put anything in the summary + if ($user->save()) { + $summary['note'] = ($user->wasRecentlyCreated ? 'CREATED' : 'UPDATED'); + $summary['status'] = 'SUCCESS'; + } else { + $errors = ''; + foreach ($user->getErrors()->getMessages() as $error) { + $errors .= implode(", ",$error); + } + $summary['note'] = $snipeUser->getDN().' was not imported. REASON: '.$errors; + $summary['status'] = 'ERROR'; + } + } else { + $summary = null; + } + } + + // $summary['note'] = ($user->getOriginal('username') ? 'UPDATED' : 'CREATED'); // this seems, kinda, like, superfluous, relative to the $summary['note'] thing above, yeah? + if($summary) { //if the $user wasn't dirty, $summary was set to null so that we will skip the following push() + $this->summary->push($summary); + } + } + + /** + * Process the users to update / create. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + */ + private function processLdapUsers(): void + { + try { + \Log::debug("CAL:LING GET LDAP SUSERS"); + $ldapUsers = $this->ldap->getLdapUsers(); + \Log::debug("END CALLING GET LDAP USERS"); + } catch (Exception $e) { + $this->outputError($e); + exit($e->getMessage()); + } + + if (0 == $ldapUsers->count()) { + $msg = 'ERROR: No users found!'; + Log::error($msg); + if ($this->dryrun) { + $this->error($msg); + } + exit($msg); + } + + // Process each individual users + foreach ($ldapUsers->getResults() as $user) { // AdLdap2's paginate() method is weird, it gets *everything* and ->getResults() returns *everything* + $this->updateCreateUser($user); + } + } + + /** + * Get the mapped locations if a base_dn is provided. + * + * @author Wes Hulette + * + * @since 5.0.0 + */ + private function getMappedLocations() + { + $ldapOuLocation = Location::where('ldap_ou', '!=', '')->select(['id', 'ldap_ou'])->get(); + $locations = $ldapOuLocation->sortBy(function ($ou, $key) { + return strlen($ou->ldap_ou); + }); + if ($locations->count() > 0) { + $msg = 'Some locations have special OUs set. Locations will be automatically set for users in those OUs.'; + LOG::debug($msg); + if ($this->dryrun) { + $this->info($msg); + } + + $this->mappedLocations = $locations->pluck('ldap_ou', 'id'); // TODO: this seems ok-ish, but the key-> value is going location_id -> OU name, and the primary action here is the opposite of that - going from OU's to location ID's. + } + } + + /** + * Set the base dn if supplied. + * + * @author Wes Hulette + * + * @since 5.0.0 + */ + private function setBaseDn(): void + { + if ($this->option('base_dn')) { + $this->ldap->baseDn = $this->option('base_dn'); + $msg = sprintf('Importing users from specified base DN: "%s"', $this->ldap->baseDn); + LOG::debug($msg); + if ($this->dryrun) { + $this->info($msg); + } + } + } + + /** + * Get a default location id for imported users. + * + * @author Wes Hulette + * + * @since 5.0.0 + */ + private function getUserDefaultLocation(): void + { + $location = $this->option('location_id') ?? $this->option('location'); + if ($location) { + $userLocation = Location::where('name', '=', $location) + ->orWhere('id', '=', intval($location)) + ->select(['name', 'id']) + ->first(); + if ($userLocation) { + $msg = 'Importing users with default location: '.$userLocation->name.' ('.$userLocation->id.')'; + LOG::debug($msg); + + if ($this->dryrun) { + $this->info($msg); + } + + $this->defaultLocation = collect([ + $userLocation->id => $userLocation->name, + ]); + } else { + $msg = 'The supplied location is invalid!'; + LOG::error($msg); + if ($this->dryrun) { + $this->error($msg); + } + exit(0); + } + } + } + + /** + * Check if LDAP intergration is enabled. + * + * @author Wes Hulette + * + * @since 5.0.0 + */ + private function checkIfLdapIsEnabled(): void + { + if (false === $this->settings['ldap_enabled']) { + $msg = 'LDAP intergration is not enabled. Exiting sync process.'; + $this->info($msg); + Log::info($msg); + exit(0); + } + } + + /** + * Check to make sure we can access the server. + * + * @author Wes Hulette + * + * @since 5.0.0 + */ + private function checkLdapConnection(): void + { + try { + $this->ldap->testLdapAdUserConnection(); + $this->ldap->testLdapAdBindConnection(); + } catch (Exception $e) { + $this->outputError($e); + exit(0); + } + } + + /** + * Output the json summary to the screen if enabled. + * + * @param Exception $error + */ + private function outputError($error): void + { + if ($this->option('json_summary')) { + $json_summary = [ + 'error' => true, + 'error_message' => $error->getMessage(), + 'summary' => [], + ]; + $this->info(json_encode($json_summary)); + } + $this->error($error->getMessage()); + LOG::error($error); + } +} diff --git a/app/Http/Controllers/Api/SettingsController.php b/app/Http/Controllers/Api/SettingsController.php index 7c499b7397..f4177ea663 100644 --- a/app/Http/Controllers/Api/SettingsController.php +++ b/app/Http/Controllers/Api/SettingsController.php @@ -16,6 +16,8 @@ use Illuminate\Support\Facades\Notification; use GuzzleHttp\Client; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; +use App\Models\Ldap; // forward-port of v4 LDAP model for Sync + class SettingsController extends Controller { @@ -76,10 +78,22 @@ class SettingsController extends Controller Log::info('Preparing to get sample user set from LDAP directory'); // Get a sample of 10 users so user can verify the data is correct + $settings = Setting::getSettings(); try { Log::info('Testing LDAP sync'); error_reporting(E_ALL & ~E_DEPRECATED); // workaround for php7.4, which deprecates ldap_control_paged_result - $users = $ldap->testUserImportSync(); + // $users = $ldap->testUserImportSync(); // from AdLdap2 from v5, disabling and falling back to v4's sync code + $users = collect(Ldap::findLdapUsers())->slice(0, 11)->filter(function ($value, $key) { //choosing ELEVEN because one is going to be the count, which we're about to filter out in the next line + return is_int($key); + })->map(function ($item) use ($settings) { + return (object) [ + 'username' => $item[$settings['ldap_username_field']][0] ?? null, + 'employee_number' => $item[$settings['ldap_emp_num']][0] ?? null, + 'lastname' => $item[$settings['ldap_lname_field']][0] ?? null, + 'firstname' => $item[$settings['ldap_fname_field']][0] ?? null, + 'email' => $item[$settings['ldap_email']][0] ?? null, + ]; + }); $message['user_sync'] = [ 'users' => $users ]; diff --git a/app/Models/Ldap.php b/app/Models/Ldap.php new file mode 100644 index 0000000000..d4310fa6c5 --- /dev/null +++ b/app/Models/Ldap.php @@ -0,0 +1,297 @@ + 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; + $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 ($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 + * + */ + static function findAndBindUserLdap($username, $password) + { + $settings = Setting::getSettings(); + $connection = Ldap::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 { + // In case they haven't added an AD domain + $userDn = ($settings->ad_domain != '') ? $username.'@'.$settings->ad_domain : $username.'@'.$settings->email_domain; + } + + } + + \Log::debug('Attempting to login using distinguished name:'.$userDn); + + + $filterQuery = $settings->ldap_auth_filter_query . $username; + + + if (!$ldapbind = @ldap_bind($connection, $userDn, $password)) { + if(!$ldapbind = Ldap::bindAdminToLdap($connection)){ + 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 + * + */ + static function bindAdminToLdap($connection) + { + + $ldap_username = Setting::getSettings()->ldap_uname; + + // 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)); + } + + } + + + /** + * Parse and map LDAP attributes based on settings + * + * @author [A. Gianotto] [] + * @since [v3.0] + * + * @param $ldapatttibutes + * @return array|bool + */ + static function parseAndMapLdapAttributes($ldapatttibutes) + { + //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; + + // Get LDAP user data + $item = array(); + $item["username"] = isset($ldapatttibutes[$ldap_result_username][0]) ? $ldapatttibutes[$ldap_result_username][0] : ""; + $item["employee_number"] = isset($ldapatttibutes[$ldap_result_emp_num][0]) ? $ldapatttibutes[$ldap_result_emp_num][0] : ""; + $item["lastname"] = isset($ldapatttibutes[$ldap_result_last_name][0]) ? $ldapatttibutes[$ldap_result_last_name][0] : ""; + $item["firstname"] = isset($ldapatttibutes[$ldap_result_first_name][0]) ? $ldapatttibutes[$ldap_result_first_name][0] : ""; + $item["email"] = isset($ldapatttibutes[$ldap_result_email][0]) ? $ldapatttibutes[$ldap_result_email][0] : "" ; + + return $item; + + + } + + /** + * Create user from LDAP attributes + * + * @author [A. Gianotto] [] + * @since [v3.0] + * @param $ldapatttibutes + * @return array|bool + */ + static function createUserFromLdap($ldapatttibutes) + { + $item = Ldap::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(Input::get("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 $ldapatttibutes + * @param $base_dn + * @return array|bool + */ + static function findLdapUsers($base_dn = null) + { + + $ldapconn = Ldap::connectToLdap(); + $ldap_bind = Ldap::bindAdminToLdap($ldapconn); + // Default to global base DN if nothing else is provided. + if (is_null($base_dn)) { + $base_dn = Setting::getSettings()->ldap_basedn; + } + $filter = Setting::getSettings()->ldap_filter; + + // Set up LDAP pagination for very large databases + $page_size = 500; + $cookie = ''; + $result_set = array(); + $global_count = 0; + + // Perform the search + do { + + // Paginate (non-critical, if not supported by server) + if (!$ldap_paging = @ldap_control_paged_result($ldapconn, $page_size, false, $cookie)) { + throw new Exception('Problem with your LDAP connection. Try checking the Use TLS setting in Admin > Settings. '); + } + + 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)"; + } + $search_results = ldap_search($ldapconn, $base_dn, $filter); + + if (!$search_results) { + return redirect()->route('users.index')->with('error', trans('admin/users/message.error.ldap_could_not_search').ldap_error($ldapconn)); // FIXME this is never called in any routed context - only from the Artisan command. So this redirect will never work. + } + + // 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)); // FIXME 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); + + @ldap_control_paged_result_response($ldapconn, $search_results, $cookie); + + } while ($cookie !== null && $cookie != ''); + + + // Clean up after search + $result_set['count'] = $global_count; + $results = $result_set; + @ldap_control_paged_result($ldapconn, 0); + + return $results; + + + } +} diff --git a/app/Services/LdapAd.php b/app/Services/LdapAd.php index d5e83de576..937507d2d7 100644 --- a/app/Services/LdapAd.php +++ b/app/Services/LdapAd.php @@ -332,6 +332,7 @@ class LdapAd extends LdapAdConfiguration $activeStatus = (in_array($user->getUserAccountControl(), self::AD_USER_ACCOUNT_CONTROL_FLAGS)) ? 1 : 0; } else { + //\Log::debug('This looks like LDAP (or an AD where the UAC is disabled)'); // If there is no activated flag, then we can't make any determination about activated/deactivated if (false == $this->ldapSettings['ldap_active_flag']) { \Log::debug('ldap_active_flag is false - no ldap_active_flag is set'); @@ -548,6 +549,7 @@ class LdapAd extends LdapAdConfiguration if (!is_null($filter)) { $search = $search->rawFilter($filter); } + //I think it might be possible to potentially do our own paging here? return $search->select($this->getSelectedFields()) ->paginate(self::PAGE_SIZE);