2020-11-30 17:11:44 -08:00
< ? php
2021-06-10 13:15:52 -07:00
2020-11-30 17:11:44 -08:00
namespace App\Models ;
use App\Models\Setting ;
2021-06-10 13:15:52 -07:00
use App\Models\User ;
2020-11-30 17:11:44 -08:00
use Exception ;
2021-06-10 13:15:52 -07:00
use Illuminate\Database\Eloquent\Model ;
2024-05-29 04:38:15 -07:00
use Illuminate\Support\Facades\Log ;
2024-05-29 04:53:51 -07:00
use Illuminate\Support\Facades\Crypt ;
2020-11-30 17:11:44 -08:00
2021-11-08 17:11:47 -08:00
/***********************************************
* TODOS :
2021-11-10 11:37:10 -08:00
*
* First off , we should probably make it so that the main LDAP thing we ' re using is an * instance * of this class ,
2021-11-08 17:11:47 -08:00
* rather than the static methods we use here . We should probably load up that class with its settings , so we
* don ' t have to explicitly refer to them so often .
2021-11-10 11:37:10 -08:00
*
2021-11-08 17:11:47 -08:00
* Then , we should probably look at embedding some of the logic we use elsewhere into here - the various methods
* should either return a User or false , or other things like that . Don ' t make the consumers of this class reach
* into its guts . While that conflates this model with the User model , I think having the appropriate logic for
* turning LDAP people into Users ought to belong here , so it ' s easier on the consumer of this class .
2021-11-10 11:37:10 -08:00
*
2021-11-08 17:11:47 -08:00
* We ' re probably going to have to eventually make it so that Snipe - IT users can define multiple LDAP servers ,
* and having this as a more instance - oriented class will be a step in the right direction .
***********************************************/
2020-11-30 17:11:44 -08:00
class Ldap extends Model
{
/**
* Makes a connection to LDAP using the settings in Admin > Settings .
*
* @ author [ A . Gianotto ] [ < snipe @ snipe . net > ]
* @ since [ v3 . 0 ]
* @ return connection
*/
public static function connectToLdap ()
{
2021-06-10 13:15:52 -07:00
$ldap_host = Setting :: getSettings () -> ldap_server ;
2022-06-06 20:57:18 -07:00
$ldap_version = Setting :: getSettings () -> ldap_version ? : 3 ;
2020-11-30 17:11:44 -08:00
$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
2021-06-10 13:15:52 -07:00
if ( $ldap_server_cert_ignore == '1' ) {
2020-11-30 17:11:44 -08:00
putenv ( 'LDAPTLS_REQCERT=never' );
}
// If the user specifies where CA Certs are, make sure to use them
2021-06-10 13:15:52 -07:00
if ( env ( 'LDAPTLS_CACERT' )) {
putenv ( 'LDAPTLS_CACERT=' . env ( 'LDAPTLS_CACERT' ));
2020-11-30 17:11:44 -08:00
}
$connection = @ ldap_connect ( $ldap_host );
2021-06-10 13:15:52 -07:00
if ( ! $connection ) {
2020-11-30 17:11:44 -08:00
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 );
2021-08-17 14:43:36 -07:00
if ( Setting :: getSettings () -> ldap_client_tls_cert && Setting :: getSettings () -> ldap_client_tls_key ) {
2022-05-23 20:31:43 -07:00
ldap_set_option ( null , LDAP_OPT_X_TLS_CERTFILE , Setting :: get_client_side_cert_path ());
ldap_set_option ( null , LDAP_OPT_X_TLS_KEYFILE , Setting :: get_client_side_key_path ());
2021-08-17 14:43:36 -07:00
}
2020-11-30 17:11:44 -08:00
if ( $ldap_use_tls == '1' ) {
ldap_start_tls ( $connection );
}
2021-08-17 14:43:36 -07:00
2020-11-30 17:11:44 -08:00
return $connection ;
}
/**
* Binds / authenticates the user to LDAP , and returns their attributes .
*
* @ author [ A . Gianotto ] [ < snipe @ snipe . net > ]
* @ 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
*/
2021-06-10 13:15:52 -07:00
public static function findAndBindUserLdap ( $username , $password )
2020-11-30 17:11:44 -08:00
{
$settings = Setting :: getSettings ();
2021-06-10 13:15:52 -07:00
$connection = self :: connectToLdap ();
$ldap_username_field = $settings -> ldap_username_field ;
$baseDn = $settings -> ldap_basedn ;
$userDn = $ldap_username_field . '=' . $username . ',' . $settings -> ldap_basedn ;
2020-11-30 17:11:44 -08:00
2021-06-10 13:15:52 -07:00
if ( $settings -> is_ad == '1' ) {
2020-11-30 17:11:44 -08:00
// Check if they are using the userprincipalname for the username field.
// If they are, we can skip building the UPN to authenticate against AD
2021-06-10 13:15:52 -07:00
if ( $ldap_username_field == 'userprincipalname' ) {
2020-11-30 17:11:44 -08:00
$userDn = $username ;
2021-11-08 17:11:47 -08:00
} else {
// TODO - we no longer respect the "add AD Domain to username" checkbox, but it still exists in settings.
// We should probably just eliminate that checkbox to avoid confusion.
// We let it sit in the DB, unused, to facilitate people downgrading (if they decide to).
// Hopefully, in a later release, we can remove it from the settings.
// This logic instead just means that if we're using UPN, we don't append ad_domain, if we aren't, then we do.
// Hopefully that should handle all of our use cases, but if not we can backport our old logic.
2021-06-10 13:15:52 -07:00
$userDn = ( $settings -> ad_domain != '' ) ? $username . '@' . $settings -> ad_domain : $username . '@' . $settings -> email_domain ;
2020-11-30 17:11:44 -08:00
}
}
2021-06-10 13:15:52 -07:00
$filterQuery = $settings -> ldap_auth_filter_query . $username ;
2021-11-08 17:11:47 -08:00
$filter = Setting :: getSettings () -> ldap_filter ; //FIXME - this *does* respect the ldap filter, but I believe that AdLdap2 did *not*.
2021-04-20 21:25:45 -07:00
$filterQuery = " ( { $filter } ( { $filterQuery } )) " ;
2024-05-29 04:38:15 -07:00
Log :: debug ( 'Filter query: ' . $filterQuery );
2020-11-30 17:11:44 -08:00
2021-06-10 13:15:52 -07:00
if ( ! $ldapbind = @ ldap_bind ( $connection , $userDn , $password )) {
2024-05-29 04:38:15 -07:00
Log :: debug ( " Status of binding user: $userDn to directory: (directly!) " . ( $ldapbind ? " success " : " FAILURE " ));
2021-11-08 17:11:47 -08:00
if ( ! $ldapbind = self :: bindAdminToLdap ( $connection )) {
2021-11-10 11:37:10 -08:00
/*
* TODO PLEASE :
*
2021-11-08 17:11:47 -08:00
* this isn 't very clear, so it' s important to note : the $ldapbind value is never correctly returned - we never 'return true' from self :: bindAdminToLdap () ( the function
* just " falls off the end " without ever explictly returning 'true' )
2021-11-10 11:37:10 -08:00
*
2021-11-08 17:11:47 -08:00
* but it * does * have an interesting side - effect of checking for the LDAP password being incorrectly encrypted with the wrong APP_KEY , so I ' m leaving it in for now .
2021-11-10 11:37:10 -08:00
*
2021-11-08 17:11:47 -08:00
* If it * did * correctly return 'true' on a succesful bind , it would _probably_ allow users to log in with an incorrect password . Which would be horrible !
2021-11-10 11:37:10 -08:00
*
2021-11-08 17:11:47 -08:00
* Let ' s definitely fix this at the next refactor !!!!
2021-11-10 11:37:10 -08:00
*
2021-11-08 17:11:47 -08:00
*/
2024-05-29 04:38:15 -07:00
Log :: debug ( " Status of binding Admin user: $userDn to directory instead: " . ( $ldapbind ? " success " : " FAILURE " ));
2021-11-08 17:11:47 -08:00
return false ;
2020-11-30 17:11:44 -08:00
}
}
2021-06-10 13:15:52 -07:00
if ( ! $results = ldap_search ( $connection , $baseDn , $filterQuery )) {
2020-11-30 17:11:44 -08:00
throw new Exception ( 'Could not search LDAP: ' );
}
2021-06-10 13:15:52 -07:00
if ( ! $entry = ldap_first_entry ( $connection , $results )) {
2020-11-30 17:11:44 -08:00
return false ;
}
2021-06-10 13:15:52 -07:00
if ( ! $user = ldap_get_attributes ( $connection , $entry )) {
2020-11-30 17:11:44 -08:00
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 ] [ < snipe @ snipe . net > ]
* @ 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
*/
2021-06-10 13:15:52 -07:00
public static function bindAdminToLdap ( $connection )
2020-11-30 17:11:44 -08:00
{
2021-06-10 13:15:52 -07:00
$ldap_username = Setting :: getSettings () -> ldap_uname ;
2020-11-30 17:11:44 -08:00
2022-07-15 00:20:55 -07:00
if ( $ldap_username ) {
2022-08-02 05:24:00 -07:00
// Lets return some nicer messages for users who donked their app key, and disable LDAP
try {
2024-05-29 04:38:15 -07:00
$ldap_pass = Crypt :: decrypt ( Setting :: getSettings () -> ldap_pword );
2022-08-02 05:24:00 -07:00
} 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.' );
}
2022-07-15 00:20:55 -07:00
if ( ! $ldapbind = @ ldap_bind ( $connection , $ldap_username , $ldap_pass )) {
throw new Exception ( 'Could not bind to LDAP: ' . ldap_error ( $connection ));
}
// TODO - this just "falls off the end" but the function states that it should return true or false
// unfortunately, one of the use cases for this function is wrong and *needs* for that failure mode to fire
// so I don't want to fix this right now.
// this method MODIFIES STATE on the passed-in $connection and just returns true or false (or, in this case, undefined)
// at the next refactor, this should be appropriately modified to be more consistent.
} else {
// LDAP should also work with anonymous bind (no dn, no password available)
if ( ! $ldapbind = @ ldap_bind ( $connection )) {
throw new Exception ( 'Could not bind to LDAP: ' . ldap_error ( $connection ));
}
}
}
2020-11-30 17:11:44 -08:00
/**
* Parse and map LDAP attributes based on settings
*
* @ author [ A . Gianotto ] [ < snipe @ snipe . net > ]
* @ since [ v3 . 0 ]
*
* @ param $ldapatttibutes
* @ return array | bool
*/
2021-06-10 13:15:52 -07:00
public static function parseAndMapLdapAttributes ( $ldapattributes )
2020-11-30 17:11:44 -08:00
{
//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 ;
2021-04-05 19:26:04 -07:00
$ldap_result_phone = Setting :: getSettings () -> ldap_phone ;
$ldap_result_jobtitle = Setting :: getSettings () -> ldap_jobtitle ;
2021-06-10 13:15:52 -07:00
$ldap_result_country = Setting :: getSettings () -> ldap_country ;
2023-04-25 11:49:33 -07:00
$ldap_result_location = Setting :: getSettings () -> ldap_location ;
2021-04-14 10:17:57 -07:00
$ldap_result_dept = Setting :: getSettings () -> ldap_dept ;
2022-03-21 11:15:39 -07:00
$ldap_result_manager = Setting :: getSettings () -> ldap_manager ;
2020-11-30 17:11:44 -08:00
// Get LDAP user data
2021-06-10 13:15:52 -07:00
$item = [];
2023-02-06 12:43:00 -08:00
$item [ 'username' ] = $ldapattributes [ $ldap_result_username ][ 0 ] ? ? '' ;
$item [ 'employee_number' ] = $ldapattributes [ $ldap_result_emp_num ][ 0 ] ? ? '' ;
$item [ 'lastname' ] = $ldapattributes [ $ldap_result_last_name ][ 0 ] ? ? '' ;
$item [ 'firstname' ] = $ldapattributes [ $ldap_result_first_name ][ 0 ] ? ? '' ;
$item [ 'email' ] = $ldapattributes [ $ldap_result_email ][ 0 ] ? ? '' ;
$item [ 'telephone' ] = $ldapattributes [ $ldap_result_phone ][ 0 ] ? ? '' ;
$item [ 'jobtitle' ] = $ldapattributes [ $ldap_result_jobtitle ][ 0 ] ? ? '' ;
$item [ 'country' ] = $ldapattributes [ $ldap_result_country ][ 0 ] ? ? '' ;
$item [ 'department' ] = $ldapattributes [ $ldap_result_dept ][ 0 ] ? ? '' ;
$item [ 'manager' ] = $ldapattributes [ $ldap_result_manager ][ 0 ] ? ? '' ;
2023-04-25 11:44:04 -07:00
$item [ 'location' ] = $ldapattributes [ $ldap_result_location ][ 0 ] ? ? '' ;
2024-07-13 07:15:44 -07:00
$item [ 'locale' ] = app () -> getLocale ();
2020-11-30 17:11:44 -08:00
2021-06-10 13:15:52 -07:00
return $item ;
2020-11-30 17:11:44 -08:00
}
/**
* Create user from LDAP attributes
*
* @ author [ A . Gianotto ] [ < snipe @ snipe . net > ]
* @ since [ v3 . 0 ]
* @ param $ldapatttibutes
2024-07-13 07:15:44 -07:00
* @ return User | bool
2020-11-30 17:11:44 -08:00
*/
2022-05-16 10:58:27 -07:00
public static function createUserFromLdap ( $ldapatttibutes , $password )
2020-11-30 17:11:44 -08:00
{
2021-06-10 13:15:52 -07:00
$item = self :: parseAndMapLdapAttributes ( $ldapatttibutes );
2020-11-30 17:11:44 -08:00
// Create user from LDAP data
2021-06-10 13:15:52 -07:00
if ( ! empty ( $item [ 'username' ])) {
2020-11-30 17:11:44 -08:00
$user = new User ;
2021-06-10 13:15:52 -07:00
$user -> first_name = $item [ 'firstname' ];
$user -> last_name = $item [ 'lastname' ];
$user -> username = $item [ 'username' ];
$user -> email = $item [ 'email' ];
2024-07-13 07:15:44 -07:00
$user -> locale = $item [ 'locale' ];
2023-08-28 12:47:56 -07:00
$user -> password = $user -> noPassword ();
2020-11-30 17:11:44 -08:00
2021-06-10 13:15:52 -07:00
if ( Setting :: getSettings () -> ldap_pw_sync == '1' ) {
2022-05-16 10:58:27 -07:00
$user -> password = bcrypt ( $password );
2020-11-30 17:11:44 -08:00
}
$user -> activated = 1 ;
$user -> ldap_import = 1 ;
$user -> notes = 'Imported on first login from LDAP' ;
if ( $user -> save ()) {
return $user ;
} else {
2024-05-29 04:38:15 -07:00
Log :: debug ( 'Could not create user.' . $user -> getErrors ());
2021-06-10 13:15:52 -07:00
throw new Exception ( 'Could not create user: ' . $user -> getErrors ());
2020-11-30 17:11:44 -08:00
}
}
return false ;
}
/**
* Searches LDAP
*
* @ author [ A . Gianotto ] [ < snipe @ snipe . net > ]
* @ since [ v3 . 0 ]
* @ param $base_dn
2021-11-03 15:22:06 -07:00
* @ param $count
2022-06-27 19:49:59 -07:00
* @ param $filter
2024-09-13 08:05:48 -07:00
* @ param $attributes
2020-11-30 17:11:44 -08:00
* @ return array | bool
*/
2024-09-13 08:05:48 -07:00
public static function findLdapUsers ( $base_dn = null , $count = - 1 , $filter = null , $attributes = [])
2020-11-30 17:11:44 -08:00
{
2021-06-10 13:15:52 -07:00
$ldapconn = self :: connectToLdap ();
2021-11-08 17:11:47 -08:00
self :: bindAdminToLdap ( $ldapconn );
2020-11-30 17:11:44 -08:00
// Default to global base DN if nothing else is provided.
if ( is_null ( $base_dn )) {
$base_dn = Setting :: getSettings () -> ldap_basedn ;
}
2022-06-27 19:49:59 -07:00
if ( $filter === null ) {
$filter = Setting :: getSettings () -> ldap_filter ;
}
2020-11-30 17:11:44 -08:00
// Set up LDAP pagination for very large databases
$page_size = 500 ;
$cookie = '' ;
2021-06-10 13:15:52 -07:00
$result_set = [];
2020-11-30 17:11:44 -08:00
$global_count = 0 ;
// Perform the search
do {
if ( $filter != '' && substr ( $filter , 0 , 1 ) != '(' ) { // wrap parens around NON-EMPTY filters that DON'T have them, for back-compatibility with AdLdap2-based filters
$filter = " ( $filter ) " ;
2021-04-14 11:17:59 -07:00
} elseif ( $filter == '' ) {
2021-06-10 13:15:52 -07:00
$filter = '(cn=*)' ;
2020-11-30 17:11:44 -08:00
}
2021-04-14 11:17:59 -07:00
2021-11-03 15:22:06 -07:00
// HUGE thanks to this article: https://stackoverflow.com/questions/68275972/how-to-get-paged-ldap-queries-in-php-8-and-read-more-than-1000-entries
// which helped me wrap my head around paged results!
// if a $count is set and it's smaller than $page_size then use that as the page size
$ldap_controls = [];
2021-11-10 11:37:10 -08:00
//if($count == -1) { //count is -1 means we have to employ paging to query the entire directory
$ldap_controls = [[ 'oid' => LDAP_CONTROL_PAGEDRESULTS , 'iscritical' => false , 'value' => [ 'size' => $count == - 1 || $count > $page_size ? $page_size : $count , 'cookie' => $cookie ]]];
//}
2024-09-13 08:05:48 -07:00
$search_results = ldap_search ( $ldapconn , $base_dn , $filter , $attributes , 0 , /* $page_size */ - 1 , - 1 , LDAP_DEREF_NEVER , $ldap_controls ); // TODO - I hate the @, and I hate that we get a full page even if we ask for 10 records. Can we use an ldap_control?
2024-05-29 04:38:15 -07:00
Log :: debug ( " LDAP search executed successfully. " );
2021-06-10 13:15:52 -07:00
if ( ! $search_results ) {
2021-11-10 11:37:10 -08:00
return redirect () -> route ( 'users.index' ) -> with ( 'error' , trans ( 'admin/users/message.error.ldap_could_not_search' ) . ldap_error ( $ldapconn )); // TODO this is never called in any routed context - only from the Artisan command. So this redirect will never work.
2020-11-30 17:11:44 -08:00
}
2021-11-03 15:22:06 -07:00
$errcode = null ;
$matcheddn = null ;
$errmsg = null ;
$referrals = null ;
$controls = [];
ldap_parse_result ( $ldapconn , $search_results , $errcode , $matcheddn , $errmsg , $referrals , $controls );
if ( isset ( $controls [ LDAP_CONTROL_PAGEDRESULTS ][ 'value' ][ 'cookie' ])) {
// You need to pass the cookie from the last call to the next one
$cookie = $controls [ LDAP_CONTROL_PAGEDRESULTS ][ 'value' ][ 'cookie' ];
2024-05-29 04:38:15 -07:00
Log :: debug ( " okay, at least one more page to go!!! " );
2021-11-03 15:22:06 -07:00
} else {
2024-05-29 04:38:15 -07:00
Log :: debug ( " okay, we're out of pages - no cookie (or empty cookie) was passed " );
2021-11-03 15:22:06 -07:00
$cookie = '' ;
}
// Empty cookie means last page
2024-09-13 08:05:48 -07:00
2020-11-30 17:11:44 -08:00
// Get results from page
$results = ldap_get_entries ( $ldapconn , $search_results );
2021-06-10 13:15:52 -07:00
if ( ! $results ) {
2021-11-10 11:37:10 -08:00
return redirect () -> route ( 'users.index' ) -> with ( 'error' , trans ( 'admin/users/message.error.ldap_could_not_get_entries' ) . ldap_error ( $ldapconn )); // TODO this is never called in any routed context - only from the Artisan command. So this redirect will never work.
2020-11-30 17:11:44 -08:00
}
// Add results to result set
$global_count += $results [ 'count' ];
$result_set = array_merge ( $result_set , $results );
2024-05-29 04:38:15 -07:00
Log :: debug ( " Total count is: $global_count " );
2020-11-30 17:11:44 -08:00
2021-11-10 11:37:10 -08:00
} while ( $cookie !== null && $cookie != '' && ( $count == - 1 || $global_count < $count )); // some servers don't even have pagination, and some will give you more results than you asked for, so just see if you have enough.
2020-11-30 17:11:44 -08:00
// Clean up after search
2021-11-10 11:37:10 -08:00
$result_set [ 'count' ] = $global_count ; // TODO: I would've figured you could just count the array instead?
2020-11-30 17:11:44 -08:00
$results = $result_set ;
return $results ;
}
}