2018-12-06 14:05:43 -08:00
< ? php
declare ( strict_types = 1 );
2019-01-10 13:20:43 -08:00
namespace App\Services ;
2018-12-06 14:05:43 -08:00
2019-03-13 20:12:03 -07:00
use Adldap\Adldap ;
use Adldap\Models\User as AdldapUser ;
use Adldap\Query\Paginator ;
2019-01-24 16:15:44 -08:00
use Adldap\Schemas\Schema ;
2019-01-15 14:05:47 -08:00
use App\Helpers\Helper ;
2019-03-13 20:12:03 -07:00
use App\Models\User ;
2018-12-06 14:05:43 -08:00
use Exception ;
use Illuminate\Support\Collection ;
use Illuminate\Support\Facades\Log ;
/**
* LDAP queries .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ since 5.0 . 0
*/
class LdapAd extends LdapAdConfiguration
{
2020-11-05 15:31:33 -08:00
/* 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
2020-11-17 19:25:49 -08:00
'1114624' , // 0x110200 NORMAL_ACCOUNT, NOT_DELEGATED, DONT_EXPIRE_PASSWORD
2020-11-05 15:31:33 -08:00
];
2018-12-06 14:05:43 -08:00
/**
* The LDAP results per page .
*/
const PAGE_SIZE = 500 ;
/**
* A base dn .
*
* @ var string
*/
public $baseDn = null ;
/**
* Adldap instance .
*
* @ var \Adldap\Adldap
*/
protected $ldap ;
/**
2019-01-10 13:20:43 -08:00
* Initialize LDAP from user settings
*
* @ since 5.0 . 0
*
2020-08-14 14:45:05 -07:00
* @ return void
2018-12-06 14:05:43 -08:00
*/
2020-08-14 14:45:05 -07:00
public function init ()
2018-12-06 14:05:43 -08:00
{
2019-01-10 13:20:43 -08:00
// Already initialized
2020-09-08 21:13:24 -07:00
if ( $this -> ldap ) {
2019-01-10 13:20:43 -08:00
return true ;
}
parent :: init ();
2018-12-11 21:01:11 -08:00
if ( $this -> isLdapEnabled ()) {
2020-10-09 15:33:17 -07:00
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 .
*/
2018-12-11 21:01:11 -08:00
$this -> ldap = new Adldap ();
$this -> ldap -> addProvider ( $this -> ldapConfig );
2019-01-10 13:20:43 -08:00
return true ;
2018-12-11 21:01:11 -08:00
}
2019-01-10 13:20:43 -08:00
return false ;
2018-12-06 14:05:43 -08:00
}
2020-08-14 14:45:05 -07:00
public function __construct () {
$this -> init ();
}
2019-01-10 13:20:43 -08:00
/**
2018-12-06 14:05:43 -08:00
* Create a user if they successfully login to the LDAP server .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ since 5.0 . 0
*
* @ param string $username
* @ param string $password
*
* @ return \App\Models\User
*
* @ throws Exception
*/
public function ldapLogin ( string $username , string $password ) : User
{
2020-10-09 15:33:17 -07:00
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 ;
2020-02-04 12:47:49 -08:00
}
2020-10-09 15:33:17 -07:00
if ( $this -> ldap -> auth () -> attempt ( $login_username , $password , true ) === false ) {
2018-12-06 14:05:43 -08:00
throw new Exception ( 'Unable to validate user credentials!' );
2020-03-24 14:27:14 -07:00
}
2018-12-06 14:05:43 -08:00
// Should we sync the logged in user
2019-01-24 16:15:44 -08:00
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 );
2018-12-06 14:05:43 -08:00
}
2019-01-24 16:15:44 -08:00
}
else {
2019-01-15 14:05:47 -08:00
throw new Exception ( 'Unable to find user in LDAP directory!' );
2018-12-06 14:05:43 -08:00
}
2020-11-17 22:31:22 -08:00
$user = User :: where ( 'username' , $username )
2018-12-06 14:05:43 -08:00
-> whereNull ( 'deleted_at' ) -> where ( 'ldap_import' , '=' , 1 )
-> where ( 'activated' , '=' , '1' ) -> first ();
2020-11-17 22:31:22 -08:00
/* 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 ;
2018-12-06 14:05:43 -08:00
}
/**
* Set the user information based on the LDAP settings .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ 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
{
2020-11-17 00:11:00 -08:00
// Only sync active users <- I think this actually means 'existing', not 'activated/deactivated'
2019-01-15 14:05:47 -08:00
if ( ! $user ) {
return null ;
2018-12-06 14:05:43 -08:00
}
2019-01-15 14:05:47 -08:00
$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 ] ? ? '' ;
2019-01-24 16:15:44 -08:00
$snipeUser [ 'title' ] = $user -> getTitle () ? ? '' ;
$snipeUser [ 'telephonenumber' ] = $user -> getTelephoneNumber () ? ? '' ;
2020-11-17 00:11:00 -08:00
/*
* $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 ;
}
2019-01-15 14:05:47 -08:00
return $this -> setUserModel ( $snipeUser );
2018-12-06 14:05:43 -08:00
}
/**
* Set the User model information .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ 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' ]);
2019-01-15 14:05:47 -08:00
$user -> password = $user -> password ? ? Helper :: generateEncyrptedPassword ();
2018-12-06 14:05:43 -08:00
$user -> first_name = trim ( $userInfo [ 'firstname' ]);
$user -> last_name = trim ( $userInfo [ 'lastname' ]);
$user -> email = trim ( $userInfo [ 'email' ]);
$user -> employee_num = trim ( $userInfo [ 'employee_number' ]);
2019-01-24 16:15:44 -08:00
$user -> jobtitle = trim ( $userInfo [ 'title' ]);
$user -> phone = trim ( $userInfo [ 'telephonenumber' ]);
2020-11-17 00:11:00 -08:00
if ( array_key_exists ( 'activated' , $userInfo )) {
$user -> activated = $userInfo [ 'activated' ];
}
if ( array_key_exists ( 'location_id' , $userInfo )) {
$user -> location_id = $userInfo [ 'location_id' ];
}
2018-12-06 14:05:43 -08:00
$user -> notes = 'Imported from LDAP' ;
$user -> ldap_import = 1 ;
return $user ;
}
/**
* Sync a user who has logged in by LDAP .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ 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 < jwhulette @ gmail . com >
*
* @ since 5.0 . 0
*
* @ param \Adldap\Models\User $user
*
* @ return bool
*/
private function isLdapSync ( AdldapUser $user ) : bool
{
2020-10-15 19:06:02 -07:00
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 ;
2018-12-06 14:05:43 -08:00
}
/**
* Set the active status of the user .
2020-11-17 00:11:00 -08:00
* Returns 0 or 1 if the user is deactivated or activated
* or returns null if we just don ' t know
2018-12-06 14:05:43 -08:00
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ since 5.0 . 0
*
* @ param \Adldap\Models\User $user
*
2020-11-17 00:11:00 -08:00
* @ return int ( or null )
2018-12-06 14:05:43 -08:00
*/
2020-11-17 00:11:00 -08:00
private function getActiveStatus ( AdldapUser $user ) : ? int
2018-12-06 14:05:43 -08:00
{
/*
* Check to see if we are connected to an AD server
* if so , check the Active Directory User Account Control Flags
2020-11-17 00:11:00 -08:00
* 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 )
2018-12-06 14:05:43 -08:00
*/
2020-11-17 00:11:00 -08:00
if ( $user -> hasAttribute ( $user -> getSchema () -> userAccountControl ()) && ! $this -> ldapSettings [ 'ldap_active_flag' ]) {
2020-11-12 22:19:16 -08:00
\Log :: debug ( 'This is AD - userAccountControl is' . $user -> getSchema () -> userAccountControl ());
2018-12-06 14:05:43 -08:00
$activeStatus = ( in_array ( $user -> getUserAccountControl (), self :: AD_USER_ACCOUNT_CONTROL_FLAGS )) ? 1 : 0 ;
} else {
2020-11-12 22:19:16 -08:00
2020-11-17 00:11:00 -08:00
\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
2019-01-24 16:15:44 -08:00
if ( false == $this -> ldapSettings [ 'ldap_active_flag' ]) {
2020-11-12 22:19:16 -08:00
\Log :: debug ( 'ldap_active_flag is false - no ldap_active_flag is set' );
2020-11-17 00:11:00 -08:00
return null ;
2018-12-06 14:05:43 -08:00
}
2020-11-12 22:19:16 -08:00
2020-11-17 00:11:00 -08:00
// If there *is* an activated flag, then respect it *only* if it is actually present. If it's not there, ignore it. <-- NOT SURE IF RIGHT?
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.
}
2020-11-12 22:19:16 -08:00
2020-11-17 00:11:00 -08:00
$activeStatus = $user -> { $this -> ldapSettings [ 'ldap_active_flag' ]} ? 1 : 0 ;
2018-12-06 14:05:43 -08:00
}
return $activeStatus ;
}
/**
* Get a default selected location , or a OU mapped location if available .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ since 5.0 . 0
*
2019-01-10 13:20:43 -08:00
* @ param \Adldap\Models\User $user
2018-12-06 14:05:43 -08:00
* @ 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 ) {
2020-10-06 18:31:06 -07:00
//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
2018-12-06 14:05:43 -08:00
}
});
if ( $location -> count () > 0 ) {
2020-10-06 18:31:06 -07:00
$locationId = $location -> keys () -> first (); // from the returned $location array from the ->filter() method above, we return the first match - there should be only one
2018-12-06 14:05:43 -08:00
}
}
return $locationId ;
}
/**
* Get the base dn for the query .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ 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 < jwhulette @ gmail . com >
*
* @ since 5.0 . 0
*
* @ return null | string
*/
private function getFilter () : ? string
{
$filter = $this -> ldapSettings [ 'ldap_filter' ];
2019-05-23 19:59:27 -07:00
if ( ! $filter ) {
2018-12-06 14:05:43 -08:00
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 < jwhulette @ gmail . com >
*
* @ since 5.0 . 0
*
* @ return array
*/
private function getSelectedFields () : array
{
2019-01-24 16:15:44 -08:00
/** @var Schema $schema */
$schema = new $this -> ldapConfig [ 'schema' ];
2020-11-12 09:34:28 -08:00
return array_values ( array_filter ([
2018-12-06 14:05:43 -08:00
$this -> ldapSettings [ 'ldap_username_field' ],
$this -> ldapSettings [ 'ldap_fname_field' ],
$this -> ldapSettings [ 'ldap_lname_field' ],
$this -> ldapSettings [ 'ldap_email' ],
$this -> ldapSettings [ 'ldap_emp_num' ],
2019-01-15 14:05:47 -08:00
$this -> ldapSettings [ 'ldap_active_flag' ],
2019-01-24 16:15:44 -08:00
$schema -> memberOf (),
$schema -> userAccountControl (),
$schema -> title (),
$schema -> telephone (),
2020-11-12 09:34:28 -08:00
]));
2018-12-06 14:05:43 -08:00
}
/**
* Test the bind user connection .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
2020-04-23 14:41:37 -07:00
* @ throws \Exception
2018-12-06 14:05:43 -08:00
* @ since 5.0 . 0
*/
public function testLdapAdBindConnection () : void
{
try {
2020-08-14 14:45:05 -07:00
$this -> ldap -> search () -> ous () -> get () -> count (); //it's saying this is null?
2018-12-06 14:05:43 -08:00
} 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 < jwhulette @ gmail . com >
2020-04-23 14:41:37 -07:00
* @ throws \Exception
2018-12-06 14:05:43 -08:00
* @ since 5.0 . 0
*/
public function testLdapAdUserConnection () : void
{
try {
2020-11-16 21:23:08 -08:00
$this -> ldap -> connect ();
2018-12-06 14:05:43 -08:00
} catch ( \Adldap\Auth\BindException $e ) {
Log :: error ( $e );
throw new Exception ( 'Unable to connect to LDAP directory!' );
}
}
/**
* Test the LDAP configuration by returning up to 10 users .
*
* @ author Wes Hulette < jwhulette @ gmail . com >
*
* @ 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 < jwhulette @ gmail . com >
*
* @ since 5.0 . 0
*
* @ return \Adldap\Query\Paginator
*/
2019-06-14 09:54:09 -07:00
public function getLdapUsers () : Paginator
2018-12-06 14:05:43 -08:00
{
2020-08-14 14:45:05 -07:00
$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?
2018-12-06 14:05:43 -08:00
$filter = $this -> getFilter ();
if ( ! is_null ( $filter )) {
$search = $search -> rawFilter ( $filter );
}
return $search -> select ( $this -> getSelectedFields ())
2019-06-14 09:54:09 -07:00
-> paginate ( self :: PAGE_SIZE );
2018-12-06 14:05:43 -08:00
}
}