DirectoryTree / LdapRecord-Laravel

Multi-domain LDAP Authentication & Management for Laravel.
https://ldaprecord.com/docs/laravel/v3
MIT License
508 stars 54 forks source link

Dynamic creation of directories #125

Closed AlexH-HankIT closed 4 years ago

AlexH-HankIT commented 4 years ago

Hi!

I thought you did great work with ADLDAP2, but this package seems to be even better. Great job 👍

I want to use LdapRecord to authenticate users from possibly multiple domains, but everything should be dynamically loaded from the database. I also am in need of local authentication support.

I came up with the following:

Step 1: Determine the domain from the request Step 2: Instantiate appropriate UserProvider

I implemented these steps (quick & dirty) in the attemptLogin() method of my LoginController:

public function attemptLogin(Request $request)
    {
        // Extends LdapRecord\Models\ActiveDirectory\User::class
        $ldapModelClass = \App\Ldap\Models\User::class;

        // Default laravel eloquent user model
        $eloquentModelClass = \App\Models\User::class;

        switch ($request->domain) {
            case 'ad.contoso.com':
                // Set authentication attribute for this domain
                $username = 'samaccountname';

                // Since I want to set the connect dynamically, I instantiate
                // the ldap model and pass the connection name. So that the
                // LdapUserAuthenticator may get it from the model.
                $model = new $ldapModelClass;
                $model->setConnection('ad.contoso.com');

                $provider = app(DatabaseUserProvider::class, [
                    // The LdapUserRepository gets the instantiated model, even though the
                    // original purpose of the parameter seems to be to send the name
                    // of the class, it should still work, when I pass an object.
                    'users' => app(LdapUserRepository::class, ['model' => $model]),
                    'importer' => app(LdapUserImporter::class, ['eloquentModel' => $eloquentModelClass, 'config' => ['sync_attributes' => ['name' => 'cn', 'email' => 'mail']]]),
                    'eloquent' => app(EloquentUserProvider::class, ['model' => $eloquentModelClass]),
                ]);
                break;
            default:
                // Local authentication
                $username = $this->username();
                $provider = app(EloquentUserProvider::class, ['model' => \App\Models\User::class]);
        }

        return tap($this->guard(), function($guard) use($provider) {
            $guard->setProvider($provider);
        })->attempt([$username => $request->get('name'), 'password' => $request->get('password')], false);
    }

It seems to work well so far. (Tested with local authentication and a single domain, using an input to provide the domain in the frontend)

My concern is primarily with the LdapUserRepository, since the model parameter is documented as string, but the code looks like it should work fine with passing an object, atleast until type hints are enforced. I need to pass an object, since I want to set the ldap connection on it. I also implemented the __toString() method on it to return the class name.

What do you think about this approach?

AlexH-HankIT commented 4 years ago

There is actually one more step necessary. I realised it was just working, because it still used the default connection, since the object I provide is not passed to the query() method inside the LdapUserRepository: https://github.com/DirectoryTree/LdapRecord-Laravel/blob/master/src/LdapUserRepository.php#L79

I decided to overwrite the query() method on the LdapUserRepository:

<?php

namespace App\Ldap;

use LdapRecord\Laravel\LdapUserRepository as LdapRecordUserRepository;

class LdapUserRepository extends LdapRecordUserRepository
{
    /**
     * Get a new model query.
     *
     * @return \LdapRecord\Query\Model\Builder
     */
    public function query()
    {
        return $this->newModelQuery($this->model);
    }
}

AppServiceProvider

$this->app->bind(LdapRecordUserRepository::class, LdapUserRepository::class);

Now it seems to work.

stevebauman commented 4 years ago

Hey @AlexH269! Thanks for the kind words, and also -- great question.

From the code example you posted, it works, but your default guard (likely the users provider using the eloquent driver) will always be used. I'm not sure if that is your intention or not.

It looks like you're dynamically replacing the guards provider at runtime, but after the initial authentication attempt, the eloquent auth driver will be used.

This means you won't be able to import / synchronize LDAP users via the ldap:import command, use LDAP model binding, or other LdapRecord-Laravel features -- since the LDAP auth provider + driver is not actually configured and existing in your applications configuration.

If that's okay for your application and it's tested + working, then there's no need to go through any extra work.

I want to use LdapRecord to authenticate users from possibly multiple domains, but everything should be dynamically loaded from the database.

This sounds like you want to also store your authentication configuration in your database. I've had to do this a couple times for storing the configuration of a mail driver.

Here's an example of what I've done -- if this helps you at all. I created a SettingServiceProvider, that upon boot(), will set configuration values using config()->set() by retrieving the values from a database table called settings:

class SettingServiceProvider extends ServiceProvider
{
    /**
     * Load application configuration from the database.
     *
     * @return void
     */
    public function boot()
    {
        try {
            config()->set('app.timezone', setting('app.timezone', env('APP_TIMEZONE')));

            config()->set([
                'mail.driver' => setting('app.email.driver'),
                'mail.host' => setting('app.email.host'),
                'mail.port' => setting('app.email.port'),
                'mail.encryption' => setting('app.email.encryption'),
                'mail.username' => setting('app.email.username'),
                'mail.password' => setting('app.email.password'),
                'mail.from' => [
                    'address' => setting('app.email.from.address'),
                    'name' => setting('app.email.from.name'),
                ],
            ]);
        } catch (Exception $ex) {
            // Migrations have not been ran yet.
        }
    }
}

The setting() helper simply queried a model named Setting for a name equal to the given key, and returned its value that was set in the database.

I feel like you may want to do something similar here, and retrieve your authentication config from your database and your LDAP connection config as well, for example:

public function boot()
{
    foreach (Domain::get() as $domain) {
        config(["ldap.connections.{$domain->name}" => [
            'hosts' => [$domain->host],
            'username' => $domain->username,
            'password' => $domain->password,
            'base_dn' => $domain->base_dn,
            // ...
        ]]);
    }
}

I have a repo of an application I was creating (recently renamed) called Scout that loaded its LDAP config from the database:

https://github.com/DirectoryTree/Scout/blob/master/app/Providers/LdapConnectionProvider.php

https://github.com/DirectoryTree/Scout/blob/master/app/LdapDomain.php

Hope this helps you on your journey!

stevebauman commented 4 years ago

I'll close this as it's not an issue, but please feel free to comment back if you like and we can keep the discussion going.

AlexH-HankIT commented 4 years ago

Hi!

Thank you for your help. Much appreciated. Based on your recommendations and some testing I came up with the following:

LoginController:

    public function attemptLogin(Request $request)
    {
        $factory = app(AuthProviderFactory::class, ['realm' => $request->get('realm')]);

        // Safe auth provider name, so we may store it inside
        // the session after the user is authenticated.
        $this->authProviderName = $factory->getProviderName();

        // Get credential array in correct format
        $credentials = $factory->getCredentials($request->get('name'), $request->get('password'));

        // Set provider on guard and attempt authentication
        return tap($this->guard(), function($guard) use($factory) {
            $guard->setProvider($factory->getProvider());
        })->attempt($credentials, false);
    }

I store the provider's name as property on the controller and instantiate it to pass it onto the guard. This will make sure, that the current authentication attempt is done using the selected provider. The AuthProviderFactory creates the appropriate provider based on the selected login realm.

After the authentication is successfully, I get the provider name from the property I stored earlier and add it to the user’s session. A middleware sets the provider at every subsequent request. This makes sure that LDAP model binding and other LdapRecord features work.

To read the configuration from the database I created a LdapServiceProvider:

public function boot()
    {
        if (! Schema::hasTable('ldap_directories')) {
            return;
        }

        $directories = LdapDirectory::all(['connection', 'hosts', 'username', 'password', 'port', 'base_dn', 'timeout', 'use_ssl', 'use_tls'])
            ->makeVisible('password')
            ->keyBy('connection')
            ->toArray();

        // Set first directory as default
        config(['ldap.default' => optional(array_keys($directories)[0])]);

        // Set connections in Laravel's configuration
        foreach ($directories as $name => $directory) {
            config([
                'ldap.connections.'.$name.'.hosts' => $directory['hosts'],
                'ldap.connections.'.$name.'.username' => $directory['username'],
                'ldap.connections.'.$name.'.password' => $directory['password'],
                'ldap.connections.'.$name.'.port' => $directory['port'],
                'ldap.connections.'.$name.'.base_dn' => $directory['base_dn'],
                'ldap.connections.'.$name.'.timeout' => $directory['timeout'],
                'ldap.connections.'.$name.'.use_ssl' => (bool) $directory['use_ssl'],
                'ldap.connections.'.$name.'.use_tls' => (bool) $directory['use_tls'],
            ]);
        }
    }

Since I need to set the connection property dynamically on the ldap model, I overwrite the boot() method on the LdapAuthServiceProvider. It creates a provider for all available ldap connections and registers them with Laravel:

public function boot()
    {
        if (! Schema::hasTable('ldap_directories')) {
            return;
        }

        $this->commands([ImportLdapUsers::class]);

        $connections = LdapDirectory::pluck('connection')->toArray();

        foreach($connections as $connection) {
            $name = 'ldap_' . $connection;

            // Register provider
            Auth::provider($name, function ($app, array $config) use($connection) {
                return app(AuthProviderFactory::class, ['realm' => $connection])->getProvider();
            });

            // Set provider in config
            config(['auth.providers.'.$name => ['driver' => $name, 'model' => \App\Ldap\Models\User::class]]);
        }

        Event::listen([Login::class, Authenticated::class], BindLdapUserModel::class);

        if (config('ldap.logging', true)) {
            // If logging is enabled, we will set up our event listeners that
            // log each event fired throughout the authentication process.
            foreach ($this->events as $event => $listener) {
                Event::listen($event, $listener);
            }
        }

        $this->registerLoginControllerListener();
    }

Local and LDAP authentication works (atleast against one LDAP), I will have to install another one to check if it works correctly with multiple. The CLI Import command also works. I will report back, when I have a fully working setup with multiple LDAP connections loaded from the database.