From 9d0b163c111bfb04fabe3b4c3b23e2a6a656b344 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Mon, 7 Feb 2022 08:58:49 -0800 Subject: [PATCH] adding untracked files --- app/Console/Commands/LdapSyncNg.php | 399 ++++++++++++ .../PreventRequestsDuringMaintenance 2.php | 17 + app/Http/Middleware/TrimStrings 2.php | 19 + app/Http/Middleware/TrustHosts 2.php | 20 + app/Services/LdapAd.php | 568 ++++++++++++++++++ app/Services/LdapAdConfiguration.php | 299 +++++++++ 6 files changed, 1322 insertions(+) create mode 100644 app/Console/Commands/LdapSyncNg.php create mode 100644 app/Http/Middleware/PreventRequestsDuringMaintenance 2.php create mode 100644 app/Http/Middleware/TrimStrings 2.php create mode 100644 app/Http/Middleware/TrustHosts 2.php create mode 100644 app/Services/LdapAd.php create mode 100644 app/Services/LdapAdConfiguration.php 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/Middleware/PreventRequestsDuringMaintenance 2.php b/app/Http/Middleware/PreventRequestsDuringMaintenance 2.php new file mode 100644 index 0000000000..e4956d0bb9 --- /dev/null +++ b/app/Http/Middleware/PreventRequestsDuringMaintenance 2.php @@ -0,0 +1,17 @@ +allSubdomainsOfApplicationUrl(), + ]; + } +} diff --git a/app/Services/LdapAd.php b/app/Services/LdapAd.php new file mode 100644 index 0000000000..4bfef3d3ed --- /dev/null +++ b/app/Services/LdapAd.php @@ -0,0 +1,568 @@ + + * + * @since 5.0.0 + */ +class LdapAd extends LdapAdConfiguration +{ + /* 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; + } */ + const AD_USER_ACCOUNT_CONTROL_FLAGS = [ + '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 + '1114624',// 0x110200 NORMAL_ACCOUNT, NOT_DELEGATED, DONT_EXPIRE_PASSWORD + ]; + + /** + * The LDAP results per page. + */ + const PAGE_SIZE = 500; + + /** + * A base dn. + * + * @var string + */ + public $baseDn = null; + + /** + * Adldap instance. + * + * @var \Adldap\Adldap + */ + protected $ldap; + + /** + * Initialize LDAP from user settings + * + * @since 5.0.0 + * + * @return void + */ + public function init() + { + // Already initialized + if ($this->ldap) { + return true; + } + + parent::init(); + if($this->isLdapEnabled()) { + if($this->ldapSettings['is_ad'] == 0 ) { //only for NON-AD setups! + $this->ldapConfig['account_prefix'] = $this->ldapSettings['ldap_auth_filter_query']; + $this->ldapConfig['account_suffix'] = ','.$this->ldapConfig['base_dn']; + } /* + To the point mentioned in ldapLogin(), we might want to add an 'else' clause here that + sets up an 'account_suffix' of '@'.$this->ldapSettings['ad_domain'] *IF* the user has + $this->ldapSettings['ad_append_domain'] enabled. + That code in ldapLogin gets simplified, in exchange for putting all the weirdness here only. + */ + $this->ldap = new Adldap(); + $this->ldap->addProvider($this->ldapConfig); + return true; + } + return false; + } + + public function __construct() { + $this->init(); + } + + /** + * Create a user if they successfully login to the LDAP server. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @param string $username + * @param string $password + * + * @return \App\Models\User + * + * @throws Exception + */ + public function ldapLogin(string $username, string $password): User + { + if ($this->ldapSettings['ad_append_domain']) { //if you're using 'userprincipalname', don't check the ad_append_domain checkbox + $login_username = $username . '@' . $this->ldapSettings['ad_domain']; // I feel like could can be solved with the 'suffix' feature? Then this would be easier. + } else { + $login_username = $username; + } + + if ($this->ldapConfig['username'] && $this->ldapConfig['password']) { + $bind_as_user = false; + } else { + $bind_as_user = true; + } + + if (($this->ldap) && ($this->ldap->auth()->attempt($login_username, $password, $bind_as_user) === false)) { + throw new Exception('Unable to validate user credentials!'); + } + + // Should we sync the logged in user + Log::debug('Attempting to find user in LDAP directory'); + $record = $this->ldap->search()->findBy($this->ldapSettings['ldap_username_field'], $username); + + if($record) { + if ($this->isLdapSync($record)) { + $this->syncUserLdapLogin($record, $password); + } + } + else { + throw new Exception('Unable to find user in LDAP directory!'); + } + + $user = User::where('username', $username) + ->whereNull('deleted_at')->where('ldap_import', '=', 1) + ->where('activated', '=', '1')->first(); + /* Above, I could've just done ->firstOrFail() which would've been cleaner, but it would've been miserable to + troubleshoot if it ever came up (giving a really generic and untraceable error message) + */ + if (!$user) { + throw new Exception("User is either deleted, not activated (can't log in), not from LDAP, or can't be found in database"); + } + + return $user; + } + + /** + * Set the user information based on the LDAP settings. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @param \Adldap\Models\User $user + * @param null|Collection $defaultLocation + * @param null|Collection $mappedLocations + * + * @return null|\App\Models\User + */ + public function processUser(AdldapUser $user, ?Collection $defaultLocation=null, ?Collection $mappedLocations=null): ?User + { + // Only sync active users <- I think this actually means 'existing', not 'activated/deactivated' + if(!$user) { + return null; + } + $snipeUser = []; + $snipeUser['username'] = $user->{$this->ldapSettings['ldap_username_field']}[0] ?? ''; + $snipeUser['employee_number'] = $user->{$this->ldapSettings['ldap_emp_num']}[0] ?? ''; + $snipeUser['lastname'] = $user->{$this->ldapSettings['ldap_lname_field']}[0] ?? ''; + $snipeUser['firstname'] = $user->{$this->ldapSettings['ldap_fname_field']}[0] ?? ''; + $snipeUser['email'] = $user->{$this->ldapSettings['ldap_email']}[0] ?? ''; + $snipeUser['title'] = $user->getTitle() ?? ''; + $snipeUser['telephonenumber'] = $user->getTelephoneNumber() ?? ''; + + /* + * $locationId being 'null' means we have no per-OU location information, + * but instead of explicitly setting it to null - which would override any admin-generated + * location assignments - we just don't set it at all. For a brand new User, the 'default null' + * on the column will cover us. For an already existing user, this will not override any + * locations that were explicitly chosen by the administrators. + * + * When syncing with a particular 'default location' in mind, those should still be respected + * and it *will* override the administrators previous choices. I think this is a fair compromise. + */ + $locationId = $this->getLocationId($user, $defaultLocation, $mappedLocations); + if ($locationId !== null ) { + $snipeUser['location_id'] = $locationId; + } + + $activeStatus = $this->getActiveStatus($user); + if ($activeStatus !== null) { + $snipeUser['activated'] = $activeStatus; + } + + return $this->setUserModel($snipeUser); + } + + /** + * Set the User model information. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @param array $userInfo The user info to save to the database + * + * @return \App\Models\User + */ + public function setUserModel(array $userInfo): User + { + // If the username exists, return the user object, otherwise create a new user object + $user = User::firstOrNew([ + 'username' => $userInfo['username'], + ]); + $user->username = $user->username ?? trim($userInfo['username']); + $user->password = $user->password ?? Helper::generateEncyrptedPassword(); + $user->first_name = trim($userInfo['firstname']); + $user->last_name = trim($userInfo['lastname']); + $user->email = trim($userInfo['email']); + $user->employee_num = trim($userInfo['employee_number']); + $user->jobtitle = trim($userInfo['title']); + $user->phone = trim($userInfo['telephonenumber']); + if (array_key_exists('activated',$userInfo)) { + $user->activated = $userInfo['activated']; + } else if ( !$user->exists ) { // no 'activated' flag was set or unset, *AND* this user is new - activate by default. + $user->activated = 1; + } + if (array_key_exists('location_id',$userInfo)) { + $user->location_id = $userInfo['location_id']; + } + + // this is a new user + if (!isset($user->id)) { + $user->notes = 'Imported from LDAP'; + } + + $user->ldap_import = 1; + + return $user; + } + + /** + * Sync a user who has logged in by LDAP. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @param \Adldap\Models\User $record + * @param string $password + * + * @throws Exception + */ + private function syncUserLdapLogin(AdldapUser $record, string $password): void + { + $user = $this->processUser($record); + + if (is_null($user->last_login)) { + $user->notes = 'Imported on first login from LDAP2'; + } + + if ($this->ldapSettings['ldap_pw_sync']) { + Log::debug('Syncing users password with LDAP directory.'); + $user->password = bcrypt($password); + } + + if (!$user->save()) { + Log::debug('Could not save user. '.$user->getErrors()); + throw new Exception('Could not save user: '.$user->getErrors()); + } + } + + /** + * Check to see if we should sync the user with the LDAP directory. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @param \Adldap\Models\User $user + * + * @return bool + */ + private function isLdapSync(AdldapUser $user): bool + { + if ( !$this->ldapSettings['ldap_active_flag']) { + return true; // always sync if you didn't define an 'active' flag + } + + if ( $user->{$this->ldapSettings['ldap_active_flag']} && // if your LDAP user has the aforementioned flag as an attribute *AND* + count($user->{$this->ldapSettings['ldap_active_flag']}) == 1 && // if that attribute has exactly one value *AND* + strtolower($user->{$this->ldapSettings['ldap_active_flag']}[0]) == 'false') { // that value is the string 'false' (regardless of case), + return false; // then your user is *INACTIVE* - return false + } + // otherwise, return true + return true; + } + + /** + * Set the active status of the user. + * Returns 0 or 1 if the user is deactivated or activated + * or returns null if we just don't know + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @param \Adldap\Models\User $user + * + * @return int (or null) + */ + private function getActiveStatus(AdldapUser $user): ?int + { + /* + * Check to see if we are connected to an AD server + * if so, check the Active Directory User Account Control Flags + * If the admin has set their own 'active flag' - respect that instead + * (this may work to allow AD users to ignore the built-in UAC stuff that AD does) + */ + if ($user->hasAttribute($user->getSchema()->userAccountControl()) && !$this->ldapSettings['ldap_active_flag']) { + \Log::debug('This is AD - userAccountControl is'. $user->getSchema()->userAccountControl()); + $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'); + return null; + } + + // If there *is* an activated flag, then respect it *only* if it is actually present. If it's not there, ignore it. + if (!$user->hasAttribute($this->ldapSettings['ldap_active_flag'])) { + return null; // 'active' flag is defined, but does not exist on returned user record. So we don't know if they're active or not. + } + + // if $user has the flag *AND* that flag has exactly one value - + if ( $user->{$this->ldapSettings['ldap_active_flag']} && count($user->{$this->ldapSettings['ldap_active_flag']}) == 1 ) { + + $active_flag_value = $user->{$this->ldapSettings['ldap_active_flag']}[0]; + + // if the value of that flag is case-insensitively the string 'false' or boolean false + if ( strcasecmp($active_flag_value, "false") == 0 || $active_flag_value === false ) { + return 0; // then make them INACTIVE + } else { + return 1; // otherwise active + } + } + return 1; // fail 'open' (active) if we have the attribute and it's multivalued or empty; that's weird + } + + return $activeStatus; + } + + /** + * Get a default selected location, or a OU mapped location if available. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @param \Adldap\Models\User $user + * @param Collection|null $defaultLocation + * @param Collection|null $mappedLocations + * + * @return null|int + */ + private function getLocationId(AdldapUser $user, ?Collection $defaultLocation, ?Collection $mappedLocations): ?int + { + $locationId = null; + // Set the users default locations, if set + if ($defaultLocation) { + $locationId = $defaultLocation->keys()->first(); + } + + // Check to see if the user is in a mapped location + if ($mappedLocations) { + $location = $mappedLocations->filter(function ($value, $key) use ($user) { + //if ($user->inOu($value)) { // <----- *THIS* seems not to be working, and it seems more 'intelligent' - but it's literally just a strpos() call, and it doesn't work quite right against plain strings + $user_ou = substr($user->getDn(), -strlen($value)); // get the LAST chars of the user's DN, the count of those chars being the length of the thing we're checking against + if(strcasecmp($user_ou, $value) === 0) { // case *IN*sensitive comparision - some people say OU=blah, some say ou=blah. returns 0 when strings are identical (which is a little odd, yeah) + return $key; // WARNING: we are doing a 'filter' - not a regular for-loop. So the answer(s) get "return"ed into the $location array + } + }); + + if ($location->count() > 0) { + $locationId = $location->keys()->first(); // from the returned $location array from the ->filter() method above, we return the first match - there should be only one + } + } + + return $locationId; + } + + /** + * Get the base dn for the query. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return string + */ + private function getBaseDn(): string + { + if (!is_null($this->baseDn)) { + return $this->baseDn; + } + + return $this->ldapSettings['ldap_basedn']; + } + + /** + * Format the ldap filter if needed. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return null|string + */ + private function getFilter(): ?string + { + $filter = $this->ldapSettings['ldap_filter']; + if (!$filter) { + return null; + } + // Add surrounding parentheses as needed + $paren = mb_substr($filter, 0, 1, 'utf-8'); + if ('(' !== $paren) { + return '('.$filter.')'; + } + + return $filter; + } + + /** + * Get the selected fields to return + * This should help with memory on large result sets as we are not returning all fields. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return array + */ + private function getSelectedFields(): array + { + /** @var Schema $schema */ + $schema = new $this->ldapConfig['schema']; + return array_values(array_filter([ + $this->ldapSettings['ldap_username_field'], + $this->ldapSettings['ldap_fname_field'], + $this->ldapSettings['ldap_lname_field'], + $this->ldapSettings['ldap_email'], + $this->ldapSettings['ldap_emp_num'], + $this->ldapSettings['ldap_active_flag'], + $schema->memberOf(), + $schema->userAccountControl(), + $schema->title(), + $schema->telephone(), + ])); + } + + /** + * Test the bind user connection. + * + * @author Wes Hulette + * @throws \Exception + * @since 5.0.0 + */ + public function testLdapAdBindConnection(): void + { + try { + $this->ldap->search()->ous()->get()->count(); //it's saying this is null? + } catch (Exception $th) { + Log::error($th->getMessage()); + throw new Exception('Unable to search LDAP directory!'); + } + } + + /** + * Test the user can connect to the LDAP server. + * + * @author Wes Hulette + * @throws \Exception + * @since 5.0.0 + */ + public function testLdapAdUserConnection(): void + { + try { + $this->ldap->connect(); + } catch (\Exception $e) { + Log::debug('LDAP ERROR: '.$e->getMessage()); + throw new Exception($e->getMessage()); + } + } + + /** + * Test the LDAP configuration by returning up to 10 users. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return Collection + */ + public function testUserImportSync(): Collection + { + $testUsers = collect($this->getLdapUsers()->getResults())->chunk(10)->first(); + if ($testUsers) { + return $testUsers->map(function ($item) { + return (object) [ + 'username' => $item->{$this->ldapSettings['ldap_username_field']}[0] ?? null, + 'employee_number' => $item->{$this->ldapSettings['ldap_emp_num']}[0] ?? null, + 'lastname' => $item->{$this->ldapSettings['ldap_lname_field']}[0] ?? null, + 'firstname' => $item->{$this->ldapSettings['ldap_fname_field']}[0] ?? null, + 'email' => $item->{$this->ldapSettings['ldap_email']}[0] ?? null, + ]; + }); + } + + return collect(); + } + + /** + * Query the LDAP server to get the users to process and return a page set. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return \Adldap\Query\Paginator + */ + public function getLdapUsers(): Paginator + { + $search = $this->ldap->search()->users()->in($this->getBaseDn()); //this looks wrong; we should instead have a passable parameter that does this, and use this as a 'sane' default, yeah? + + $filter = $this->getFilter(); + 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); + } +} diff --git a/app/Services/LdapAdConfiguration.php b/app/Services/LdapAdConfiguration.php new file mode 100644 index 0000000000..e9b7e96d42 --- /dev/null +++ b/app/Services/LdapAdConfiguration.php @@ -0,0 +1,299 @@ + + * + * @since 5.0.0 + */ +class LdapAdConfiguration +{ + const LDAP_PORT = 389; + const CONNECTION_TIMEOUT = 5; + const DEFAULT_LDAP_VERSION = 3; + const LDAP_BOOLEAN_SETTINGS = [ + 'ldap_enabled', + 'ldap_server_cert_ignore', + 'ldap_tls', + 'ldap_tls', + 'ldap_pw_sync', + 'is_ad', + 'ad_append_domain', + ]; + + /** + * Ldap Settings. + * + * @var Collection + */ + public $ldapSettings; + + /** + * LDAP Config. + * + * @var array + */ + public $ldapConfig; + + /** + * Initialize LDAP from user settings + * + * @since 5.0.0 + */ + public function init() { + + // This try/catch is dumb, but is necessary to run initial migrations, since + // this service provider is booted even during migrations. :( - snipe + try { + $this->ldapSettings = $this->getSnipeItLdapSettings(); + if ($this->isLdapEnabled()) { + $this->setSnipeItConfig(); + } + } catch (\Exception $e) { + \Log::debug($e); + $this->ldapSettings = null; + } + + } + + /** + * Merge the default Adlap config with the SnipeIT config. + * + * @author Wes Hulette + * + * @since 5.0.0 + */ + private function setSnipeItConfig() + { + $this->ldapConfig = $this->setLdapConnectionConfiguration(); + $this->certificateCheck(); + } + + /** + * Get the LDAP settings from the Settings model. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return \Illuminate\Support\Collection + */ + private function getSnipeItLdapSettings(): Collection + { + $ldapSettings = collect(); + if(Setting::first()) { // during early migration steps, there may be no settings table entry to start with + $ldapSettings = Setting::getLdapSettings() + ->map(function ($item, $key) { + // Trim the items + if (is_string($item)) { + $item = trim($item); + } + // Get the boolean value of the LDAP setting, makes it easier to work with them + if (in_array($key, self::LDAP_BOOLEAN_SETTINGS)) { + return boolval($item); + } + + // Decrypt the admin password + if ('ldap_pword' === $key && !empty($item)) { + try { + return decrypt($item); + } 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 ($item && 'ldap_server' === $key) { + return collect(parse_url($item)); + } + + return $item; + }); + } + return $ldapSettings; + } + + /** + * Set the server certificate environment variable. + * + * @author Wes Hulette + * + * @since 5.0.0 + */ + private function certificateCheck(): void + { + // If we are ignoring the SSL cert we need to setup the environment variable + // before we create the connection + if ($this->ldapSettings['ldap_server_cert_ignore']) { + 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')); + } + } + + /** + * Set the Adlap2 connection configuration values based on SnipeIT settings. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return array + */ + private function setLdapConnectionConfiguration(): array + { + // Create the configuration array. + return [ + // Mandatory Configuration Options + 'hosts' => $this->getServerUrlBase(), + 'base_dn' => $this->ldapSettings['ldap_basedn'], + 'username' => $this->ldapSettings['ldap_uname'], + 'password' => $this->ldapSettings['ldap_pword'], + + // Optional Configuration Options + 'schema' => $this->getSchema(), // FIXME - we probably ought not to be using this, right? + 'account_prefix' => '', + 'account_suffix' => '', + 'port' => $this->getPort(), + 'follow_referrals' => false, + 'use_ssl' => $this->isSsl(), + 'use_tls' => $this->ldapSettings['ldap_tls'], + 'version' => $this->ldapSettings['ldap_version'] ?? self::DEFAULT_LDAP_VERSION, + 'timeout' => self::CONNECTION_TIMEOUT, + + // Custom LDAP Options + 'custom_options' => [ + // See: http://php.net/ldap_set_option + // LDAP_OPT_X_TLS_REQUIRE_CERT => LDAP_OPT_X_TLS_HARD, + ], + ]; + } + + /** + * Get the schema to use for the connection. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return string + */ + private function getSchema(): string //wait, what? This is a little weird, since we have completely separate variables for this; we probably shoulnd't be using any 'schema' at all + { + $schema = \Adldap\Schemas\OpenLDAP::class; + if ($this->ldapSettings['is_ad']) { + $schema = \Adldap\Schemas\ActiveDirectory::class; + } + + return $schema; + } + + /** + * Get the port number from the connection url. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return int + */ + private function getPort(): int + { + $port = $this->getLdapServerData('port'); + if ($port && is_int($port)) { + return $port; + } + return self::LDAP_PORT; + } + + /** + * Get ldap scheme from url to determin ssl use. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return bool + */ + private function isSsl(): bool + { + $scheme = $this->getLdapServerData('scheme'); + if ($scheme && 'ldaps' === strtolower($scheme)) { + return true; + } + return false; + } + + /** + * Return the base url to the LDAP server. + * + * @author Wes Hulette + * + * @since 5.0.0 + * + * @return array + */ + private function getServerUrlBase(): array + { + /* if ($this->ldapSettings['is_ad']) { + return collect(explode(',', $this->ldapSettings['ad_domain']))->map(function ($item) { + return trim($item); + })->toArray(); + } */ // <- this was the *original* intent of the PR for AdLdap2, but we've been moving away from having + // two separate fields - one for "ldap_host" and one for "ad_domain" - towards just using "ldap_host" + // ad_domain for us just means "append this domain to your usernames for login, if you click that checkbox" + // that's all, nothing more (I hope). + + $url = $this->getLdapServerData('host'); + return $url ? [$url] : []; + } + + /** + * Get ldap enabled setting + * + * @author Steffen Buehl + * + * @since 5.0.0 + * + * @return bool + */ + public function isLdapEnabled(): bool + { + return $this->ldapSettings && $this->ldapSettings->get('ldap_enabled'); + } + + /** + * Get parsed ldap server information + * + * @author Steffen Buehl + * + * @since 5.0.0 + * + * @param $key + * @return mixed|null + */ + protected function getLdapServerData($key) + { + if ($this->ldapSettings) { + $ldapServer = $this->ldapSettings->get('ldap_server'); + if ($ldapServer && $ldapServer instanceof Collection) { + return $ldapServer->get($key); + } + } + + return null; + } +}