Closed gregupton closed 4 years ago
Hi @gregupton! Thanks so much for the sponsorship! β€οΈ
I'll absolutely walk you through the process.
First, create your own User
LDAP model that extends the built-in LdapRecord Active Directory user.
You can do this by running the below command:
php artisan make:ldap-model User
Then, update the generated model (inside the app\Ldap\User.php
file) and make it extend the LdapRecord Active Directory User
model:
namespace App\Ldap;
use LdapRecord\Models\ActiveDirectory\User as BaseModel;
class User extends BaseModel
{
//
}
Now we will create the scope that will be applied to the model.
Run the below command to create it:
php artisan make:ldap-scope ImportFilter
Then, in the generated scope (app/Ldap/Scopes/ImportFilter.php
), this is where you will limit the users who can authenticate and be imported into your application.
Here is an example scope:
// ...
use LdapRecord\Models\ActiveDirectory\Group;
class ImportFilter implements Scope
{
public function apply(Builder $query, Model $model)
{
$group = Group::findByAnrOrFail('My group name');
$query->whereMemberOf($group);
}
}
Note: I use the
Group::findByAnrOrFail()
method to generate an exception if the group cannot be found in the directory. This guarantees that the scope is applied and group membership is properly queried for.
Then, inside of your created app/Ldap/User.php
model, override the static boot()
method, and add the global scope to the model:
namespace App\Ldap;
use App\Ldap\Scopes\ImportFilter;
use LdapRecord\Models\ActiveDirectory\User as BaseModel;
class User extends BaseModel
{
protected static function boot()
{
parent::boot();
static:addGlobalScope(new ImportFilter);
}
}
After that is done, update your authentication provider inside your config/auth.php
file with your new LDAP model:
// ...
// config/auth.php
'providers' => [
// ...
'ldap' => [
// ...
'model' => \App\Ldap\User::class,
],
],
Now when you run ldap:import
command, only members of the AD group you have specified will be imported. In addition, users attempting to sign into your application that have not yet been imported will have the same scope applied, guaranteeing that your application only contains members of the group you have specified.
Thanks for the fast reply! I must be missing something... when I run the CLI I get "Found [] User(s)."
namespace App\Ldap\Scopes;
use LdapRecord\Models\Model;
use LdapRecord\Models\Scope;
use LdapRecord\Query\Model\Builder;
use LdapRecord\Models\ActiveDirectory\Group;
class ImportFilter implements Scope
{
/**
* Apply the scope to the given query.
*
* @param Builder $query
* @param Model $model
*
* @return void
*/
public function apply(Builder $query, Model $model)
{
{
$group = Group::findByAnrOrFail('cn=intranetusers,ou=groups,dc=contoso,dc=com');
$query->whereMemberOf($group);
}
}
}
My User Model ( I had to comment out my relationship to the my survey model once I removed the Eloquent Model from inheritance.
namespace App;
use App\Ldap\Scopes\ImportFilter;
use Illuminate\Database\Eloquent\SoftDeletes;
use LdapRecord\Models\ActiveDirectory\User as BaseModel;
class User extends BaseModel
{
use SoftDeletes;
/**
*
* Global Scope Filter
*
*/
public static function boot()
{
static::boot();
static::addGlobalScope(new ImportFilter);
}
protected $hidden = [
'password',
];
protected $guarded = [];
protected $perPage = 50;
//public function surveys()
//{
// return $this->hasMany('App\Survey');
//}
public function scopeOrderByName($query)
{
$query->orderBy('last_name')->orderBy('first_name');
}
public function scopeFilter($query, array $filters)
{
$query->when($filters['search'] ?? null, function ($query, $search) {
$query->where(function ($query) use ($search) {
$query->where('first_name', 'like', '%'.$search.'%')
->orWhere('last_name', 'like', '%'.$search.'%');
});
})->when($filters['trashed'] ?? null, function ($query, $trashed) {
if ($trashed === 'with') {
$query->withTrashed();
} elseif ($trashed === 'only') {
$query->onlyTrashed();
}
});
}
}
My Auth driver:
'providers' => [
'ldap' => [
'driver' => 'ldap',
'model' => LdapRecord\Models\ActiveDirectory\User::class,
'database' => [
'model' => App\User::class,
'sync_passwords' => true,
'sync_attributes' => [
'name' => 'cn',
'first_name' => 'givenname',
'last_name' => 'sn',
'email' => 'mail',
],
],
],
],
Ah okay this is your issue here:
$group = Group::findByAnrOrFail('cn=intranetusers,ou=groups,dc=contoso,dc=com');
If you have the full distinguished name, use findOrFail
method instead:
$group = Group::findOrFail('cn=intranetusers,ou=groups,dc=contoso,dc=com');
Can you give that a shot?
Oh also, it seems your applying the scope to your Eloquent database model -- not your LDAP model.
This is how your configured provider (inside your config/auth.php
file) should look:
'providers' => [
'ldap' => [
'driver' => 'ldap',
'model' => App\Ldap\User::class, // <-- Your custom LdapRecord model
'database' => [
'model' => App\User::class, // <-- Your Laravel Eloquent database model
'sync_passwords' => true,
'sync_attributes' => [
'name' => 'cn',
'first_name' => 'givenname',
'last_name' => 'sn',
'email' => 'mail',
],
],
],
],
So now that I've made those changes I am getting a memory error... this doesn't happen if I comment out the scope in the ldap user model.
$ php artisan ldap:import ldap
PHP Warning: Module 'ldap' already loaded in Unknown on line 0
PHP Fatal error: Allowed memory size of 2147483648 bytes exhausted (tried to allocate 262144 bytes) in /home/forge/my.contoso.com/app/Ldap/User.php on line 12
PHP Fatal error: Allowed memory size of 2147483648 bytes exhausted (tried to allocate 262144 bytes) in Unknown on line 0
Line 12 is bash static::boot();
π€¦ Dang, sorry my mistake, we're recursively calling the boot()
method, causing it to be called indefinitely and running out of memory. Update your scope to this:
protected static function boot()
{
parent::boot();
static::addGlobalScope(new ImportFilter);
}
I've changed the
boot()
method toprotected
, and usedparent::boot()
instead ofstatic::boot()
.
Let me know if successful!
Now we're getting somewhere... I got about 13 of the 64 users. Logs show:
[2020-06-10 00:12:15] development.ERROR: Importing user [CN=Exchange Online-ApplicationAccount] failed. Declaration of Illuminate\Database\Eloquent\SoftDeletes::restore() should be compatible with LdapRecord\Models\ActiveDirectory\Entry::restore($newParentDn = NULL)
Looks like you've applied the Eloquent SoftDeletes
trait on your App\Ldap\User.php
model -- remove it and you should be good!
Also, if you want to prevent system accounts (such as the Exchange Online-ApplicationAccount
) from being imported into your Laravel application, you can add further LDAP filters to restrict the import even further.
For example, if your users are located inside (nested or otherwise) one of our LDAP Organizational Units, you can use the in()
method to do so:
$group = Group::findByOrFail('cn=intranetusers,ou=groups,dc=contoso,dc=com');
$query
->in('ou=users,dc=contoso,dc=com')
->whereMemberOf($group);
No softdeletes on that model...
namespace App\Ldap;
use App\Ldap\Scopes\ImportFilter;
use LdapRecord\Models\ActiveDirectory\User as BaseModel;
class User extends BaseModel
{
protected static function boot()
{
parent::boot();
static::addGlobalScope(new ImportFilter);
}
}
There are no system accounts in the IntranetUsers group and unfortunately all of the users are spread across many OU's so using in()
may become cumbersome.
Strange... can you post your config/auth.php
file?
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option controls the default authentication "guard" and password
| reset options for your application. You may change these defaults
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => 'web',
'passwords' => 'users',
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| here which uses session storage and the Eloquent user provider.
|
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data.
|
| Supported: "session", "token"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'ldap',
],
'api' => [
'driver' => 'token',
'provider' => 'users',
'hash' => false,
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication drivers have a user provider. This defines how the
| users are actually retrieved out of your database or other storage
| mechanisms used by this application to persist your user's data.
|
| If you have multiple user tables or models you may configure multiple
| sources which represent each model / table. These sources may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'ldap' => [
'driver' => 'ldap',
'model' => App\Ldap\User::class,
'database' => [
'model' => App\User::class,
'sync_passwords' => true,
'sync_attributes' => [
'name' => 'cn',
'first_name' => 'givenname',
'last_name' => 'sn',
'email' => 'mail',
],
],
],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| You may specify multiple password reset configurations if you have more
| than one user table or model in the application and you want to have
| separate password reset settings based on the specific user types.
|
| The expire time is the number of minutes that the reset token should be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => 'password_resets',
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the amount of seconds before a password confirmation
| times out and the user is prompted to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => 10800,
];
Hmmm... can you post your App\User.php
model?
Oddly, I'm not getting any error messages now on re-deployment. But only 13 of the 48 users are importing. I removed SoftDeletes from my App\User model last night.
Here is my current App\User model:
<?php
namespace App;
use Illuminate\Notifications\Notifiable;
use Illuminate\Auth\Authenticatable;
use Illuminate\Foundation\Auth\Access\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use LdapRecord\Laravel\Auth\LdapAuthenticatable;
use LdapRecord\Laravel\Auth\AuthenticatesWithLdap;
class User extends Model implements AuthenticatableContract, AuthorizableContract, LdapAuthenticatable
{
use Notifiable, Authenticatable, Authorizable, AuthenticatesWithLdap;
protected $hidden = [
'password',
];
protected $guarded = [];
protected $perPage = 50;
public function survey()
{
return $this->hasMany('App\Survey');
}
public function scopeOrderByName($query)
{
$query->orderBy('last_name')->orderBy('first_name');
}
public function scopeFilter($query, array $filters)
{
$query->when($filters['search'] ?? null, function ($query, $search) {
$query->where(function ($query) use ($search) {
$query->where('first_name', 'like', '%'.$search.'%')
->orWhere('last_name', 'like', '%'.$search.'%');
});
})->when($filters['trashed'] ?? null, function ($query, $trashed) {
if ($trashed === 'with') {
$query->withTrashed();
} elseif ($trashed === 'only') {
$query->onlyTrashed();
}
});
}
}
My Forge deployment script for my dev environment:
cd /home/forge/<project name>
git pull origin dev
composer install --no-interaction --prefer-dist --optimize-autoloader
npm install && npm run dev
php artisan migrate:fresh --force
php artisan ldap:import ldap --no-interaction
( flock -w 10 9 || exit 1
echo 'Restarting FPM...'; sudo -S service php7.4-fpm reload ) 9>/tmp/fpmlock
Can you check the logs and see any errors on import?
No errors now. Just missing users.
When running the import, does it show 13 / 48 successfully imported? Or are you identifying users who are missing yourself?
It's only showing 13/13 but there are 48 user objects in the group.
$ php artisan ldap:import ldap
PHP Warning: Module 'ldap' already loaded in Unknown on line 0
Found [] user(s).
Would you like to display the user(s) to be imported / synchronized? (yes/no) [no]:
> yes
Would you like these users to be imported / synchronized? (yes/no) [yes]:
> yes
13/13 [ββββββββββββββββββββββββββββ] 100%
Successfully imported / synchronized [13] user(s).
Ok I'm understanding now, thanks!
Are these users perhaps members of a group that is contained in your parent group?
Let's give this a shot -- update your ImportFilter
to this:
$group = Group::findByDnOrFail('cn=intranetusers,ou=groups,dc=contoso,dc=com');
$query->whereMemberOf($group, $nested = true);
I've added the $nested
flag in the above query. Let me know if this makes a difference π
Same result... I just emailed you the output of:
Get-ADGroupMember -identity "IntranetUsers" -Recursive | Get-ADUser -Property DisplayName | Select Name,ObjectClass,DisplayName
Thanks Greg -- another thing to look as it the base_dn
set inside of your config/ldap.php
file -- is that set to the proper base of your domain?
Yes, sir. Base DN appears to be correct in the .env file. I've exported all of the users and their attributes to a CSV file... Nothing is jumping out at me. Before the scope, we were importing 144 users. All of the ones that we're missing now were importing before.
Perhaps I need to consider moving these users to a specific OU and filter them based on that rather than the group membership.
Hmmm... Strange! I'm super interested as to why these users are not being discovered by the query.
Can you try this inside of your routes/web.php
file and see if you get all the proper members?
We're just dumping the names of all the members of the group.
// routes/web.php
$group = \LdapRecord\Models\ActiveDirectory\Group::find(
'cn=intranetusers,ou=groups,dc=contoso,dc=com'
);
dd($group->members()->get()->map->getFirstAttribute('cn'));
// ...
BadMethodCallException Call to undefined method LdapRecord\Query\Model\ActiveDirectoryBuilder::findByDn()
Typo! My mistake -- see the updated code example.
I've removed the names, but it returns the same 13...
LdapRecord\Query\Collection^ {#285
#items: array:13 [
0 =>
1 =>
2 =>
3 =>
4 =>
5 =>
6 =>
7 =>
8 =>
9 =>
10 =>
11 =>
12 =>
]
}
Script @php artisan package:discover --ansi handling the post-autoload-dump event returned with error code 1
Okay we're getting closer...
Try again with:
$group = \LdapRecord\Models\ActiveDirectory\Group::find(
'cn=intranetusers,ou=groups,dc=contoso,dc=com'
);
dd($group->members()->recursive()->get()->map->getFirstAttribute('cn'));
I've added the
recursive()
method call to the above example.
If the same members are returned with the above example, is this group possibly a "Primary Group" of the other users that are missing? I ask, as primary groups are stored differently than regular group memberships.
Same output.
I looked at an account that is being found and one that is not, and both have the option to set the group as a primary group, which leads me to believe that it is not the case.
The group scope is global and the group type is security.
Also, just for the sake of my sanity, I created a new group... added all 48 users to it. Same output.
Very strange... can you post your LDAP configuration with sensitive details removed?
My Config\Ldap.php
<?php
return [
/*
|--------------------------------------------------------------------------
| Default LDAP Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the LDAP connections below you wish
| to use as your default connection for all LDAP operations. Of
| course you may add as many connections you'd like below.
|
*/
'default' => env('LDAP_CONNECTION', 'default'),
/*
|--------------------------------------------------------------------------
| LDAP Connections
|--------------------------------------------------------------------------
|
| Below you may configure each LDAP connection your application requires
| access to. Be sure to include a valid base DN - otherwise you may
| not receive any results when performing LDAP search operations.
|
*/
'connections' => [
'default' => [
'hosts' => [env('LDAP_HOST', '127.0.0.1')],
'username' => env('LDAP_USERNAME', 'cn=user,dc=local,dc=om'),
'password' => env('LDAP_PASSWORD', 'secret'),
'port' => env('LDAP_PORT', 389),
'base_dn' => env('LDAP_BASE_DN', 'dc=local,dc=com'),
'timeout' => env('LDAP_TIMEOUT', 5),
'use_ssl' => env('LDAP_SSL', false),
'use_tls' => env('LDAP_TLS', false),
],
],
/*
|--------------------------------------------------------------------------
| LDAP Logging
|--------------------------------------------------------------------------
|
| When LDAP logging is enabled, all LDAP search and authentication
| operations are logged using the default application logging
| driver. This can assist in debugging issues and more.
|
*/
'logging' => env('LDAP_LOGGING', true),
/*
|--------------------------------------------------------------------------
| LDAP Cache
|--------------------------------------------------------------------------
|
| LDAP caching enables the ability of caching search results using the
| query builder. This is great for running expensive operations that
| may take many seconds to complete, such as a pagination request.
|
*/
'cache' => [
'enabled' => env('LDAP_CACHE', false),
'driver' => env('CACHE_DRIVER', 'file'),
],
];
my .env settings
LDAP_LOGGING=true
LDAP_CONNECTION=default
LDAP_HOST=<PDC IP>
LDAP_USERNAME=ldap@contoso.com
LDAP_PASSWORD=********
LDAP_PORT=389
LDAP_BASE_DN="dc=contoso,dc=com"
LDAP_TIMEOUT=5
LDAP_SSL=false
LDAP_TLS=false
Everything looking good there... Can you try this in your routes/web.php
file and post the results?
$group = \LdapRecord\Models\ActiveDirectory\Group::find(
'cn=intranetusers,ou=groups,dc=contoso,dc=com'
);
dd($group->member);
OK, that found all of the users....
Ok, so the users are immediate members, but for some reason, the query for them is returning a limited number of them.
This brings me to the base_dn
again... But to be sure, can you post the results of:
$group = \LdapRecord\Models\ActiveDirectory\Group::find(
'cn=intranetusers,ou=groups,dc=contoso,dc=com'
);
dd($group->members()->getQuery()->getUnescapedQuery());
"(objectclass=*)"
Okay, now try:
$group = \LdapRecord\Models\ActiveDirectory\Group::find(
'cn=intranetusers,ou=groups,dc=contoso,dc=com'
);
dd($group->members()->getRelationQuery()->getUnescapedQuery());
"(objectclass=*)"
For now I've moved the users to a new OU and I'm using
$query->in('ou=users,ou=managed,dc=contoso,dc=com');
I can still and would like to troubleshoot this in my dev environment as time permits.
For now I've moved the users to a new OU and I'm using
When you do this, are all members returned properly via $group->members()->get()
?
No, it only returns 13 users.
Weird... this:
$group = \LdapRecord\Models\ActiveDirectory\Group::find(
'cn=intranetusers,ou=groups,dc=contoso,dc=com'
);
dd($group->members()->getRelationQuery()->getUnescapedQuery());
Should return:
"(memberof=cn=intranetusers,ou=groups,dc=contoso,dc=com)"
Would you like to hop on a Zoom call tonight or tomorrow and we can debug this together?
No worries if not, I can continue going step by step with you on here.
Let me know your thoughts!
What time tomorrow?
From: Steve Bauman notifications@github.com Reply-To: DirectoryTree/LdapRecord-Laravel reply@reply.github.com Date: Wednesday, June 10, 2020 at 5:54 PM To: DirectoryTree/LdapRecord-Laravel LdapRecord-Laravel@noreply.github.com Cc: Gregory Upton greg@itHero.tech, Mention mention@noreply.github.com Subject: Re: [DirectoryTree/LdapRecord-Laravel] globalScope or import filter [Support] (#166)
Weird... this:
$group = \LdapRecord\Models\ActiveDirectory\Group::find(
'cn=intranetusers,ou=groups,dc=contoso,dc=com'
);
dd($group->members()->getRelationQuery()->getUnescapedQuery());
Should return:
"(memberof=cn=intranetusers,ou=groups,dc=contoso,dc=com)"
Would you like to hop on a Zoom call tonight or tomorrow and we can debug this together?
No worries if not, I can continue going step by step with you on here.
Let me know your thoughts!
β You are receiving this because you were mentioned. Reply to this email directly, view it on GitHubhttps://github.com/DirectoryTree/LdapRecord-Laravel/issues/166#issuecomment-642287440, or unsubscribehttps://github.com/notifications/unsubscribe-auth/ANA4YRRPGYQFIC36FYRRBNDRV76HJANCNFSM4NZ3MTLA.
I can do noon at 12 PM EST, or anytime after 4:30 PM EST?
Letβs do noon.
From: Steve Bauman notifications@github.com Reply-To: DirectoryTree/LdapRecord-Laravel reply@reply.github.com Date: Wednesday, June 10, 2020 at 6:15 PM To: DirectoryTree/LdapRecord-Laravel LdapRecord-Laravel@noreply.github.com Cc: Gregory Upton greg@itHero.tech, Mention mention@noreply.github.com Subject: Re: [DirectoryTree/LdapRecord-Laravel] globalScope or import filter [Support] (#166)
I can do noon at 12 PM EST, or anytime after 4:30 PM EST?
β You are receiving this because you were mentioned. Reply to this email directly, view it on GitHubhttps://github.com/DirectoryTree/LdapRecord-Laravel/issues/166#issuecomment-642295642, or unsubscribehttps://github.com/notifications/unsubscribe-auth/ANA4YRRBBN46AFEFEZ4P77LRWAAXHANCNFSM4NZ3MTLA.
Great, Iβll send you a zoom link to your email tomorrow morning for noon.
Hey Greg - thanks for connecting with me earlier today to get this issue fixed!
I've done a bit of digging on this -- and the issue may be permissions with the account you are binding to the AD directory with:
https://serverfault.com/questions/582728/linux-ldap-query-to-ad-missing-group-members
Somehow certain AD objects were not readable by the generic account I was using (the LDAP query account did not have the
read group membership
permission) - adding that right to my generic LDAP query account fixed the issue permanently.
What you're using now will still work, but wanted to post this in case you need to troubleshoot this in the future and go back to what was being attempted previously. π
So after our Zoom meeting I wasn't able to log into the app with the workaround, it was syncing all of the users, but the passwords weren't working.
Interestingly enough, I logged into our tools server as the generic account and was able to query all members of the group via powershell.
However, you're on the right track with permissions, I ran the delegation wizard and gave the user account permission to "read all user information" and reverted the code to the original example and it works flawlessly.
Might be worth making a note in the project docs.
Awesome! I'm really glad it's working properly now. I'll definitely add this into the docs π
I appreciate your time getting to the bottom of this issue.
Hello Steve,
How would one go about creating a globalScope or filter to only import users from a specific AD group?