├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── support-request.md └── workflows │ ├── run-cs-fix.yml │ └── run-tests.yml ├── .gitignore ├── composer.json ├── config └── ldap.php ├── database └── migrations │ └── 2025_01_01_000000_add_ldap_columns_to_users_table.php ├── license.md ├── phpunit.xml ├── readme.md ├── resources └── lang │ └── en │ └── errors.php ├── src ├── Auth │ ├── AuthenticatesWithLdap.php │ ├── BindFailureListener.php │ ├── CreatesUserProvider.php │ ├── DatabaseUserProvider.php │ ├── HasLdapUser.php │ ├── LdapAuthenticatable.php │ ├── ListensForLdapBindFailure.php │ ├── NoDatabaseUserProvider.php │ ├── Rule.php │ ├── Rules │ │ └── OnlyImported.php │ ├── UserProvider.php │ └── Validator.php ├── Commands │ ├── BrowseLdapServer.php │ ├── GetRootDse.php │ ├── ImportLdapUsers.php │ ├── MakeLdapModel.php │ ├── MakeLdapRule.php │ ├── MakeLdapScope.php │ ├── TestLdapConnection.php │ └── stubs │ │ ├── model.stub │ │ ├── rule.stub │ │ └── scope.stub ├── DetectsSoftDeletes.php ├── Events │ ├── Auth │ │ ├── BindFailed.php │ │ ├── Binding.php │ │ ├── Bound.php │ │ ├── Completed.php │ │ ├── CompletedWithWindows.php │ │ ├── DiscoveredWithCredentials.php │ │ ├── EloquentUserTrashed.php │ │ ├── Event.php │ │ ├── Rejected.php │ │ ├── RuleEvent.php │ │ ├── RuleFailed.php │ │ └── RulePassed.php │ ├── Import │ │ ├── Completed.php │ │ ├── Deleted.php │ │ ├── DeletedMissing.php │ │ ├── Event.php │ │ ├── ImportFailed.php │ │ ├── Imported.php │ │ ├── Importing.php │ │ ├── Restored.php │ │ ├── Saved.php │ │ ├── Started.php │ │ ├── Synchronized.php │ │ └── Synchronizing.php │ ├── Loggable.php │ └── LoggableEvent.php ├── Import │ ├── EloquentHydrator.php │ ├── EloquentUserHydrator.php │ ├── Hydrators │ │ ├── AttributeHydrator.php │ │ ├── DomainHydrator.php │ │ ├── GuidHydrator.php │ │ ├── Hydrator.php │ │ └── PasswordHydrator.php │ ├── ImportException.php │ ├── Importer.php │ ├── LdapUserImporter.php │ ├── Synchronizer.php │ └── UserSynchronizer.php ├── ImportableFromLdap.php ├── LdapAuthServiceProvider.php ├── LdapImportable.php ├── LdapRecord.php ├── LdapServiceProvider.php ├── LdapUserAuthenticator.php ├── LdapUserRepository.php ├── Middleware │ ├── UserDomainValidator.php │ └── WindowsAuthenticate.php └── Testing │ ├── DirectoryEmulator.php │ ├── Emulated │ ├── ActiveDirectoryBuilder.php │ ├── EmulatesModelQueries.php │ ├── ModelBuilder.php │ └── OpenLdapBuilder.php │ ├── EmulatedBuilder.php │ ├── EmulatedConnectionFake.php │ ├── EmulatesQueries.php │ ├── LdapDatabaseManager.php │ ├── LdapObject.php │ ├── LdapObjectAttribute.php │ ├── LdapObjectAttributeValue.php │ ├── ResolvesEmulatedConnection.php │ ├── UnescapedValue.php │ ├── VirtualAttributeObserver.php │ └── VirtualAttributeValueObserver.php └── tests ├── Feature ├── Commands │ ├── GetRootDseTest.php │ ├── ImportLdapUsersTest.php │ └── TestLdapConnectionTest.php ├── CreatesTestUsers.php ├── DatabaseTestCase.php ├── DatabaseUserProviderTest.php ├── Emulator │ ├── EmulatedAuthenticationTest.php │ ├── EmulatedDatabaseAuthenticationTest.php │ ├── EmulatedImportTest.php │ ├── EmulatedModelBindingTest.php │ ├── EmulatedModelQueryTest.php │ ├── EmulatedQueryTest.php │ ├── EmulatedUserRepositoryTest.php │ ├── EmulatedWindowsAuthenticateTest.php │ └── EmulatorTest.php ├── LdapUserSynchronizerTest.php ├── ListenForLdapBindFailureTest.php ├── NoDatabaseUserProviderTest.php ├── SanctumTest.php ├── SanctumTestUserModelStub.php ├── TestUserModelStub.php └── WindowsAuthMiddlewareTest.php ├── TestCase.php └── Unit ├── EloquentHydratorTest.php ├── LdapDatabaseManagerTest.php ├── LdapImporterTest.php ├── LdapServiceProviderTest.php ├── LdapSynchronizerTest.php ├── LdapUserAuthenticatorTest.php ├── LdapUserRepositoryTest.php ├── ListenForLdapBindFailureTest.php ├── ValidatorRuleOnlyImportedTest.php └── ValidatorTest.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [stevebauman] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve LdapRecord-Laravel 4 | assignees: '' 5 | 6 | --- 7 | 8 | 12 | **Environment:** 13 | - LDAP Server Type: [e.g. ActiveDirectory / OpenLDAP / FreeIPA] 14 | - LdapRecord-Laravel Major Version: [e.g. v1, v2] 15 | - PHP Version: [e.g. 7.3 / 7.4 / 8.0] 16 | 17 | **Describe the bug:** 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for LdapRecord-Laravel 4 | title: "[Feature]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the feature you'd like:** 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support request 3 | about: Request help using LdapRecord-Laravel (requires sponsorship) 4 | title: "[Support]" 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | 18 | **Environment:** 19 | - LDAP Server Type: [e.g. ActiveDirectory / OpenLDAP / FreeIPA] 20 | - LdapRecord-Laravel Major Version: [e.g. v1, v2] 21 | - PHP Version: [e.g. 7.3 / 7.4 / 8.0] 22 | -------------------------------------------------------------------------------- /.github/workflows/run-cs-fix.yml: -------------------------------------------------------------------------------- 1 | name: run-cs-fix 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: true 11 | matrix: 12 | php: [8.3] 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php }} 22 | extensions: json, dom, curl, libxml, mbstring 23 | coverage: none 24 | 25 | - name: Install Pint 26 | run: composer global require laravel/pint 27 | 28 | - name: Run Pint 29 | run: pint 30 | 31 | - name: Commit linted files 32 | uses: stefanzweifel/git-auto-commit-action@v5 33 | with: 34 | commit_message: "Fix code style" 35 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | run-tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - php: 8.4 17 | laravel: 12.* 18 | testbench: 10.* 19 | 20 | - php: 8.4 21 | laravel: 11.* 22 | testbench: 9.* 23 | 24 | - php: 8.3 25 | laravel: 10.* 26 | testbench: 8.* 27 | 28 | - php: 8.2 29 | laravel: 9.* 30 | testbench: 7.* 31 | 32 | name: P${{ matrix.php }} - L${{ matrix.laravel }} 33 | 34 | steps: 35 | - name: Checkout code 36 | uses: actions/checkout@v2 37 | 38 | - name: Cache dependencies 39 | uses: actions/cache@v3 40 | with: 41 | path: ~/.composer/cache/files 42 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 43 | 44 | - name: Setup PHP 45 | uses: shivammathur/setup-php@v2 46 | with: 47 | php-version: ${{ matrix.php }} 48 | extensions: ldap, fileinfo, dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, mysql, pdo_mysql, bcmath, intl, gd, exif, iconv 49 | coverage: none 50 | 51 | - name: Install dependencies 52 | run: | 53 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update --dev 54 | composer update --prefer-stable --prefer-dist --no-interaction 55 | 56 | - name: Execute tests 57 | run: vendor/bin/phpunit --testdox 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | composer.lock 4 | .phpunit.cache 5 | .phpunit.result.cache 6 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "directorytree/ldaprecord-laravel", 3 | "description": "LDAP Authentication & Management for Laravel.", 4 | "keywords": [ 5 | "ldaprecord", 6 | "adldap2", 7 | "ldap", 8 | "laravel" 9 | ], 10 | "license": "MIT", 11 | "type": "project", 12 | "require": { 13 | "php": ">=8.1", 14 | "ext-ldap": "*", 15 | "ext-json": "*", 16 | "ramsey/uuid": "*", 17 | "directorytree/ldaprecord": "^v3.3", 18 | "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0" 19 | }, 20 | "require-dev": { 21 | "mockery/mockery": "^1.0", 22 | "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.0", 23 | "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", 24 | "spatie/ray": "^1.28", 25 | "laravel/sanctum": "*", 26 | "laravel/pint": "^1.9" 27 | }, 28 | "archive": { 29 | "exclude": [ 30 | "/tests" 31 | ] 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "LdapRecord\\Laravel\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "LdapRecord\\Laravel\\Tests\\": "tests/" 41 | } 42 | }, 43 | "extra": { 44 | "laravel": { 45 | "providers": [ 46 | "LdapRecord\\Laravel\\LdapServiceProvider", 47 | "LdapRecord\\Laravel\\LdapAuthServiceProvider" 48 | ] 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/ldap.php: -------------------------------------------------------------------------------- 1 | env('LDAP_CONNECTION', 'default'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | LDAP Connections 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Below you may configure each LDAP connection your application requires 24 | | access to. Be sure to include a valid base DN - otherwise you may 25 | | not receive any results when performing LDAP search operations. 26 | | 27 | */ 28 | 29 | 'connections' => [ 30 | 31 | 'default' => [ 32 | 'hosts' => [env('LDAP_HOST', '127.0.0.1')], 33 | 'username' => env('LDAP_USERNAME', 'cn=user,dc=local,dc=com'), 34 | 'password' => env('LDAP_PASSWORD', 'secret'), 35 | 'port' => env('LDAP_PORT', 389), 36 | 'base_dn' => env('LDAP_BASE_DN', 'dc=local,dc=com'), 37 | 'timeout' => env('LDAP_TIMEOUT', 5), 38 | 'use_ssl' => env('LDAP_SSL', false), 39 | 'use_tls' => env('LDAP_TLS', false), 40 | 'use_sasl' => env('LDAP_SASL', false), 41 | 'sasl_options' => [ 42 | // 'mech' => 'GSSAPI', 43 | ], 44 | ], 45 | 46 | ], 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | LDAP Logging 51 | |-------------------------------------------------------------------------- 52 | | 53 | | When LDAP logging is enabled, all LDAP search and authentication 54 | | operations are logged using the default application logging 55 | | driver. This can assist in debugging issues and more. 56 | | 57 | */ 58 | 59 | 'logging' => [ 60 | 'enabled' => env('LDAP_LOGGING', true), 61 | 'channel' => env('LOG_CHANNEL', 'stack'), 62 | 'level' => env('LOG_LEVEL', 'info'), 63 | ], 64 | 65 | /* 66 | |-------------------------------------------------------------------------- 67 | | LDAP Cache 68 | |-------------------------------------------------------------------------- 69 | | 70 | | LDAP caching enables the ability of caching search results using the 71 | | query builder. This is great for running expensive operations that 72 | | may take many seconds to complete, such as a pagination request. 73 | | 74 | */ 75 | 76 | 'cache' => [ 77 | 'enabled' => env('LDAP_CACHE', false), 78 | 'driver' => env('CACHE_DRIVER', 'file'), 79 | ], 80 | 81 | ]; 82 | -------------------------------------------------------------------------------- /database/migrations/2025_01_01_000000_add_ldap_columns_to_users_table.php: -------------------------------------------------------------------------------- 1 | getDriverName(); 16 | 17 | Schema::table('users', function (Blueprint $table) use ($driver) { 18 | $table->string('guid')->nullable(); 19 | $table->string('domain')->nullable(); 20 | 21 | if ($driver !== 'sqlsrv') { 22 | $table->unique('guid'); 23 | } 24 | }); 25 | 26 | if ($driver === 'sqlsrv') { 27 | DB::statement( 28 | $this->compileUniqueSqlServerIndexStatement('users', 'guid') 29 | ); 30 | } 31 | } 32 | 33 | /** 34 | * Reverse the migrations. 35 | */ 36 | public function down(): void 37 | { 38 | Schema::table('users', function (Blueprint $table) { 39 | $table->dropColumn(['guid', 'domain']); 40 | }); 41 | } 42 | 43 | /** 44 | * Compile a compatible "unique" SQL Server index constraint. 45 | */ 46 | protected function compileUniqueSqlServerIndexStatement(string $table, string $column): string 47 | { 48 | return sprintf('create unique index %s on %s (%s) where %s is not null', 49 | implode('_', [$table, $column, 'unique']), 50 | $table, 51 | $column, 52 | $column 53 | ); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © Steve Bauman 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/ 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 |

6 | 7 |

Integrate LDAP into your Laravel application.

8 | 9 |

10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 |

18 | Documentation 19 | · 20 | Directory Browser 21 | · 22 | Post a Question 23 |

24 | 25 | --- 26 | 27 | ⭐️ **Developer Experience First** 28 | 29 | LdapRecord focuses on clean, easy to understand syntax along with thorough documentation. 30 | 31 | 🔑 **Authenticate LDAP Users** 32 | 33 | Allow LDAP users to log into your application and control which users can login via [Scopes](https://ldaprecord.com/docs/laravel/v3/usage/#scopes) and [Rules](https://ldaprecord.com/docs/laravel/v1/auth/configuration/#rules). 34 | 35 | 🔄 **Import & Synchronize LDAP users** 36 | 37 | Import users from your directory via [command](https://ldaprecord.com/docs/laravel/v3/auth/database/importing): `php artisan ldap:import`. 38 | 39 | 💼 **Multi-Domain Support** 40 | 41 | Authenticate users from as many LDAP domains as you'd like. Support comes [out of the box](https://ldaprecord.com/docs/laravel/v3/auth/multi-domain). 42 | 43 | 🎩 **Eloquent Query Builder** 44 | 45 | Search for LDAP objects with a [fluent and easy to use interface](https://ldaprecord.com/docs/core/v3/searching) you're used to. You'll feel right at home. 46 | 47 | ✏️ **Active Record LDAP Models** 48 | 49 | LDAP objects are [individual models](https://ldaprecord.com/docs/core/v3/models). Persist them to your LDAP server with a single `save()`. 50 | 51 | 💫 **LDAP Directory Emulator** 52 | 53 | Test [authenticating](https://ldaprecord.com/docs/laravel/v3/auth/testing/#getting-started) and 54 | [querying users](https://ldaprecord.com/docs/laravel/v3/testing/#getting-started) without 55 | changing your application code. 56 | 57 | Create, update, and delete LDAP objects without touching a real LDAP server. 58 | 59 | --- 60 | 61 |

LdapRecord-Laravel is Supportware™

62 | 63 |

If you require support using LdapRecord-Laravel, a sponsorship is required :pray:

64 | 65 |

Thank you for your understanding :heart:

66 | 67 | --- 68 | 69 |

Security Vulnerabilities

70 | 71 |

If you discover a security vulnerability within LdapRecord-Laravel, please send an e-mail to Steve Bauman via steven_bauman@outlook.com.

72 | 73 |

All security vulnerabilities will be promptly addressed.

74 | -------------------------------------------------------------------------------- /resources/lang/en/errors.php: -------------------------------------------------------------------------------- 1 | 'User not found.', 5 | 'user_not_permitted_at_this_time' => 'Not permitted to logon at this time.', 6 | 'user_not_permitted_to_login' => 'Not permitted to logon at this workstation.', 7 | 'password_expired' => 'Your password has expired.', 8 | 'account_disabled' => 'Your account is disabled.', 9 | 'account_expired' => 'Your account has expired.', 10 | 'user_must_reset_password' => 'You must reset your password before logging in.', 11 | 'user_account_locked' => 'Your account is locked.', 12 | ]; 13 | -------------------------------------------------------------------------------- /src/Auth/AuthenticatesWithLdap.php: -------------------------------------------------------------------------------- 1 | listenForLdapBindFailure(); 29 | } 30 | }); 31 | } 32 | 33 | /** 34 | * Register the bind failure listener upon resolving the given class. 35 | */ 36 | protected static function whenResolving(string $class, ?Closure $callback = null): void 37 | { 38 | if (! class_exists($class)) { 39 | return; 40 | } 41 | 42 | app()->resolving($class, $callback ?? function () { 43 | (new static)->listenForLdapBindFailure(); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Auth/CreatesUserProvider.php: -------------------------------------------------------------------------------- 1 | $config) { 18 | if (Auth::guard($guard)->check()) { 19 | return $guard; 20 | } 21 | } 22 | 23 | return null; 24 | } 25 | 26 | /** 27 | * Get the guard's authentication user provider. 28 | */ 29 | protected function getCurrentAuthProvider(string $guard): ?UserProvider 30 | { 31 | if ($guard === 'sanctum') { 32 | $guard = Arr::first( 33 | Arr::wrap(Config::get('sanctum.guard', 'web')) 34 | ); 35 | } 36 | 37 | return Auth::createUserProvider( 38 | Config::get("auth.guards.$guard.provider") 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Auth/DatabaseUserProvider.php: -------------------------------------------------------------------------------- 1 | synchronizer = $synchronizer; 54 | $this->eloquent = $eloquent; 55 | } 56 | 57 | /** 58 | * Dynamically pass missing methods to the Eloquent user provider. 59 | */ 60 | public function __call(string $method, array $parameters): mixed 61 | { 62 | return $this->forwardCallTo($this->eloquent, $method, $parameters); 63 | } 64 | 65 | /** 66 | * Get the Eloquent user provider. 67 | */ 68 | public function eloquent(): EloquentUserProvider 69 | { 70 | return $this->eloquent; 71 | } 72 | 73 | /** 74 | * Get the LDAP user importer. 75 | */ 76 | public function getLdapUserSynchronizer(): UserSynchronizer 77 | { 78 | return $this->synchronizer; 79 | } 80 | 81 | /** 82 | * Set the authenticating LDAP user. 83 | */ 84 | public function setAuthenticatingUser(Model $user): void 85 | { 86 | $this->user = $user; 87 | } 88 | 89 | /** 90 | * Fallback to Eloquent authentication after failing to locate an LDAP user. 91 | */ 92 | public function shouldFallback(): void 93 | { 94 | $this->fallback = true; 95 | } 96 | 97 | /** 98 | * Set the callback to be used to resolve LDAP users. 99 | */ 100 | public function resolveUsersUsing(Closure $callback): static 101 | { 102 | $this->userResolver = $callback; 103 | 104 | return $this; 105 | } 106 | 107 | /** 108 | * {@inheritdoc} 109 | */ 110 | public function retrieveById($identifier): ?Authenticatable 111 | { 112 | return $this->eloquent->retrieveById($identifier); 113 | } 114 | 115 | /** 116 | * {@inheritdoc} 117 | */ 118 | public function retrieveByToken($identifier, $token): ?Authenticatable 119 | { 120 | return $this->eloquent->retrieveByToken($identifier, $token); 121 | } 122 | 123 | /** 124 | * {@inheritdoc} 125 | */ 126 | public function updateRememberToken(Authenticatable $user, $token): void 127 | { 128 | $this->eloquent->updateRememberToken($user, $token); 129 | } 130 | 131 | /** 132 | * {@inheritdoc} 133 | */ 134 | public function retrieveByCredentials(array $credentials): ?Authenticatable 135 | { 136 | $this->fallback = isset($credentials['fallback']); 137 | 138 | $fetch = function () use ($credentials) { 139 | return $this->fetchLdapUserByCredentials($credentials); 140 | }; 141 | 142 | // If fallback is enabled, we want to make sure we catch 143 | // any exceptions that may occur so we do not interrupt 144 | // the fallback process and exit authentication early. 145 | $user = $this->fallback ? rescue($fetch) : value($fetch); 146 | 147 | // If the user was unable to be located and fallback is 148 | // enabled, we will attempt to fetch the database user 149 | // and perform validation on their password normally. 150 | if (! $user) { 151 | return $this->fallback 152 | ? $this->retrieveByCredentialsUsingEloquent($credentials['fallback']) 153 | : null; 154 | } 155 | 156 | $this->setAuthenticatingUser($user); 157 | 158 | return $this->synchronizer->run($user, $credentials); 159 | } 160 | 161 | /** 162 | * Retrieve the user from their credentials using Eloquent. 163 | * 164 | * @throws \Exception 165 | */ 166 | protected function retrieveByCredentialsUsingEloquent(array $credentials): ?Authenticatable 167 | { 168 | return $this->eloquent->retrieveByCredentials($credentials); 169 | } 170 | 171 | /** 172 | * {@inheritdoc} 173 | */ 174 | public function validateCredentials(Authenticatable $user, array $credentials): bool 175 | { 176 | // If an LDAP user has not been located, fallback is enabled, and 177 | // the given Eloquent model exists, we will attempt to validate 178 | // the users password normally via the Eloquent user provider. 179 | if (! $this->user instanceof Model) { 180 | return $this->fallback && $user->exists 181 | ? $this->eloquent->validateCredentials($user, $credentials) 182 | : false; 183 | } 184 | 185 | $this->auth->setEloquentModel($user); 186 | 187 | if (! $this->auth->attempt($this->user, $credentials['password'])) { 188 | return false; 189 | } 190 | 191 | $user->save(); 192 | 193 | event(new Saved($this->user, $user)); 194 | 195 | if ($user->wasRecentlyCreated) { 196 | event(new Imported($this->user, $user)); 197 | } 198 | 199 | event(new Completed($this->user, $user)); 200 | 201 | return true; 202 | } 203 | 204 | /** 205 | * {@inheritdoc} 206 | */ 207 | public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false): void 208 | { 209 | if (($this->synchronizer->getConfig()['password_column'] ?? 'password') !== false) { 210 | $this->eloquent->rehashPasswordIfRequired($user, $credentials, $force); 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Auth/HasLdapUser.php: -------------------------------------------------------------------------------- 1 | getCurrentAuthGuard()) { 27 | return null; 28 | } 29 | 30 | if (! $provider = $this->getCurrentAuthProvider($guard)) { 31 | return null; 32 | } 33 | 34 | if (! $provider instanceof UserProvider) { 35 | return null; 36 | } 37 | 38 | if (! isset($this->ldapUserModel)) { 39 | $this->ldapUserModel = $provider->getLdapUserRepository()->findByModel($this); 40 | } 41 | 42 | return $this->ldapUserModel; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Auth/LdapAuthenticatable.php: -------------------------------------------------------------------------------- 1 | listen(Connecting::class, function (Connecting $event) use (&$isOnLastHost) { 42 | $connection = $event->getConnection(); 43 | 44 | $numberOfHosts = count($connection->getConfiguration()->get('hosts')); 45 | 46 | $numberOfHostsAttempted = count($connection->attempted()); 47 | 48 | $isOnLastHost = ($numberOfHosts - 1) - $numberOfHostsAttempted === 0; 49 | }); 50 | 51 | $dispatcher->listen(BindFailed::class, function (BindFailed $event) use (&$isOnLastHost) { 52 | if (! $isOnLastHost) { 53 | return; 54 | } 55 | 56 | $this->ldapBindFailed( 57 | $event->getConnection()->getLastError(), 58 | $event->getConnection()->getDiagnosticMessage() 59 | ); 60 | }); 61 | } 62 | 63 | /** 64 | * Generate a human validation error for LDAP bind failures. 65 | * 66 | * @throws ValidationException 67 | */ 68 | protected function ldapBindFailed(string $errorMessage, ?string $diagnosticMessage = null): void 69 | { 70 | switch (true) { 71 | case $this->causedByLostConnection($errorMessage): 72 | $this->handleLdapBindError($errorMessage); 73 | 74 | return; 75 | 76 | case is_null($diagnosticMessage): 77 | // If there is no diagnostic message to work with, we 78 | // cannot make any further attempts to determine 79 | // the error. We will bail here in such case. 80 | return; 81 | 82 | case $this->causedByInvalidCredentials($errorMessage, $diagnosticMessage): 83 | // We'll bypass any invalid LDAP credential errors and let 84 | // the login controller handle it. This is so proper 85 | // translation can be done on the validation error. 86 | return; 87 | default: 88 | foreach ($this->ldapDiagnosticCodeErrorMap() as $code => $message) { 89 | if ($this->errorContainsMessage($diagnosticMessage, (string) $code)) { 90 | $this->handleLdapBindError($message, $code); 91 | } 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * Handle the LDAP bind error. 98 | * 99 | * @throws ValidationException 100 | */ 101 | protected function handleLdapBindError(string $message, ?string $code = null): void 102 | { 103 | logger()->error($message, compact('code')); 104 | 105 | ($callback = static::$bindErrorHandler) 106 | ? $callback($message, $code) 107 | : $this->throwLoginValidationException($message); 108 | } 109 | 110 | /** 111 | * Throw a login validation exception. 112 | * 113 | * @throws ValidationException 114 | */ 115 | protected function throwLoginValidationException(string $message): void 116 | { 117 | $username = 'email'; 118 | 119 | if (class_exists($fortify = 'Laravel\Fortify\Fortify')) { 120 | $username = $fortify::username(); 121 | } elseif (method_exists($this, 'username')) { 122 | $username = $this->username(); 123 | } elseif (property_exists($this, 'username')) { 124 | $username = $this->username; 125 | } 126 | 127 | throw ValidationException::withMessages([ 128 | $username => $message, 129 | ]); 130 | } 131 | 132 | /** 133 | * Determine if the LDAP error generated is caused by invalid credentials. 134 | */ 135 | protected function causedByInvalidCredentials(string $errorMessage, string $diagnosticMessage): bool 136 | { 137 | return $this->errorContainsMessage($errorMessage, 'Invalid credentials') 138 | && $this->errorContainsMessage($diagnosticMessage, '52e'); 139 | } 140 | 141 | /** 142 | * The LDAP diagnostic code error map. 143 | */ 144 | protected function ldapDiagnosticCodeErrorMap(): array 145 | { 146 | return [ 147 | '525' => trans('ldap::errors.user_not_found'), 148 | '530' => trans('ldap::errors.user_not_permitted_at_this_time'), 149 | '531' => trans('ldap::errors.user_not_permitted_to_login'), 150 | '532' => trans('ldap::errors.password_expired'), 151 | '533' => trans('ldap::errors.account_disabled'), 152 | '701' => trans('ldap::errors.account_expired'), 153 | '773' => trans('ldap::errors.user_must_reset_password'), 154 | '775' => trans('ldap::errors.user_account_locked'), 155 | ]; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Auth/NoDatabaseUserProvider.php: -------------------------------------------------------------------------------- 1 | users->findByGuid($identifier); 17 | } 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function retrieveByToken($identifier, $token): void 23 | { 24 | // We can't retrieve LDAP users via remember 25 | // token, as we have nowhere to store them. 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function updateRememberToken(Authenticatable $user, $token): void 32 | { 33 | // LDAP users cannot contain remember tokens. 34 | } 35 | 36 | /** 37 | * {@inheritdoc} 38 | */ 39 | public function retrieveByCredentials(array $credentials): ?Model 40 | { 41 | return $this->fetchLdapUserByCredentials($credentials); 42 | } 43 | 44 | /** 45 | * {@inheritdoc} 46 | */ 47 | public function validateCredentials(Authenticatable $user, array $credentials): bool 48 | { 49 | if (! $this->auth->attempt($user, $credentials['password'])) { 50 | return false; 51 | } 52 | 53 | event(new Completed($user)); 54 | 55 | return true; 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | */ 61 | public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false) 62 | { 63 | // We can't rehash LDAP users passwords. 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Auth/Rule.php: -------------------------------------------------------------------------------- 1 | exists; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Auth/UserProvider.php: -------------------------------------------------------------------------------- 1 | users = $users; 37 | $this->auth = $auth; 38 | 39 | $this->userResolver = function (array $credentials) { 40 | return $this->users->findByCredentials($credentials); 41 | }; 42 | } 43 | 44 | /** 45 | * Set the callback to resolve users by. 46 | */ 47 | public function resolveUsersUsing(Closure $callback): static 48 | { 49 | $this->userResolver = $callback; 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * Attempt to retrieve the user by their credentials. 56 | * 57 | * @throws ValidationException 58 | */ 59 | protected function fetchLdapUserByCredentials(array $credentials): ?Model 60 | { 61 | try { 62 | return call_user_func($this->userResolver, $credentials); 63 | } catch (Exception $e) { 64 | $this->handleException($e); 65 | 66 | return null; 67 | } 68 | } 69 | 70 | /** 71 | * Handle exceptions during user resolution. 72 | * 73 | * @throws ValidationException 74 | */ 75 | protected function handleException(Exception $e): void 76 | { 77 | if ($e instanceof ValidationException) { 78 | throw $e; 79 | } 80 | 81 | if (! LdapRecord::failingQuietly()) { 82 | throw $e; 83 | } 84 | 85 | report($e); 86 | } 87 | 88 | /** 89 | * Set the LDAP user repository. 90 | */ 91 | public function setLdapUserRepository(LdapUserRepository $users): void 92 | { 93 | $this->users = $users; 94 | } 95 | 96 | /** 97 | * Get the LDAP user repository. 98 | */ 99 | public function getLdapUserRepository(): LdapUserRepository 100 | { 101 | return $this->users; 102 | } 103 | 104 | /** 105 | * Get the LDAP user authenticator. 106 | */ 107 | public function getLdapUserAuthenticator(): LdapUserAuthenticator 108 | { 109 | return $this->auth; 110 | } 111 | 112 | /** 113 | * Set the LDAP user authenticator. 114 | */ 115 | public function setLdapUserAuthenticator(LdapUserAuthenticator $auth): void 116 | { 117 | $this->auth = $auth; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Auth/Validator.php: -------------------------------------------------------------------------------- 1 | addRule($rule); 26 | } 27 | } 28 | 29 | /** 30 | * Determine if all rules pass validation. 31 | */ 32 | public function passes(LdapRecord $user, ?Eloquent $model = null): bool 33 | { 34 | foreach ($this->rules as $rule) { 35 | if (! $rule->passes($user, $model)) { 36 | event(new RuleFailed($rule, $user, $model)); 37 | 38 | return false; 39 | } 40 | 41 | event(new RulePassed($rule, $user, $model)); 42 | } 43 | 44 | return true; 45 | } 46 | 47 | /** 48 | * Adds a rule to the validator. 49 | */ 50 | public function addRule(Rule $rule): void 51 | { 52 | $this->rules[] = $rule; 53 | } 54 | 55 | /** 56 | * Get the rules on the validator. 57 | * 58 | * @return Rule[] 59 | */ 60 | public function getRules(): array 61 | { 62 | return $this->rules; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Commands/GetRootDse.php: -------------------------------------------------------------------------------- 1 | argument('connection') ?? Container::getDefaultConnectionName(); 36 | 37 | $rootDse = Entry::getRootDse($connection); 38 | 39 | if ($selected = $this->option('attributes')) { 40 | $onlyAttributes = array_map('trim', explode(',', $selected)); 41 | } 42 | 43 | $attributes = isset($onlyAttributes) 44 | ? Arr::only($rootDse->getAttributes(), $onlyAttributes) 45 | : $rootDse->getAttributes(); 46 | 47 | if (! empty($attributes)) { 48 | foreach ($attributes as $attribute => $values) { 49 | $this->line("$attribute:"); 50 | 51 | array_map(function ($value) { 52 | $this->line(" $value"); 53 | }, $values); 54 | 55 | $this->line(''); 56 | } 57 | 58 | return static::SUCCESS; 59 | } 60 | 61 | if (isset($onlyAttributes)) { 62 | $this->error( 63 | sprintf('Attributes [%s] were not found in the Root DSE record.', implode(', ', $onlyAttributes)) 64 | ); 65 | } else { 66 | $this->error('No attributes were returned from the Root DSE query.'); 67 | } 68 | 69 | return static::FAILURE; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Commands/MakeLdapModel.php: -------------------------------------------------------------------------------- 1 | argument('connection')) { 35 | $connections = [$connection => Container::getConnection($connection)]; 36 | } else { 37 | $connections = Container::getInstance()->getConnections(); 38 | } 39 | 40 | if (empty($connections)) { 41 | $this->error('No LDAP connections have been defined.'); 42 | 43 | return static::INVALID; 44 | } 45 | 46 | $tested = []; 47 | $connected = []; 48 | 49 | foreach ($connections as $name => $connection) { 50 | [,$connected[]] = $tested[] = $this->performTest($name, $connection); 51 | } 52 | 53 | $this->table(['Connection', 'Successful', 'Username', 'Message', 'Response Time'], $tested); 54 | 55 | return in_array('✘ No', $connected) ? static::FAILURE : static::SUCCESS; 56 | } 57 | 58 | /** 59 | * Perform a connectivity test on the given connection. 60 | */ 61 | protected function performTest(string $name, Connection $connection): array 62 | { 63 | $this->info("Testing LDAP connection [$name]..."); 64 | 65 | $start = microtime(true); 66 | 67 | $message = $this->attempt($connection); 68 | 69 | return [ 70 | $name, 71 | $connection->isConnected() ? '✔ Yes' : '✘ No', 72 | $connection->getConfiguration()->get('username'), 73 | $message, 74 | (app()->runningUnitTests() ? '0' : $this->getElapsedTime($start)).'ms', 75 | ]; 76 | } 77 | 78 | /** 79 | * Attempt establishing the connection. 80 | */ 81 | protected function attempt(Connection $connection): string 82 | { 83 | try { 84 | $connection->connect(); 85 | 86 | $message = 'Successfully connected.'; 87 | } catch (BindException $e) { 88 | $detailedError = optional($e->getDetailedError()); 89 | 90 | $errorCode = $detailedError->getErrorCode(); 91 | $diagnosticMessage = $detailedError->getDiagnosticMessage(); 92 | 93 | $message = sprintf( 94 | '%s. Error Code: [%s] Diagnostic Message: %s', 95 | $e->getMessage(), 96 | $errorCode ?? 'NULL', 97 | $diagnosticMessage ?? 'NULL' 98 | ); 99 | } catch (Exception $e) { 100 | $message = sprintf( 101 | '%s. Error Code: [%s]', 102 | $e->getMessage(), 103 | $e->getCode() 104 | ); 105 | } 106 | 107 | return $message; 108 | } 109 | 110 | /** 111 | * Get the elapsed time since a given starting point. 112 | */ 113 | protected function getElapsedTime(int $start): int|float 114 | { 115 | return round((microtime(true) - $start) * 1000, 2); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Commands/stubs/model.stub: -------------------------------------------------------------------------------- 1 | object->getName()}] has failed LDAP authentication."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Auth/Binding.php: -------------------------------------------------------------------------------- 1 | object->getName()}] is authenticating."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Auth/Bound.php: -------------------------------------------------------------------------------- 1 | object->getName()}] has successfully passed LDAP authentication."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Auth/Completed.php: -------------------------------------------------------------------------------- 1 | object->getName()}] has successfully authenticated."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Auth/CompletedWithWindows.php: -------------------------------------------------------------------------------- 1 | object->getName()}] has successfully authenticated via NTLM."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Auth/DiscoveredWithCredentials.php: -------------------------------------------------------------------------------- 1 | user = $user; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function getLogMessage(): string 30 | { 31 | return "User [{$this->user->getName()}] has been successfully discovered for authentication."; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Events/Auth/EloquentUserTrashed.php: -------------------------------------------------------------------------------- 1 | object->getName()}] was denied authentication because their model is soft-deleted."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Auth/Event.php: -------------------------------------------------------------------------------- 1 | object = $object; 26 | $this->eloquent = $eloquent; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Events/Auth/Rejected.php: -------------------------------------------------------------------------------- 1 | object->getName()}] has failed validation. They have been denied authentication."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Auth/RuleEvent.php: -------------------------------------------------------------------------------- 1 | rule = $rule; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Events/Auth/RuleFailed.php: -------------------------------------------------------------------------------- 1 | objects = $objects; 31 | $this->imported = $imported; 32 | } 33 | 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function getLogMessage(): string 38 | { 39 | $imported = $this->imported->filter(function (Model $eloquent) { 40 | return $eloquent->wasRecentlyCreated; 41 | })->count(); 42 | 43 | $synchronized = $this->imported->count() - $imported; 44 | 45 | return "Completed import. Imported [$imported] new LDAP objects. Synchronized [$synchronized] existing LDAP objects."; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Events/Import/Deleted.php: -------------------------------------------------------------------------------- 1 | ldapModel = $ldapModel; 36 | $this->eloquentModel = $eloquentModel; 37 | $this->deleted = $deleted; 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | public function getLogMessage(): string 44 | { 45 | $guids = $this->deleted->values()->implode(', '); 46 | 47 | return "Users with guids [$guids] have been soft-deleted due to being missing from LDAP result."; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Events/Import/Event.php: -------------------------------------------------------------------------------- 1 | object = $object; 26 | $this->eloquent = $eloquent; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Events/Import/ImportFailed.php: -------------------------------------------------------------------------------- 1 | exception = $exception; 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function getLogLevel(): string 34 | { 35 | return 'error'; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function getLogMessage(): string 42 | { 43 | return "Failed importing object [{$this->object->getName()}]. {$this->exception->getMessage()}"; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Events/Import/Imported.php: -------------------------------------------------------------------------------- 1 | object->getName()}] has been successfully imported."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Import/Importing.php: -------------------------------------------------------------------------------- 1 | object->getName()}] is being imported."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Import/Restored.php: -------------------------------------------------------------------------------- 1 | objects = $objects; 24 | } 25 | 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function getLogMessage(): string 30 | { 31 | return "Starting import of [{$this->objects->count()}] LDAP objects."; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Events/Import/Synchronized.php: -------------------------------------------------------------------------------- 1 | object->getName()}] has been successfully synchronized."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Import/Synchronizing.php: -------------------------------------------------------------------------------- 1 | object->getName()}] is being synchronized."; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Events/Loggable.php: -------------------------------------------------------------------------------- 1 | config = $config; 35 | } 36 | 37 | /** 38 | * Extra data to pass to each hydrator. 39 | */ 40 | public function with(array $data = []): static 41 | { 42 | $this->data = $data; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Hydrate the database model with the LDAP user. 49 | */ 50 | public function hydrate(LdapModel $user, EloquentModel $database): void 51 | { 52 | foreach ($this->hydrators as $hydrator) { 53 | $hydrator::with($this->config, $this->data)->hydrate($user, $database); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Import/EloquentUserHydrator.php: -------------------------------------------------------------------------------- 1 | getSyncAttributes() as $eloquentField => $ldapField) { 18 | if (! $this->isAttributeHandler($ldapField)) { 19 | $eloquent->{$eloquentField} = is_string($ldapField) 20 | ? $object->getFirstAttribute($ldapField) 21 | : $ldapField; 22 | 23 | continue; 24 | } 25 | 26 | if ($ldapField instanceof Closure) { 27 | $ldapField($object, $eloquent); 28 | 29 | continue; 30 | } 31 | 32 | if (is_callable($handler = app($ldapField))) { 33 | $handler($object, $eloquent); 34 | 35 | continue; 36 | } 37 | 38 | $handler->handle($object, $eloquent); 39 | } 40 | } 41 | 42 | /** 43 | * Get the database sync attributes. 44 | */ 45 | protected function getSyncAttributes(): array 46 | { 47 | return (array) Arr::get( 48 | $this->config, 49 | 'sync_attributes', 50 | ['name' => 'cn', 'email' => 'mail'] 51 | ); 52 | } 53 | 54 | /** 55 | * Determines if the given value is an attribute handler. 56 | */ 57 | protected function isAttributeHandler(mixed $value): bool 58 | { 59 | if ($value instanceof Closure) { 60 | return true; 61 | } 62 | 63 | return class_exists($value) && (method_exists($value, '__invoke') || method_exists($value, 'handle')); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Import/Hydrators/DomainHydrator.php: -------------------------------------------------------------------------------- 1 | setLdapDomain($object->getConnectionName() ?? Config::get('ldap.default')); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Import/Hydrators/GuidHydrator.php: -------------------------------------------------------------------------------- 1 | setLdapGuid($object->getConvertedGuid()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Import/Hydrators/Hydrator.php: -------------------------------------------------------------------------------- 1 | config = $config; 26 | $this->data = $data; 27 | } 28 | 29 | /** 30 | * Create a new hydrator instance. 31 | */ 32 | public static function with(array $config = [], array $data = []): static 33 | { 34 | return new static($config, $data); 35 | } 36 | 37 | /** 38 | * Hydrate the database model with the LDAP user. 39 | */ 40 | abstract public function hydrate(LdapModel $object, EloquentModel $eloquent): void; 41 | } 42 | -------------------------------------------------------------------------------- /src/Import/Hydrators/PasswordHydrator.php: -------------------------------------------------------------------------------- 1 | hasPasswordColumn()) { 19 | return; 20 | } 21 | 22 | $password = $this->getPassword() ?? Str::random(); 23 | 24 | if (! $this->isSyncingPasswords()) { 25 | $password = Str::random(); 26 | } 27 | 28 | $column = method_exists($eloquent, 'getAuthPasswordName') 29 | ? $eloquent->getAuthPasswordName() 30 | : $this->getPasswordColumn(); 31 | 32 | if ($this->passwordNeedsUpdate($eloquent, $column, $password)) { 33 | $this->setPassword($eloquent, $column, $password); 34 | } 35 | } 36 | 37 | /** 38 | * Set the password on the users model. 39 | */ 40 | protected function setPassword(EloquentModel $model, string $column, string $password): void 41 | { 42 | // If the model has a mutator for the password field, we 43 | // can assume hashing passwords is taken care of. 44 | // Otherwise, we will hash it normally. 45 | $password = $model->hasSetMutator($column) 46 | ? $password 47 | : Hash::make($password); 48 | 49 | $model->setAttribute($column, $password); 50 | } 51 | 52 | /** 53 | * Determine if the current model requires a password update. 54 | * 55 | * This checks if the model does not currently have a 56 | * password, or if the password fails a hash check. 57 | */ 58 | protected function passwordNeedsUpdate(EloquentModel $model, string $column, ?string $password = null): bool 59 | { 60 | $current = $this->getCurrentModelPassword($model, $column); 61 | 62 | // If the application is running in console, we will assume the 63 | // import command is being run. In this case, we do not want 64 | // to overwrite a password that's already properly hashed. 65 | if (app()->runningInConsole() && ! Hash::needsRehash((string) $current)) { 66 | return false; 67 | } 68 | 69 | // If the eloquent model contains a password and password sync is 70 | // enabled, we will check the integrity of the given password 71 | // against it to determine if it should be updated. 72 | if (! is_null($current) && $this->isSyncingPasswords()) { 73 | return ! Hash::check($password, $current); 74 | } 75 | 76 | return is_null($current); 77 | } 78 | 79 | /** 80 | * Determines if the developer has configured a password column. 81 | */ 82 | protected function hasPasswordColumn(): bool 83 | { 84 | return $this->getPasswordColumn() !== false; 85 | } 86 | 87 | /** 88 | * Get the current models hashed password. 89 | */ 90 | protected function getCurrentModelPassword(EloquentModel $model, string $column): ?string 91 | { 92 | return $model->getAttribute($column); 93 | } 94 | 95 | /** 96 | * Get the password from the current data. 97 | */ 98 | protected function getPassword(): ?string 99 | { 100 | return Arr::get($this->data, 'password'); 101 | } 102 | 103 | /** 104 | * Get the configured database password column to use. 105 | * 106 | * @return string|false 107 | */ 108 | protected function getPasswordColumn(): bool|string 109 | { 110 | return Arr::get($this->config, 'password_column', 'password'); 111 | } 112 | 113 | /** 114 | * Determine whether password sync is enabled. 115 | */ 116 | protected function isSyncingPasswords(): bool 117 | { 118 | return Arr::get($this->config, 'sync_passwords', false); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Import/ImportException.php: -------------------------------------------------------------------------------- 1 | getGuidKey(), 19 | get_class($model), 20 | $model->getDn() 21 | ) 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Import/LdapUserImporter.php: -------------------------------------------------------------------------------- 1 | object instanceof ActiveDirectory) { 43 | return; 44 | } 45 | 46 | if (! $this->isUsingSoftDeletes($event->eloquent)) { 47 | return; 48 | } 49 | 50 | if ($this->trashDisabledUsers) { 51 | $this->delete($event->object, $event->eloquent); 52 | } 53 | 54 | if ($this->restoreEnabledUsers) { 55 | $this->restore($event->object, $event->eloquent); 56 | } 57 | }); 58 | } 59 | 60 | /** 61 | * Set the LDAP user repository to use for importing. 62 | */ 63 | public function setLdapUserRepository(LdapUserRepository $repository): static 64 | { 65 | $this->repository = $repository; 66 | 67 | return $this; 68 | } 69 | 70 | /** 71 | * Enable restoring enabled users. 72 | */ 73 | public function restoreEnabledUsers(): static 74 | { 75 | $this->restoreEnabledUsers = true; 76 | 77 | return $this; 78 | } 79 | 80 | /** 81 | * Enable trashing disabled users. 82 | */ 83 | public function trashDisabledUsers(): static 84 | { 85 | $this->trashDisabledUsers = true; 86 | 87 | return $this; 88 | } 89 | 90 | /** 91 | * Load the import's objects from the LDAP repository. 92 | */ 93 | public function loadObjectsFromRepository(?string $username = null): Collection 94 | { 95 | $query = $this->applyLdapQueryConstraints( 96 | $this->repository->query() 97 | ); 98 | 99 | if (! $username) { 100 | return $this->objects = $query->paginate(); 101 | } 102 | 103 | $users = $query->getModel()->newCollection(); 104 | 105 | return $this->objects = ($user = $query->findByAnr($username)) 106 | ? $users->add($user) 107 | : $users; 108 | } 109 | 110 | /** 111 | * Load the import's objects from the LDAP repository via chunking. 112 | */ 113 | public function chunkObjectsFromRepository(Closure $callback, int $perChunk = 500): void 114 | { 115 | $query = $this->applyLdapQueryConstraints( 116 | $this->repository->query() 117 | ); 118 | 119 | $query->chunk($perChunk, function ($objects) use ($callback) { 120 | $callback($this->objects = $objects); 121 | }); 122 | } 123 | 124 | /** 125 | * Soft deletes the specified model if their LDAP account is disabled. 126 | */ 127 | protected function delete(LdapRecord $object, Eloquent $eloquent): void 128 | { 129 | if ($eloquent->trashed()) { 130 | return; 131 | } 132 | 133 | if (! $this->userIsDisabled($object)) { 134 | return; 135 | } 136 | 137 | $eloquent->delete(); 138 | 139 | event(new Deleted($object, $eloquent)); 140 | } 141 | 142 | /** 143 | * Restores soft-deleted models if their LDAP account is enabled. 144 | */ 145 | protected function restore(LdapRecord $object, Eloquent $eloquent): void 146 | { 147 | if (! $eloquent->trashed()) { 148 | return; 149 | } 150 | 151 | if (! $this->userIsEnabled($object)) { 152 | return; 153 | } 154 | 155 | $eloquent->restore(); 156 | 157 | event(new Restored($object, $eloquent)); 158 | } 159 | 160 | /** 161 | * Determine whether the user is enabled. 162 | */ 163 | protected function userIsEnabled(LdapRecord $object): bool 164 | { 165 | return $this->getUserAccountControl($object) === null ? false : ! $this->userIsDisabled($object); 166 | } 167 | 168 | /** 169 | * Determines whether the user is disabled. 170 | */ 171 | protected function userIsDisabled(LdapRecord $object): bool 172 | { 173 | return ($this->getUserAccountControl($object) & AccountControl::ACCOUNTDISABLE) === AccountControl::ACCOUNTDISABLE; 174 | } 175 | 176 | /** 177 | * Get the user account control integer from the user. 178 | */ 179 | protected function getUserAccountControl(LdapRecord $object): ?int 180 | { 181 | return $object->getFirstAttribute('userAccountControl'); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/Import/UserSynchronizer.php: -------------------------------------------------------------------------------- 1 | {$this->getLdapDomainColumn()}; 21 | } 22 | 23 | /** 24 | * Set the models LDAP domain. 25 | */ 26 | public function setLdapDomain(?string $domain): void 27 | { 28 | $this->{$this->getLdapDomainColumn()} = $domain; 29 | } 30 | 31 | /** 32 | * Get the models LDAP GUID database column name. 33 | */ 34 | public function getLdapGuidColumn(): string 35 | { 36 | return 'guid'; 37 | } 38 | 39 | /** 40 | * Get the models LDAP GUID. 41 | */ 42 | public function getLdapGuid(): ?string 43 | { 44 | return $this->{$this->getLdapGuidColumn()}; 45 | } 46 | 47 | /** 48 | * Set the models LDAP GUID. 49 | */ 50 | public function setLdapGuid(?string $guid): void 51 | { 52 | $this->{$this->getLdapGuidColumn()} = $guid; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/LdapAuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadTranslationsFrom(__DIR__.'/../resources/lang/', 'ldap'); 22 | 23 | $this->registerCommands(); 24 | $this->registerMigrations(); 25 | $this->registerAuthProvider(); 26 | $this->registerLoginControllerListeners(); 27 | } 28 | 29 | /** 30 | * Register the LDAP auth commands. 31 | */ 32 | protected function registerCommands(): void 33 | { 34 | $this->commands([ImportLdapUsers::class]); 35 | } 36 | 37 | /** 38 | * Register the LDAP auth migrations. 39 | */ 40 | protected function registerMigrations(): void 41 | { 42 | if (! $this->app->runningInConsole()) { 43 | return; 44 | } 45 | 46 | $this->publishes([ 47 | __DIR__.'/../database/migrations' => database_path('migrations'), 48 | ], 'migrations'); 49 | } 50 | 51 | /** 52 | * Register the LDAP auth provider. 53 | */ 54 | protected function registerAuthProvider(): void 55 | { 56 | Auth::provider('ldap', function ($app, array $config) { 57 | return array_key_exists('database', $config) 58 | ? $this->makeDatabaseUserProvider($config) 59 | : $this->makePlainUserProvider($config); 60 | }); 61 | } 62 | 63 | /** 64 | * Registers the login controller listener to handle LDAP errors. 65 | */ 66 | protected function registerLoginControllerListeners(): void 67 | { 68 | BindFailureListener::usingLaravelUi(); 69 | BindFailureListener::usingLaravelJetstream(); 70 | } 71 | 72 | /** 73 | * Get a new database user provider. 74 | */ 75 | protected function makeDatabaseUserProvider(array $config): DatabaseUserProvider 76 | { 77 | return app(DatabaseUserProvider::class, [ 78 | 'users' => $this->makeLdapUserRepository($config), 79 | 'auth' => $this->makeLdapUserAuthenticator($config), 80 | 'eloquent' => $this->makeEloquentUserProvider($config), 81 | 'synchronizer' => $this->makeLdapUserSynchronizer($config['database']), 82 | ]); 83 | } 84 | 85 | /** 86 | * Make a new plain LDAP user provider. 87 | */ 88 | protected function makePlainUserProvider(array $config): NoDatabaseUserProvider 89 | { 90 | return app(NoDatabaseUserProvider::class, [ 91 | 'users' => $this->makeLdapUserRepository($config), 92 | 'auth' => $this->makeLdapUserAuthenticator($config), 93 | ]); 94 | } 95 | 96 | /** 97 | * Make a new Eloquent user provider. 98 | */ 99 | protected function makeEloquentUserProvider(array $config): EloquentUserProvider 100 | { 101 | return app(EloquentUserProvider::class, [ 102 | 'hasher' => $this->app->make('hash'), 103 | 'model' => $config['database']['model'], 104 | ]); 105 | } 106 | 107 | /** 108 | * Make a new LDAP user authenticator. 109 | */ 110 | protected function makeLdapUserAuthenticator(array $config): LdapUserAuthenticator 111 | { 112 | return app(LdapUserAuthenticator::class, ['rules' => $config['rules'] ?? []]); 113 | } 114 | 115 | /** 116 | * Make a new LDAP user repository. 117 | */ 118 | protected function makeLdapUserRepository(array $config): LdapUserRepository 119 | { 120 | return app(LdapUserRepository::class, [ 121 | 'model' => $config['model'], 122 | 'scopes' => $config['scopes'] ?? [], 123 | ]); 124 | } 125 | 126 | /** 127 | * Make a new LDAP user importer. 128 | */ 129 | protected function makeLdapUserSynchronizer(array $config): UserSynchronizer 130 | { 131 | return app(UserSynchronizer::class, [ 132 | 'eloquentModel' => $config['model'], 133 | 'config' => $config, 134 | ]); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/LdapImportable.php: -------------------------------------------------------------------------------- 1 | bind(LdapUserAuthenticator::class, $class); 37 | } 38 | 39 | /** 40 | * Register a class that should be used for locating LDAP users. 41 | */ 42 | public static function locateUsersUsing(Closure|string $class): void 43 | { 44 | app()->bind(LdapUserRepository::class, $class); 45 | } 46 | 47 | /** 48 | * Register a class that should be used for synchronizing LDAP users. 49 | */ 50 | public static function synchronizeUsersUsing(Closure|string $class): void 51 | { 52 | app()->bind(UserSynchronizer::class, $class); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/LdapUserAuthenticator.php: -------------------------------------------------------------------------------- 1 | rules = $rules; 43 | 44 | $this->authenticator = function (Model $user, $password) { 45 | return $user->getConnection()->auth()->attempt($user->getDn(), $password); 46 | }; 47 | } 48 | 49 | /** 50 | * Set the authenticating eloquent model. 51 | */ 52 | public function setEloquentModel(?Eloquent $model = null): static 53 | { 54 | $this->eloquentModel = $model; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Attempt authenticating against the LDAP domain. 61 | */ 62 | public function attempt(Model $user, ?string $password = null): bool 63 | { 64 | $this->attempting($user); 65 | 66 | if ($this->databaseModelIsTrashed()) { 67 | $this->trashed($user); 68 | 69 | return false; 70 | } 71 | 72 | // Here we will attempt to bind the authenticating LDAP 73 | // user to our connection to ensure their password is 74 | // correct, using the defined authenticator closure. 75 | if (! call_user_func($this->authenticator, $user, $password)) { 76 | $this->failed($user); 77 | 78 | return false; 79 | } 80 | 81 | // Now we will perform authorization on the LDAP user. If all 82 | // validation rules pass, we will allow the authentication 83 | // attempt. Otherwise, it is automatically rejected. 84 | if (! $this->validate($user)) { 85 | $this->rejected($user); 86 | 87 | return false; 88 | } 89 | 90 | $this->passed($user); 91 | 92 | return true; 93 | } 94 | 95 | /** 96 | * Attempt authentication using the given callback once. 97 | */ 98 | public function attemptOnceUsing(Closure $callback, Model $user, ?string $password = null): bool 99 | { 100 | $authenticator = $this->authenticator; 101 | 102 | $result = $this->authenticateUsing($callback)->attempt($user, $password); 103 | 104 | $this->authenticator = $authenticator; 105 | 106 | return $result; 107 | } 108 | 109 | /** 110 | * Set the callback to use for authenticating users. 111 | */ 112 | public function authenticateUsing(Closure $authenticator): static 113 | { 114 | $this->authenticator = $authenticator; 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * Validate the given user against the authentication rules. 121 | */ 122 | protected function validate(Model $user): bool 123 | { 124 | return $this->validator()->passes($user, $this->eloquentModel); 125 | } 126 | 127 | /** 128 | * Create a new user validator. 129 | */ 130 | protected function validator(): Validator 131 | { 132 | return app(Validator::class, ['rules' => $this->rules()]); 133 | } 134 | 135 | /** 136 | * Get the authentication rules for the domain. 137 | */ 138 | protected function rules(): Collection 139 | { 140 | return collect($this->rules)->map(fn ($rule) => app($rule))->values(); 141 | } 142 | 143 | /** 144 | * Fire the "attempting" event. 145 | */ 146 | protected function attempting(Model $user): void 147 | { 148 | event(new Binding($user, $this->eloquentModel)); 149 | } 150 | 151 | /** 152 | * Fire the "passed" event. 153 | */ 154 | protected function passed(Model $user): void 155 | { 156 | event(new Bound($user, $this->eloquentModel)); 157 | } 158 | 159 | /** 160 | * Fire the "trashed" event. 161 | */ 162 | protected function trashed(Model $user): void 163 | { 164 | event(new EloquentUserTrashed($user, $this->eloquentModel)); 165 | } 166 | 167 | /** 168 | * Fire the "failed" event. 169 | */ 170 | protected function failed(Model $user): void 171 | { 172 | event(new BindFailed($user, $this->eloquentModel)); 173 | } 174 | 175 | /** 176 | * Fire the "rejected" event. 177 | */ 178 | protected function rejected(Model $user): void 179 | { 180 | event(new Rejected($user, $this->eloquentModel)); 181 | } 182 | 183 | /** 184 | * Determine if the database model is trashed. 185 | */ 186 | protected function databaseModelIsTrashed(): bool 187 | { 188 | return isset($this->eloquentModel) 189 | && $this->isUsingSoftDeletes($this->eloquentModel) 190 | && $this->eloquentModel->trashed(); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/LdapUserRepository.php: -------------------------------------------------------------------------------- 1 | model = $model; 37 | $this->scopes = $scopes; 38 | } 39 | 40 | /** 41 | * Get a user by the attribute and value. 42 | */ 43 | public function findBy(string $attribute, mixed $value): ?Model 44 | { 45 | return $this->query()->findBy($attribute, $value); 46 | } 47 | 48 | /** 49 | * Get an LDAP user by their eloquent model. 50 | */ 51 | public function findByModel(LdapAuthenticatable $model): ?Model 52 | { 53 | if (empty($guid = $model->getLdapGuid())) { 54 | return null; 55 | } 56 | 57 | return $this->findByGuid($guid); 58 | } 59 | 60 | /** 61 | * Get a user by their object GUID. 62 | */ 63 | public function findByGuid(string $guid): ?Model 64 | { 65 | return $this->query()->findByGuid($guid); 66 | } 67 | 68 | /** 69 | * Retrieve a user by the given credentials. 70 | */ 71 | public function findByCredentials(array $credentials = []): ?Model 72 | { 73 | if (empty($credentials)) { 74 | return null; 75 | } 76 | 77 | $query = $this->query(); 78 | 79 | foreach ($credentials as $key => $value) { 80 | if (Str::contains($key, $this->bypassCredentialKeys)) { 81 | continue; 82 | } 83 | 84 | if (is_array($value) || $value instanceof Arrayable) { 85 | $query->whereIn($key, $value); 86 | } else { 87 | $query->where($key, $value); 88 | } 89 | } 90 | 91 | if (is_null($user = $query->first())) { 92 | return null; 93 | } 94 | 95 | event(new DiscoveredWithCredentials($user)); 96 | 97 | return $user; 98 | } 99 | 100 | /** 101 | * Get a new model query. 102 | */ 103 | public function query(): Builder 104 | { 105 | $this->applyScopes( 106 | $query = $this->newModelQuery() 107 | ); 108 | 109 | return $query; 110 | } 111 | 112 | /** 113 | * Set the scopes to apply on the query. 114 | */ 115 | public function setScopes(array $scopes): static 116 | { 117 | $this->scopes = $scopes; 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * Apply the configured scopes to the query. 124 | */ 125 | protected function applyScopes(Builder $query): void 126 | { 127 | foreach ($this->scopes as $identifier => $scope) { 128 | match (true) { 129 | is_string($scope) => $query->withGlobalScope($scope, app($scope)), 130 | is_callable($scope) => $query->withGlobalScope($identifier, $scope), 131 | is_object($scope) => $query->withGlobalScope(get_class($scope), $scope), 132 | }; 133 | } 134 | } 135 | 136 | /** 137 | * Create a new instance of the LdapRecord model. 138 | */ 139 | public function createModel(): Model 140 | { 141 | $class = '\\'.ltrim($this->model, '\\'); 142 | 143 | return new $class; 144 | } 145 | 146 | /** 147 | * Gets the name of the LdapRecord user model. 148 | * 149 | * @return class-string 150 | */ 151 | public function getModel(): string 152 | { 153 | return $this->model; 154 | } 155 | 156 | /** 157 | * Get a new query builder for the model instance. 158 | */ 159 | protected function newModelQuery(?Model $model = null): Builder 160 | { 161 | $model = is_null($model) ? $this->createModel() : $model; 162 | 163 | $query = $model->newQuery(); 164 | 165 | // We will ensure our object GUID attribute is always selected 166 | // along will all attributes. Otherwise, if the object GUID 167 | // attribute is virtual, it may not be returned in results. 168 | $selects = array_merge(['*', $model->getGuidKey()], $query->getSelects()); 169 | 170 | return $query->select(array_unique($selects)); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Middleware/UserDomainValidator.php: -------------------------------------------------------------------------------- 1 | getDomainComponents($user->getDn())) { 20 | return false; 21 | } 22 | 23 | return $this->domainExistsInComponents($domain, $components); 24 | } 25 | 26 | /** 27 | * Get the domain components from the Distinguished Name. 28 | */ 29 | protected function getDomainComponents(string $dn): array 30 | { 31 | return DistinguishedName::build($dn)->components('dc'); 32 | } 33 | 34 | /** 35 | * Determine if the domain exists in the given components. 36 | */ 37 | protected function domainExistsInComponents(string $domain, array $components): bool 38 | { 39 | return collect($components)->map(function ($component) { 40 | [,$value] = $component; 41 | 42 | return strtolower($value); 43 | })->contains(strtolower($domain)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Testing/DirectoryEmulator.php: -------------------------------------------------------------------------------- 1 | name($name); 21 | 22 | app(LdapDatabaseManager::class)->connection($name, $config); 23 | }); 24 | } 25 | 26 | /** 27 | * Make a fake connection. 28 | */ 29 | public static function makeConnectionFake(array $config = []): EmulatedConnectionFake 30 | { 31 | return EmulatedConnectionFake::make($config)->shouldBeConnected(); 32 | } 33 | 34 | /** 35 | * Tear down the fake directory. 36 | */ 37 | public static function tearDown(): void 38 | { 39 | app(LdapDatabaseManager::class)->teardown(); 40 | 41 | parent::tearDown(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Testing/Emulated/ActiveDirectoryBuilder.php: -------------------------------------------------------------------------------- 1 | connection)) 24 | ->setModel($this->model) 25 | ->setBaseDn($baseDn); 26 | } 27 | 28 | /** 29 | * {@inheritdoc} 30 | */ 31 | public function add(string $dn, array $attributes): bool 32 | { 33 | if (! $model = $this->find($dn)) { 34 | return false; 35 | } 36 | 37 | foreach ($attributes as $name => $values) { 38 | $model->{$name} = array_merge($model->{$name} ?? [], Arr::wrap($values)); 39 | } 40 | 41 | $model->save(); 42 | 43 | return true; 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function update(string $dn, array $modifications): bool 50 | { 51 | if (! $model = $this->findEloquentModelByDn($dn)) { 52 | return false; 53 | } 54 | 55 | foreach ($modifications as $modification) { 56 | $this->applyBatchModificationToModel($model, $modification); 57 | } 58 | 59 | return true; 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | public function replace(string $dn, array $attributes): bool 66 | { 67 | if (! $model = $this->find($dn)) { 68 | return false; 69 | } 70 | 71 | foreach ($attributes as $name => $values) { 72 | $model->{$name} = $values; 73 | } 74 | 75 | $model->save(); 76 | 77 | return true; 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | */ 83 | public function remove(string $dn, array $attributes): bool 84 | { 85 | if (! $model = $this->find($dn)) { 86 | return false; 87 | } 88 | 89 | foreach ($attributes as $attribute => $value) { 90 | if (empty($value)) { 91 | $model->{$attribute} = null; 92 | } elseif ($model->hasAttribute($attribute)) { 93 | $model->{$attribute} = array_values( 94 | array_diff($model->{$attribute}, (array) $value) 95 | ); 96 | } 97 | } 98 | 99 | $model->save(); 100 | 101 | return true; 102 | } 103 | 104 | /** 105 | * Parse the database query results. 106 | * 107 | * @param \Illuminate\Database\Eloquent\Collection $resource 108 | */ 109 | public function parse(mixed $resource): array 110 | { 111 | return $resource->toArray(); 112 | } 113 | 114 | /** 115 | * {@inheritdoc} 116 | */ 117 | protected function process(array $results): Collection 118 | { 119 | return $this->model->newCollection($results)->transform(function ($result) { 120 | return $this->resultToModelInstance($result); 121 | }); 122 | } 123 | 124 | /** 125 | * Transform the result into a model instance. 126 | */ 127 | protected function resultToModelInstance($result): Model 128 | { 129 | if ($result instanceof $this->model) { 130 | return $result; 131 | } 132 | 133 | return $this->model->newInstance() 134 | ->setDn($result['dn']) 135 | ->setRawAttributes( 136 | array_merge( 137 | $this->transform($result), 138 | [$result['guid_key'] => [$result['guid']]] 139 | ) 140 | ); 141 | } 142 | 143 | /** 144 | * {@inheritdoc} 145 | */ 146 | public function findOrFail(string $dn, array|string $columns = ['*']): Model 147 | { 148 | return $this->baseFindOrFail($dn, $columns); 149 | } 150 | 151 | /** 152 | * {@inheritdoc} 153 | */ 154 | protected function addFilterToDatabaseQuery(Builder $query, string $field, string $operator, ?string $value): void 155 | { 156 | if ($field === 'anr') { 157 | $query->whereIn('name', $this->model->getAnrAttributes()) 158 | ->whereHas('values', function ($query) use ($value) { 159 | $query->where('value', 'like', "%$value%"); 160 | }); 161 | 162 | return; 163 | } 164 | 165 | $this->baseAddFilterToDatabaseQuery($query, $field, $operator, $value); 166 | } 167 | 168 | /** 169 | * Override the possibility of the underlying LDAP model being compatible with ANR. 170 | */ 171 | protected function modelIsCompatibleWithAnr(): bool 172 | { 173 | return true; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Testing/Emulated/ModelBuilder.php: -------------------------------------------------------------------------------- 1 | determineBuilderFromModel($model); 24 | 25 | return (new $builder($this->connection)) 26 | ->setBaseDn($this->baseDn) 27 | ->setModel($model); 28 | } 29 | 30 | /** 31 | * Determine the query builder to use for the model. 32 | */ 33 | protected function determineBuilderFromModel(Model $model): string 34 | { 35 | switch (true) { 36 | case $model instanceof ActiveDirectory: 37 | return Emulated\ActiveDirectoryBuilder::class; 38 | case $model instanceof OpenLDAP: 39 | return Emulated\OpenLdapBuilder::class; 40 | default: 41 | return Emulated\ModelBuilder::class; 42 | } 43 | } 44 | 45 | /** 46 | * Process the database query results into an LDAP result set. 47 | */ 48 | protected function process(array $results): array 49 | { 50 | return array_map([$this, 'mergeAttributesAndTransformResult'], $results); 51 | } 52 | 53 | /** 54 | * Merge and transform the result. 55 | */ 56 | protected function mergeAttributesAndTransformResult(array $result): array 57 | { 58 | return array_merge( 59 | $this->transform($result), 60 | $this->retrieveExtraAttributes($result) 61 | ); 62 | } 63 | 64 | /** 65 | * Retrieve extra attributes that should be merged with the result. 66 | */ 67 | protected function retrieveExtraAttributes(array $result): array 68 | { 69 | $attributes = array_filter(['dn', $result['guid_key'] ?? null]); 70 | 71 | return array_map(function ($value) { 72 | return Arr::wrap($value); 73 | }, Arr::only($result, $attributes)); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Testing/EmulatedConnectionFake.php: -------------------------------------------------------------------------------- 1 | name; 21 | } 22 | 23 | $this->name = $name; 24 | 25 | return $this; 26 | } 27 | 28 | /** 29 | * Create a new Eloquent LDAP query builder. 30 | * 31 | * @throws \LdapRecord\Configuration\ConfigurationException 32 | */ 33 | public function query(): EmulatedBuilder 34 | { 35 | return (new EmulatedBuilder($this))->setBaseDn( 36 | $this->configuration->get('base_dn') 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Testing/LdapDatabaseManager.php: -------------------------------------------------------------------------------- 1 | db = $db; 55 | } 56 | 57 | /** 58 | * Create the eloquent database model. 59 | */ 60 | public function createModel(?string $connection = null): Model 61 | { 62 | $class = '\\'.ltrim(static::$model, '\\'); 63 | 64 | return tap(new $class)->setConnection($connection); 65 | } 66 | 67 | /** 68 | * Get the LDAP database connection. 69 | */ 70 | public function connection(?string $name = null, array $config = []): Connection 71 | { 72 | $name = $name ?? Config::get('ldap.default', 'default'); 73 | 74 | $this->connections[$name] = $this->makeConnection( 75 | $this->makeDatabaseConnectionName($name), 76 | Arr::get($config, 'database', ':memory:') 77 | ); 78 | 79 | return $this->connections[$name]; 80 | } 81 | 82 | /** 83 | * Make the database connection. 84 | */ 85 | protected function makeConnection(string $name, string $database): Connection 86 | { 87 | // If we're not working with an in-memory database, 88 | // we'll assume a file path has been given and 89 | // create it before we run the migrations. 90 | if ($database !== ':memory:' && ! file_exists($database)) { 91 | file_put_contents($database, ''); 92 | } 93 | 94 | app('config')->set("database.connections.$name", [ 95 | 'driver' => 'sqlite', 96 | 'database' => $database, 97 | ]); 98 | 99 | return tap($this->db->connection($name), function (Connection $connection) { 100 | $this->migrate($connection); 101 | }); 102 | } 103 | 104 | /** 105 | * Tear down the LDAP database connections. 106 | */ 107 | public function teardown(): void 108 | { 109 | foreach ($this->connections as $name => $connection) { 110 | $this->rollback($connection); 111 | 112 | unset($this->connections[$name]); 113 | } 114 | } 115 | 116 | /** 117 | * Return the created connections. 118 | */ 119 | public function getConnections(): array 120 | { 121 | return $this->connections; 122 | } 123 | 124 | /** 125 | * Make the unique LDAP database connection name. 126 | */ 127 | protected function makeDatabaseConnectionName(string $name): string 128 | { 129 | return Str::start($name, 'ldap_'); 130 | } 131 | 132 | /** 133 | * Run the database migrations on the connection. 134 | */ 135 | protected function migrate(Connection $connection): void 136 | { 137 | $builder = $connection->getSchemaBuilder(); 138 | 139 | if (! $builder->hasTable('ldap_objects')) { 140 | $builder->create('ldap_objects', function (Blueprint $table) { 141 | $table->bigIncrements('id'); 142 | $table->timestamps(); 143 | $table->string('domain')->nullable(); 144 | $table->string('guid')->unique()->index(); 145 | $table->string('guid_key')->nullable(); 146 | $table->string('name')->nullable(); 147 | $table->string('dn')->nullable(); 148 | $table->string('parent_dn')->nullable(); 149 | }); 150 | } 151 | 152 | if (! $builder->hasTable('ldap_object_attributes')) { 153 | $builder->create('ldap_object_attributes', function (Blueprint $table) { 154 | $table->bigIncrements('id'); 155 | $table->unsignedBigInteger('ldap_object_id'); 156 | $table->string('name'); 157 | }); 158 | } 159 | 160 | if (! $builder->hasTable('ldap_object_attribute_values')) { 161 | $builder->create('ldap_object_attribute_values', function (Blueprint $table) { 162 | $table->bigIncrements('id'); 163 | $table->unsignedBigInteger('ldap_object_attribute_id'); 164 | $table->string('value'); 165 | }); 166 | } 167 | } 168 | 169 | /** 170 | * Rollback the database migrations on the connection. 171 | */ 172 | protected function rollback(Connection $connection): void 173 | { 174 | if ($connection->getDatabaseName() === ':memory:') { 175 | tap($connection->getSchemaBuilder(), function (Builder $builder) { 176 | $builder->dropIfExists('ldap_object_attribute_values'); 177 | $builder->dropIfExists('ldap_object_attributes'); 178 | $builder->dropIfExists('ldap_objects'); 179 | }); 180 | } elseif (file_exists($dbFilePath = $connection->getDatabaseName())) { 181 | unlink($dbFilePath); 182 | } 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Testing/LdapObject.php: -------------------------------------------------------------------------------- 1 | firstWhere('dn', 'like', $dn); 52 | } 53 | 54 | /** 55 | * Find an object by its object guid. 56 | */ 57 | public static function findByGuid(string $guid, ?string $connection = null): ?static 58 | { 59 | return static::on($connection)->firstWhere('guid', '=', $guid); 60 | } 61 | 62 | /** 63 | * The hasMany attributes relation. 64 | */ 65 | public function attributes(): HasMany 66 | { 67 | return $this->hasMany(LdapObjectAttribute::class); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Testing/LdapObjectAttribute.php: -------------------------------------------------------------------------------- 1 | values()->each( 51 | fn (LdapObjectAttributeValue $value) => $value->delete() 52 | ); 53 | }); 54 | } 55 | 56 | /** 57 | * The object relationship. 58 | */ 59 | public function object(): BelongsTo 60 | { 61 | return $this->belongsTo(LdapObject::class, 'ldap_object_id'); 62 | } 63 | 64 | /** 65 | * The values relationship. 66 | */ 67 | public function values(): HasMany 68 | { 69 | return $this->hasMany(LdapObjectAttributeValue::class); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Testing/LdapObjectAttributeValue.php: -------------------------------------------------------------------------------- 1 | belongsTo(LdapObjectAttribute::class, 'ldap_object_attribute_id'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Testing/ResolvesEmulatedConnection.php: -------------------------------------------------------------------------------- 1 | connection($connection); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Testing/UnescapedValue.php: -------------------------------------------------------------------------------- 1 | get(); 15 | } 16 | 17 | /** 18 | * {@inheritdoc} 19 | */ 20 | public function get(): string 21 | { 22 | // Don't escape values. 23 | return (string) $this->value; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Testing/VirtualAttributeObserver.php: -------------------------------------------------------------------------------- 1 | ['member', 'memberof'], 14 | ]; 15 | 16 | /** 17 | * Handle updating the virtual attribute values in related model(s). 18 | */ 19 | public function updated(LdapObject $model): void 20 | { 21 | if (empty($changes = $model->getChanges())) { 22 | return; 23 | } 24 | 25 | foreach (static::$attributes as $property => $attributes) { 26 | if (! array_key_exists($property, $changes)) { 27 | continue; 28 | } 29 | 30 | LdapObjectAttributeValue::query() 31 | ->where('value', $model->getOriginal($property)) 32 | ->whereHas('attribute', fn (Builder $query) => $query->whereIn('name', $attributes)) 33 | ->each(fn (LdapObjectAttributeValue $value) => $value->update(['value' => $changes[$property]])); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Testing/VirtualAttributeValueObserver.php: -------------------------------------------------------------------------------- 1 | 'memberof', 12 | ]; 13 | 14 | /** 15 | * Handle updating the virtual attribute in the related model(s). 16 | */ 17 | public function saved(LdapObjectAttributeValue $model): void 18 | { 19 | if (! $this->isWatchedAttribute($model->attribute->name)) { 20 | return; 21 | } 22 | 23 | /** @var LdapObject|null $object */ 24 | if (! $object = LdapObject::findByDn($model->value)) { 25 | return; 26 | } 27 | 28 | /** @var LdapObjectAttribute $attribute */ 29 | $attribute = $object->attributes()->firstOrCreate([ 30 | 'name' => static::$attributes[$model->attribute->name], 31 | ]); 32 | 33 | $attribute->values()->firstOrCreate([ 34 | 'value' => $model->attribute->object->dn, 35 | ]); 36 | } 37 | 38 | /** 39 | * Handle deleting the virtual attribute in the related model(s). 40 | */ 41 | public function deleted(LdapObjectAttributeValue $model): void 42 | { 43 | if (! $this->isWatchedAttribute($model->attribute->name)) { 44 | return; 45 | } 46 | 47 | /** @var LdapObject|null $object */ 48 | if (! $object = LdapObject::findByDn($model->value)) { 49 | return; 50 | } 51 | 52 | /** @var LdapObjectAttribute|null $attribute */ 53 | if (! $attribute = $object->attributes()->firstWhere('name', static::$attributes[$model->attribute->name])) { 54 | return; 55 | } 56 | 57 | $attribute->values()->where('value', $model->attribute->object->dn)->delete(); 58 | } 59 | 60 | /** 61 | * Determine if the attribute is a watched attribute. 62 | */ 63 | protected function isWatchedAttribute(string $attribute): bool 64 | { 65 | return array_key_exists($attribute, static::$attributes); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/Feature/Commands/GetRootDseTest.php: -------------------------------------------------------------------------------- 1 | withoutMockingConsoleOutput(); 20 | } 21 | 22 | public function test_command_displays_root_dse_attributes() 23 | { 24 | RootDse::create([ 25 | 'objectclass' => ['*'], 26 | 'foo' => 'bar', 27 | 'baz' => 'zal', 28 | ]); 29 | 30 | $code = $this->artisan('ldap:rootdse'); 31 | 32 | $this->assertEquals(0, $code); 33 | 34 | $output = Artisan::output(); 35 | 36 | $this->assertStringContainsString('foo', $output); 37 | $this->assertStringContainsString('bar', $output); 38 | $this->assertStringContainsString('baz', $output); 39 | $this->assertStringContainsString('zal', $output); 40 | } 41 | 42 | public function test_command_displays_only_requested_attributes() 43 | { 44 | RootDse::create([ 45 | 'objectclass' => ['*'], 46 | 'foo' => 'bar', 47 | 'baz' => 'zal', 48 | 'zee' => 'bur', 49 | ]); 50 | 51 | $code = $this->artisan('ldap:rootdse', ['--attributes' => 'foo,baz']); 52 | 53 | $this->assertEquals(0, $code); 54 | 55 | $output = Artisan::output(); 56 | 57 | $this->assertStringContainsString('foo', $output); 58 | $this->assertStringContainsString('bar', $output); 59 | $this->assertStringContainsString('baz', $output); 60 | $this->assertStringContainsString('zal', $output); 61 | 62 | $this->assertStringNotContainsString('zee', $output); 63 | $this->assertStringNotContainsString('bur', $output); 64 | } 65 | 66 | public function test_command_displays_no_attributes_error_when_rootdse_is_empty_and_attributes_were_requested() 67 | { 68 | RootDse::create([ 69 | 'objectclass' => ['*'], 70 | ]); 71 | 72 | $code = $this->artisan('ldap:rootdse', ['--attributes' => 'foo,bar']); 73 | 74 | $this->assertEquals(Command::FAILURE, $code); 75 | 76 | $output = Artisan::output(); 77 | 78 | $this->assertStringContainsString('Attributes [foo, bar] were not found in the Root DSE record.', $output); 79 | } 80 | } 81 | 82 | class RootDse extends Entry 83 | { 84 | public function getCreatableDn(?string $name = null, ?string $attribute = null): string 85 | { 86 | return ''; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/Feature/Commands/TestLdapConnectionTest.php: -------------------------------------------------------------------------------- 1 | shouldReceive('setDispatcher')->once()->with(DispatcherInterface::class)->andReturn($connection); 27 | 28 | Container::addConnection($connection); 29 | 30 | $connection->shouldReceive('connect')->once()->andReturnNull(); 31 | $connection->shouldReceive('isConnected')->once()->andReturnTrue(); 32 | $connection->shouldReceive('getConfiguration')->once()->andReturn(new DomainConfiguration(['username' => 'user'])); 33 | 34 | $pendingCommand = $this->artisan('ldap:test', $params); 35 | 36 | $table = (new Table($output = new BufferedOutput)) 37 | ->setHeaders(['Connection', 'Successful', 'Username', 'Message', 'Response Time']) 38 | ->setRows([ 39 | ['default', '✔ Yes', 'user', 'Successfully connected.', '0ms'], 40 | ]); 41 | 42 | $table->render(); 43 | 44 | $lines = array_filter( 45 | preg_split("/\n/", $output->fetch()) 46 | ); 47 | 48 | foreach ($lines as $line) { 49 | $pendingCommand->expectsOutput($line); 50 | } 51 | 52 | $pendingCommand->assertSuccessful(); 53 | } 54 | 55 | /** 56 | * @testWith 57 | * [{"connection": "default"}] 58 | * [[]] 59 | */ 60 | public function test_command_fails_when_connection_fails($params) 61 | { 62 | $connection = m::mock(Connection::class); 63 | 64 | $connection->shouldReceive('setDispatcher')->once()->with(DispatcherInterface::class)->andReturn($connection); 65 | 66 | Container::addConnection($connection); 67 | 68 | $connection->shouldReceive('connect')->once()->andThrow(new BindException('Unable to connect')); 69 | $connection->shouldReceive('isConnected')->once()->andReturnFalse(); 70 | $connection->shouldReceive('getConfiguration')->once()->andReturn(new DomainConfiguration(['username' => 'user'])); 71 | 72 | $pendingCommand = $this->artisan('ldap:test', $params); 73 | 74 | $table = (new Table($output = new BufferedOutput)) 75 | ->setHeaders(['Connection', 'Successful', 'Username', 'Message', 'Response Time']) 76 | ->setRows([ 77 | ['default', '✘ No', 'user', 'Unable to connect. Error Code: [NULL] Diagnostic Message: NULL', '0ms'], 78 | ]); 79 | 80 | $table->render(); 81 | 82 | $lines = array_filter( 83 | preg_split("/\n/", $output->fetch()) 84 | ); 85 | 86 | foreach ($lines as $line) { 87 | $pendingCommand->expectsOutput($line); 88 | } 89 | 90 | $pendingCommand->assertFailed(); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Feature/CreatesTestUsers.php: -------------------------------------------------------------------------------- 1 | $value) { 18 | $user->{$name} = $value; 19 | } 20 | 21 | $user->save(); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Feature/DatabaseTestCase.php: -------------------------------------------------------------------------------- 1 | softDeletes(); 30 | }); 31 | } 32 | 33 | protected function defineDatabaseMigrations() 34 | { 35 | $this->loadMigrationsFrom(__DIR__.'/../../database/migrations'); 36 | $this->loadMigrationsFrom(__DIR__.'/../../vendor/laravel/sanctum/database/migrations'); 37 | } 38 | 39 | protected function getMockLdapModel(array $attributes = []) 40 | { 41 | $ldapModel = m::mock(Model::class); 42 | $ldapModel->shouldReceive('getName')->andReturn('name'); 43 | $ldapModel->shouldReceive('getConvertedGuid')->andReturn('guid'); 44 | $ldapModel->shouldReceive('getConnectionName')->andReturn('default'); 45 | 46 | foreach ($attributes as $name => $value) { 47 | $ldapModel->shouldReceive('getFirstAttribute')->withArgs([$name])->andReturn($value); 48 | } 49 | 50 | return $ldapModel; 51 | } 52 | 53 | protected function createDatabaseUserProvider( 54 | ?LdapUserRepository $repo = null, 55 | ?LdapUserAuthenticator $auth = null, 56 | ?UserSynchronizer $synchronizer = null, 57 | ?EloquentUserProvider $eloquent = null 58 | ) { 59 | return new DatabaseUserProvider( 60 | $repo ?? $this->createLdapUserRepository(), 61 | $auth ?? $this->createLdapUserAuthenticator(), 62 | $synchronizer ?? $this->createLdapUserSynchronizer(), 63 | $eloquent ?? $this->createEloquentUserProvider() 64 | ); 65 | } 66 | 67 | protected function createLdapUserRepository($model = null) 68 | { 69 | return new LdapUserRepository($model ?? User::class); 70 | } 71 | 72 | protected function createLdapUserAuthenticator() 73 | { 74 | return new LdapUserAuthenticator; 75 | } 76 | 77 | protected function createLdapUserSynchronizer($eloquentModel = null, array $config = []) 78 | { 79 | return new UserSynchronizer($eloquentModel ?? TestUserModelStub::class, $config); 80 | } 81 | 82 | protected function createEloquentUserProvider($model = null) 83 | { 84 | return new EloquentUserProvider(app('hash'), $model ?? TestUserModelStub::class); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Feature/DatabaseUserProviderTest.php: -------------------------------------------------------------------------------- 1 | createDatabaseUserProvider( 32 | $this->createLdapUserRepository(), 33 | $this->createLdapUserAuthenticator(), 34 | $synchronizer 35 | ); 36 | 37 | $this->assertSame($synchronizer, $provider->getLdapUserSynchronizer()); 38 | } 39 | 40 | public function test_retrieve_by_id_uses_eloquent() 41 | { 42 | $model = $this->createTestUser([ 43 | 'name' => 'john', 44 | 'email' => 'test@email.com', 45 | 'password' => 'secret', 46 | ]); 47 | 48 | $provider = $this->createDatabaseUserProvider(); 49 | 50 | $this->assertTrue($model->is($provider->retrieveById($model->id))); 51 | } 52 | 53 | public function test_retrieve_by_token_uses_eloquent() 54 | { 55 | $model = $this->createTestUser([ 56 | 'name' => 'john', 57 | 'email' => 'test@email.com', 58 | 'password' => 'secret', 59 | 'remember_token' => 'token', 60 | ]); 61 | 62 | $provider = $this->createDatabaseUserProvider(); 63 | 64 | $this->assertTrue($model->is($provider->retrieveByToken($model->id, $model->remember_token))); 65 | } 66 | 67 | public function test_update_remember_token_uses_eloquent() 68 | { 69 | $model = $this->createTestUser([ 70 | 'name' => 'john', 71 | 'email' => 'test@email.com', 72 | 'password' => 'secret', 73 | 'remember_token' => 'token', 74 | ]); 75 | 76 | $provider = $this->createDatabaseUserProvider(); 77 | 78 | $provider->updateRememberToken($model, 'new-token'); 79 | 80 | $this->assertEquals('new-token', $model->fresh()->remember_token); 81 | } 82 | 83 | public function test_retrieve_by_credentials_returns_unsaved_database_model() 84 | { 85 | $credentials = ['samaccountname' => 'jdoe', 'password' => 'secret']; 86 | 87 | $ldapUser = $this->getMockLdapModel([ 88 | 'cn' => 'John Doe', 89 | 'mail' => 'jdoe@test.com', 90 | ]); 91 | 92 | $repo = m::mock(LdapUserRepository::class); 93 | $repo->shouldReceive('findByCredentials')->once()->withArgs([$credentials])->andReturn($ldapUser); 94 | 95 | $provider = $this->createDatabaseUserProvider($repo); 96 | 97 | $databaseModel = $provider->retrieveByCredentials($credentials); 98 | 99 | $this->assertFalse($databaseModel->exists); 100 | $this->assertEquals('John Doe', $databaseModel->name); 101 | $this->assertEquals('jdoe@test.com', $databaseModel->email); 102 | $this->assertFalse(Hash::needsRehash($databaseModel->password)); 103 | } 104 | 105 | public function test_validate_credentials_returns_false_when_no_database_model_is_set() 106 | { 107 | $databaseModel = new TestUserModelStub; 108 | 109 | $provider = $this->createDatabaseUserProvider(); 110 | 111 | $this->assertFalse($provider->validateCredentials($databaseModel, ['password' => 'secret'])); 112 | $this->assertFalse($databaseModel->exists); 113 | } 114 | 115 | public function test_validate_credentials_saves_database_model_after_passing() 116 | { 117 | $credentials = ['samaccountname' => 'jdoe', 'password' => 'secret']; 118 | 119 | $ldapUser = $this->getMockLdapModel([ 120 | 'cn' => 'John Doe', 121 | 'mail' => 'jdoe@test.com', 122 | ]); 123 | 124 | $repo = m::mock(LdapUserRepository::class); 125 | $repo->shouldReceive('findByCredentials')->once()->withArgs([$credentials])->andReturn($ldapUser); 126 | 127 | $auth = m::mock(LdapUserAuthenticator::class); 128 | $auth->shouldReceive('setEloquentModel')->once()->withArgs([TestUserModelStub::class]); 129 | $auth->shouldReceive('attempt')->once()->withArgs([$ldapUser, 'secret'])->andReturnTrue(); 130 | 131 | $provider = $this->createDatabaseUserProvider($repo, $auth); 132 | 133 | $databaseModel = $provider->retrieveByCredentials($credentials); 134 | $provider->validateCredentials($databaseModel, ['password' => 'secret']); 135 | $this->assertTrue($databaseModel->exists); 136 | $this->assertTrue($databaseModel->wasRecentlyCreated); 137 | } 138 | 139 | public function test_method_calls_are_passed_to_eloquent_user_provider() 140 | { 141 | $provider = $this->createDatabaseUserProvider(); 142 | 143 | $model = $provider->getModel(); 144 | 145 | $this->assertInstanceOf(Model::class, new $model); 146 | } 147 | 148 | public function test_failing_loudly_throws_exception_when_resolving_users() 149 | { 150 | LdapRecord::failLoudly(); 151 | 152 | $provider = m::mock(DatabaseUserProvider::class)->makePartial(); 153 | 154 | $provider->resolveUsersUsing(function () { 155 | throw new Exception('Failed'); 156 | }); 157 | 158 | $this->expectExceptionMessage('Failed'); 159 | 160 | $provider->retrieveByCredentials([]); 161 | } 162 | 163 | public function test_rehash_password_if_required_does_nothing_when_password_column_disabled() 164 | { 165 | $synchronizer = new UserSynchronizer(TestUserModelStub::class, [ 166 | 'password_column' => false, 167 | ]); 168 | 169 | $provider = $this->createDatabaseUserProvider(synchronizer: $synchronizer); 170 | 171 | $provider->rehashPasswordIfRequired($model = new TestUserModelStub, ['password' => 'secret']); 172 | 173 | $this->assertNull($model->password); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /tests/Feature/Emulator/EmulatedAuthenticationTest.php: -------------------------------------------------------------------------------- 1 | setupPlainUserProvider(); 30 | 31 | $user = LdapUser::create([ 32 | 'cn' => 'John', 33 | 'mail' => 'jdoe@email.com', 34 | ]); 35 | 36 | $fake->actingAs($user); 37 | 38 | $this->assertTrue(Auth::attempt([ 39 | 'mail' => $user->mail[0], 40 | 'password' => 'secret', 41 | ])); 42 | 43 | $model = Auth::user(); 44 | 45 | $this->assertInstanceOf(LdapUser::class, $model); 46 | $this->assertTrue($user->is($model)); 47 | $this->assertEquals($user->mail[0], $model->mail[0]); 48 | $this->assertEquals($user->getDn(), $model->getDn()); 49 | 50 | Event::assertDispatched(Binding::class); 51 | Event::assertDispatched(Bound::class); 52 | Event::assertDispatched(DiscoveredWithCredentials::class); 53 | 54 | Event::assertNotDispatched(Importing::class); 55 | Event::assertNotDispatched(Imported::class); 56 | Event::assertNotDispatched(Synchronizing::class); 57 | Event::assertNotDispatched(Synchronized::class); 58 | } 59 | 60 | public function test_plain_ldap_authentication_fails() 61 | { 62 | Event::fake(); 63 | 64 | DirectoryEmulator::setup()->shouldBeConnected(); 65 | 66 | $this->setupPlainUserProvider(); 67 | 68 | $user = LdapUser::create([ 69 | 'cn' => 'John', 70 | 'mail' => 'jdoe@email.com', 71 | ]); 72 | 73 | $this->assertFalse(Auth::attempt(['mail' => $user->mail[0], 'password' => 'secret'])); 74 | 75 | Event::assertDispatched(Binding::class); 76 | Event::assertDispatched(DiscoveredWithCredentials::class); 77 | 78 | Event::assertNotDispatched(Bound::class); 79 | Event::assertNotDispatched(Importing::class); 80 | Event::assertNotDispatched(Imported::class); 81 | Event::assertNotDispatched(Synchronizing::class); 82 | Event::assertNotDispatched(Synchronized::class); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Feature/Emulator/EmulatedModelBindingTest.php: -------------------------------------------------------------------------------- 1 | setupDatabaseUserProvider([ 17 | 'database' => [ 18 | 'model' => TestUserModelStub::class, 19 | ], 20 | ]); 21 | 22 | DirectoryEmulator::setup(); 23 | 24 | $user = User::create([ 25 | 'cn' => 'John', 26 | 'mail' => 'jdoe@federalbridge.ca', 27 | 'objectguid' => Uuid::uuid4()->toString(), 28 | ]); 29 | 30 | $model = TestUserModelStub::create([ 31 | 'name' => $user->cn[0], 32 | 'email' => $user->mail[0], 33 | 'guid' => $user->objectguid[0], 34 | 'password' => 'secret', 35 | ]); 36 | 37 | Auth::login($model); 38 | 39 | $this->assertInstanceOf(User::class, $model->ldap); 40 | $this->assertTrue($user->is($model->ldap)); 41 | $this->assertTrue(isset($model->ldap)); 42 | } 43 | 44 | public function test_ldap_users_are_not_bound_when_model_cannot_be_located() 45 | { 46 | $this->setupDatabaseUserProvider([ 47 | 'database' => [ 48 | 'model' => TestUserModelStub::class, 49 | ], 50 | ]); 51 | 52 | DirectoryEmulator::setup(); 53 | 54 | $model = TestUserModelStub::create([ 55 | 'name' => 'John', 56 | 'email' => 'jdoe@email.com', 57 | 'guid' => Uuid::uuid4()->toString(), 58 | 'password' => 'secret', 59 | ]); 60 | 61 | Auth::login($model); 62 | 63 | $this->assertNull($model->ldap); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Feature/Emulator/EmulatedUserRepositoryTest.php: -------------------------------------------------------------------------------- 1 | 'John']), 20 | User::create(['cn' => 'Jane']), 21 | User::create(['cn' => 'Jen']), 22 | ]); 23 | 24 | $repo = new LdapUserRepository(User::class); 25 | $this->assertNull($repo->findBy('cn', 'Other')); 26 | $this->assertTrue($users->get(1)->is($repo->findBy('cn', 'Jane'))); 27 | } 28 | 29 | public function test_find_by_guid() 30 | { 31 | DirectoryEmulator::setup(); 32 | 33 | $user = User::create(['cn' => 'John', 'objectguid' => Uuid::uuid4()->toString()]); 34 | 35 | $repo = new LdapUserRepository(User::class); 36 | $this->assertNull($repo->findByGuid(Uuid::uuid4()->toString())); 37 | $this->assertTrue($user->is($repo->findByGuid($user->getConvertedGuid()))); 38 | } 39 | 40 | public function test_find_by_model() 41 | { 42 | DirectoryEmulator::setup(); 43 | 44 | $guid = Uuid::uuid4()->toString(); 45 | 46 | $user = User::create(['cn' => 'John', 'objectguid' => $guid]); 47 | 48 | $model = new TestUserModelStub(['guid' => $guid]); 49 | 50 | $repo = new LdapUserRepository(User::class); 51 | $this->assertNull($repo->findByModel(new TestUserModelStub(['guid' => Uuid::uuid4()]))); 52 | $this->assertTrue($user->is($repo->findByModel($model))); 53 | } 54 | 55 | public function test_find_by_credentials() 56 | { 57 | DirectoryEmulator::setup(); 58 | 59 | $user = User::create(['cn' => 'John', 'mail' => 'jdoe@email.com']); 60 | 61 | $repo = new LdapUserRepository(User::class); 62 | $this->assertNull($repo->findByCredentials(['mail' => 'invalid@email.com'])); 63 | $this->assertTrue($user->is($repo->findByCredentials( 64 | ['mail' => $user->mail[0]] 65 | ))); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/Feature/Emulator/EmulatedWindowsAuthenticateTest.php: -------------------------------------------------------------------------------- 1 | setupDatabaseUserProvider([ 49 | 'database' => [ 50 | 'model' => TestUserModelStub::class, 51 | ], 52 | ]); 53 | 54 | $user = User::create([ 55 | 'cn' => 'John', 56 | 'mail' => 'jdoe@email.com', 57 | 'samaccountname' => 'jdoe', 58 | 'objectguid' => $this->faker->uuid, 59 | ]); 60 | 61 | $request = tap(new Request, function ($request) { 62 | $request->server->set('AUTH_USER', 'LOCAL\\jdoe'); 63 | }); 64 | 65 | app(WindowsAuthenticate::class)->handle($request, function () use ($user) { 66 | $this->assertTrue(auth()->check()); 67 | $this->assertEquals($user->getConvertedGuid(), auth()->user()->guid); 68 | }); 69 | 70 | Event::assertDispatched(Importing::class); 71 | Event::assertDispatched(Imported::class); 72 | Event::assertDispatched(Synchronizing::class); 73 | Event::assertDispatched(Synchronized::class); 74 | Event::assertDispatched(Binding::class); 75 | Event::assertDispatched(Bound::class); 76 | Event::assertDispatched(Saved::class); 77 | Event::assertDispatched(CompletedWithWindows::class); 78 | } 79 | 80 | public function test_kerberos_authenticated_user_is_signed_in() 81 | { 82 | Event::fake(); 83 | 84 | DirectoryEmulator::setup(); 85 | 86 | $this->setupDatabaseUserProvider([ 87 | 'database' => [ 88 | 'model' => TestUserModelStub::class, 89 | ], 90 | ]); 91 | 92 | WindowsAuthenticate::serverKey('REMOTE_USER'); 93 | WindowsAuthenticate::username('userPrincipalName'); 94 | WindowsAuthenticate::extractDomainUsing(function ($accountName) { 95 | return $accountName; 96 | }); 97 | WindowsAuthenticate::validateDomainUsing(function ($user, $username) { 98 | return $user->getFirstAttribute('userPrincipalName') === $username; 99 | }); 100 | 101 | $user = User::create([ 102 | 'cn' => 'John', 103 | 'mail' => 'jdoe@email.com', 104 | 'samaccountname' => 'jdoe', 105 | 'userprincipalname' => 'jdoe@local.com', 106 | 'objectguid' => $this->faker->uuid, 107 | ]); 108 | 109 | $request = tap(new Request, function ($request) use ($user) { 110 | $request->server->set('REMOTE_USER', $user->getFirstAttribute('userprincipalname')); 111 | }); 112 | 113 | app(WindowsAuthenticate::class)->handle($request, function () use ($user) { 114 | $this->assertTrue(auth()->check()); 115 | $this->assertEquals($user->getConvertedGuid(), auth()->user()->guid); 116 | }); 117 | 118 | Event::assertDispatched(Importing::class); 119 | Event::assertDispatched(Imported::class); 120 | Event::assertDispatched(Synchronizing::class); 121 | Event::assertDispatched(Synchronized::class); 122 | Event::assertDispatched(Binding::class); 123 | Event::assertDispatched(Bound::class); 124 | Event::assertDispatched(Saved::class); 125 | Event::assertDispatched(CompletedWithWindows::class); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/Feature/Emulator/EmulatorTest.php: -------------------------------------------------------------------------------- 1 | set('ldap.default', 'default'); 17 | $app['config']->set('ldap.connections.default', [ 18 | 'base_dn' => 'dc=local,dc=com', 19 | 'use_tls' => true, 20 | ]); 21 | } 22 | 23 | public function test_sqlite_connection_is_setup() 24 | { 25 | DirectoryEmulator::setup('default'); 26 | 27 | $db = app(LdapDatabaseManager::class); 28 | 29 | $this->assertCount(1, $db->getConnections()); 30 | 31 | $connection = $db->getConnections()['default']; 32 | 33 | $this->assertInstanceOf(Connection::class, $connection); 34 | $this->assertEquals('sqlite', $connection->getConfig('driver')); 35 | $this->assertEquals(':memory:', $connection->getDatabaseName()); 36 | } 37 | 38 | public function test_configuration_and_options_are_carried_over_to_emulated_connection() 39 | { 40 | $fake = DirectoryEmulator::setup('default'); 41 | 42 | $this->assertTrue($fake->getLdapConnection()->isUsingTLS()); 43 | $this->assertEquals('dc=local,dc=com', $fake->getConfiguration()->get('base_dn')); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Feature/ListenForLdapBindFailureTest.php: -------------------------------------------------------------------------------- 1 | set('ldap.connections.default', [ 19 | 'hosts' => ['one', 'two', 'three'], 20 | 'username' => 'user', 21 | 'password' => 'secret', 22 | 'base_dn' => 'dc=local,dc=com', 23 | ]); 24 | } 25 | 26 | public function test_validation_exception_is_not_thrown_until_all_connection_hosts_are_attempted() 27 | { 28 | $this->setupPlainUserProvider(['model' => User::class]); 29 | 30 | $fake = DirectoryFake::setup('default')->shouldNotBeConnected(); 31 | 32 | $expectedSelects = [ 33 | 'objectguid', 34 | '*', 35 | ]; 36 | 37 | $expectedFilter = $fake->query() 38 | ->where([ 39 | ['objectclass', '=', 'top'], 40 | ['objectclass', '=', 'person'], 41 | ['objectclass', '=', 'organizationalperson'], 42 | ['objectclass', '=', 'user'], 43 | ['mail', '=', 'jdoe@local.com'], 44 | ['objectclass', '!=', 'computer'], 45 | ]) 46 | ->getQuery(); 47 | 48 | $expectedQueryResult = [ 49 | [ 50 | 'mail' => ['jdoe@local.com'], 51 | 'dn' => ['cn=jdoe,dc=local,dc=com'], 52 | ], 53 | ]; 54 | 55 | $fake->getLdapConnection()->expect([ 56 | // Two bind attempts fail on hosts "one" and "two" with configured user account. 57 | LdapFake::operation('bind') 58 | ->with('user', 'secret') 59 | ->twice() 60 | ->andReturn(new LdapResultResponse(1)), 61 | 62 | // Third bind attempt passes. 63 | LdapFake::operation('bind') 64 | ->with('user', 'secret') 65 | ->once() 66 | ->andReturn(new LdapResultResponse), 67 | 68 | // Bind is attempted with the authenticating user and passes. 69 | LdapFake::operation('bind') 70 | ->with('cn=jdoe,dc=local,dc=com', 'secret') 71 | ->once() 72 | ->andReturn(new LdapResultResponse), 73 | 74 | // Rebind is attempted with configured user account. 75 | LdapFake::operation('bind') 76 | ->with('user', 'secret') 77 | ->once() 78 | ->andReturn(new LdapResultResponse(0)), 79 | 80 | // Search operation is executed for authenticating user. 81 | LdapFake::operation('search') 82 | ->with(['dc=local,dc=com', $expectedFilter, $expectedSelects, false, 1]) 83 | ->once() 84 | ->andReturn($expectedQueryResult), 85 | 86 | LdapFake::operation('parseResult') 87 | ->once() 88 | ->andReturn(new LdapResultResponse), 89 | ])->shouldReturnError("Can't contact LDAP server"); 90 | 91 | $result = Auth::attempt([ 92 | 'mail' => 'jdoe@local.com', 93 | 'password' => 'secret', 94 | ]); 95 | 96 | $this->assertTrue($result); 97 | $this->assertCount(2, $fake->attempted()); 98 | $this->assertInstanceOf(User::class, Auth::user()); 99 | $this->assertEquals(['one', 'two'], array_keys($fake->attempted())); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /tests/Feature/NoDatabaseUserProviderTest.php: -------------------------------------------------------------------------------- 1 | assertSame($repo, $provider->getLdapUserRepository()); 32 | } 33 | 34 | public function test_user_authenticator_can_be_retrieved() 35 | { 36 | $auth = new LdapUserAuthenticator; 37 | 38 | $provider = new NoDatabaseUserProvider(new LdapUserRepository(User::class), $auth); 39 | 40 | $this->assertSame($auth, $provider->getLdapUserAuthenticator()); 41 | } 42 | 43 | public function test_retrieve_by_id_returns_model_instance() 44 | { 45 | $model = new Entry; 46 | 47 | $repo = m::mock(LdapUserRepository::class); 48 | 49 | $repo->shouldReceive('findByGuid')->once()->withArgs(['id'])->andReturn($model); 50 | 51 | $provider = new NoDatabaseUserProvider($repo, new LdapUserAuthenticator); 52 | 53 | $this->assertSame($model, $provider->retrieveById('id')); 54 | } 55 | 56 | public function test_retrieve_by_credentials_returns_model_instance() 57 | { 58 | $model = new Entry; 59 | 60 | $repo = m::mock(LdapUserRepository::class); 61 | 62 | $repo->shouldReceive('findByCredentials')->once()->withArgs([['username' => 'foo']])->andReturn($model); 63 | 64 | $provider = new NoDatabaseUserProvider($repo, new LdapUserAuthenticator); 65 | 66 | $this->assertSame($model, $provider->retrieveByCredentials(['username' => 'foo'])); 67 | } 68 | 69 | public function test_validate_credentials_attempts_authentication() 70 | { 71 | $user = new User; 72 | 73 | $auth = m::mock(LdapUserAuthenticator::class); 74 | 75 | $auth->shouldReceive('attempt')->once()->withArgs([$user, 'secret'])->andReturnTrue(); 76 | 77 | $provider = new NoDatabaseUserProvider(new LdapUserRepository(Entry::class), $auth); 78 | 79 | $this->assertTrue($provider->validateCredentials($user, ['password' => 'secret'])); 80 | } 81 | 82 | public function test_failing_loudly_throws_exception_when_resolving_users() 83 | { 84 | LdapRecord::failLoudly(); 85 | 86 | $provider = m::mock(NoDatabaseUserProvider::class)->makePartial(); 87 | 88 | $provider->resolveUsersUsing(function () { 89 | throw new Exception('Failed'); 90 | }); 91 | 92 | $this->expectExceptionMessage('Failed'); 93 | 94 | $provider->retrieveByCredentials([]); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Feature/SanctumTest.php: -------------------------------------------------------------------------------- 1 | setupDatabaseUserProvider([ 22 | 'database' => [ 23 | 'model' => SanctumTestUserModelStub::class, 24 | 'sync_attributes' => [ 25 | 'name' => 'cn', 26 | 'email' => 'mail', 27 | ], 28 | ], 29 | ]); 30 | 31 | Route::get('api/user', function (Request $request) { 32 | return $request->user(); 33 | })->middleware('auth:sanctum'); 34 | 35 | Route::post('api/sanctum/token', function (Request $request) { 36 | if (Auth::validate($request->only('mail', 'password'))) { 37 | return [ 38 | 'token' => Auth::getLastAttempted() 39 | ->createToken($request->device_name) 40 | ->plainTextToken, 41 | ]; 42 | } 43 | 44 | throw ValidationException::withMessages([ 45 | 'email' => 'The provided credentials are incorrect.', 46 | ]); 47 | }); 48 | } 49 | 50 | public function test_ldap_user_can_request_sanctum_token() 51 | { 52 | $fake = DirectoryEmulator::setup(); 53 | 54 | $user = LdapUser::create([ 55 | 'cn' => 'John Doe', 56 | 'mail' => 'john@local.com', 57 | ]); 58 | 59 | $fake->actingAs($user); 60 | 61 | $this->postJson('api/sanctum/token', [ 62 | 'mail' => 'john@local.com', 63 | 'password' => 'secret', 64 | 'device_name' => 'browser', 65 | ])->assertJsonStructure(['token']); 66 | 67 | $this->assertDatabaseHas('users', [ 68 | 'email' => $user->mail[0], 69 | 'name' => $user->cn[0], 70 | ]); 71 | 72 | $this->assertEquals(1, PersonalAccessToken::count()); 73 | } 74 | 75 | public function test_ldap_user_can_fail_requesting_sanctum_token_with_invalid_password() 76 | { 77 | DirectoryEmulator::setup(); 78 | 79 | $user = LdapUser::create([ 80 | 'cn' => 'John Doe', 81 | 'mail' => 'john@local.com', 82 | ]); 83 | 84 | $this->postJson('api/sanctum/token', [ 85 | 'mail' => 'john@local.com', 86 | 'password' => 'secret', 87 | 'device_name' => 'browser', 88 | ])->assertJsonValidationErrors(['email' => 'The provided credentials are incorrect.']); 89 | 90 | $this->assertDatabaseMissing('users', [ 91 | 'email' => $user->mail[0], 92 | 'name' => $user->cn[0], 93 | ]); 94 | 95 | $this->assertEquals(0, PersonalAccessToken::count()); 96 | } 97 | 98 | public function test_ldap_user_can_use_sanctum_token_for_authentication() 99 | { 100 | $fake = DirectoryEmulator::setup(); 101 | 102 | $user = LdapUser::create([ 103 | 'cn' => 'John Doe', 104 | 'mail' => 'john@local.com', 105 | ]); 106 | 107 | $fake->actingAs($user); 108 | 109 | $plainTextToken = $this->postJson('api/sanctum/token', [ 110 | 'mail' => 'john@local.com', 111 | 'password' => 'secret', 112 | 'device_name' => 'browser', 113 | ])->json('token'); 114 | 115 | $this->getJson('api/user', [ 116 | 'Authorization' => "Bearer $plainTextToken", 117 | ])->assertJsonFragment([ 118 | 'name' => $user->cn[0], 119 | 'email' => $user->mail[0], 120 | ]); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/Feature/SanctumTestUserModelStub.php: -------------------------------------------------------------------------------- 1 | set('database.default', 'testbench'); 46 | $config->set('database.connections.testbench', [ 47 | 'driver' => 'sqlite', 48 | 'database' => ':memory:', 49 | ]); 50 | 51 | // LDAP mock setup. 52 | $config->set('ldap.default', 'default'); 53 | $config->set('ldap.logging.enabled', false); 54 | $config->set('ldap.connections.default', [ 55 | 'hosts' => ['localhost'], 56 | 'username' => 'user', 57 | 'password' => 'secret', 58 | 'base_dn' => 'dc=local,dc=com', 59 | 'port' => 389, 60 | ]); 61 | } 62 | 63 | protected function setupLdapUserProvider($guardName, array $config) 64 | { 65 | config()->set('auth.defaults.guard', $guardName); 66 | 67 | config()->set("auth.guards.$guardName", [ 68 | 'driver' => 'session', 69 | 'provider' => $guardName, 70 | ]); 71 | 72 | config()->set("auth.providers.$guardName", array_merge([ 73 | 'rules' => [], 74 | 'driver' => 'ldap', 75 | 'model' => User::class, 76 | ], $config)); 77 | } 78 | 79 | protected function setupPlainUserProvider(array $config = []) 80 | { 81 | $this->setupLdapUserProvider('ldap-plain', $config); 82 | } 83 | 84 | protected function setupDatabaseUserProvider(array $config = []) 85 | { 86 | $this->setupLdapUserProvider('ldap-database', array_merge([ 87 | 'database' => [ 88 | 'sync_attributes' => [ 89 | 'name' => 'cn', 90 | 'email' => 'mail', 91 | ], 92 | ], 93 | ], $config)); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /tests/Unit/EloquentHydratorTest.php: -------------------------------------------------------------------------------- 1 | 'bf9679e7-0de6-11d0-a285-00aa003049e2']); 22 | $model = new TestModelStub; 23 | $hydrator = new GuidHydrator; 24 | 25 | $hydrator->hydrate($entry, $model); 26 | 27 | $this->assertEquals($entry->getConvertedGuid(), $model->guid); 28 | } 29 | 30 | public function test_domain_hydrator_uses_default_connection_name() 31 | { 32 | $entry = new Entry; 33 | $model = new TestModelStub; 34 | $hydrator = new DomainHydrator; 35 | 36 | $hydrator->hydrate($entry, $model); 37 | 38 | $this->assertEquals('default', $model->domain); 39 | } 40 | 41 | public function test_attribute_hydrator() 42 | { 43 | $entry = new Entry(['bar' => 'baz']); 44 | $model = new TestModelStub; 45 | 46 | AttributeHydrator::with([ 47 | 'sync_attributes' => ['foo' => 'bar'], 48 | ])->hydrate($entry, $model); 49 | 50 | $this->assertEquals('baz', $model->foo); 51 | } 52 | 53 | public function test_attribute_hydrator_can_use_handle_function_of_class() 54 | { 55 | $entry = new Entry(['bar' => 'baz']); 56 | $model = new TestModelStub; 57 | 58 | AttributeHydrator::with([ 59 | 'sync_attributes' => [TestAttributeHandlerHandleStub::class], 60 | ])->hydrate($entry, $model); 61 | 62 | $this->assertEquals('baz', $model->foo); 63 | } 64 | 65 | public function test_attribute_hydrator_can_use_invokable_class() 66 | { 67 | $entry = new Entry(['bar' => 'baz']); 68 | $model = new TestModelStub; 69 | 70 | AttributeHydrator::with(['sync_attributes' => [ 71 | TestAttributeHandlerInvokableStub::class, 72 | ]])->hydrate($entry, $model); 73 | 74 | $this->assertEquals('baz', $model->foo); 75 | } 76 | 77 | public function test_attribute_hydrator_can_use_inline_function() 78 | { 79 | $entry = new Entry(['bar' => 'baz']); 80 | $model = new TestModelStub; 81 | 82 | AttributeHydrator::with(['sync_attributes' => [ 83 | function ($object, $eloquent) { 84 | $eloquent->foo = $object->getFirstAttribute('bar'); 85 | }, 86 | ]])->hydrate($entry, $model); 87 | 88 | $this->assertEquals('baz', $model->foo); 89 | } 90 | 91 | public function test_password_hydrator_uses_random_password() 92 | { 93 | $entry = new Entry; 94 | $model = new TestModelStub; 95 | $hydrator = new PasswordHydrator; 96 | 97 | $hydrator->hydrate($entry, $model); 98 | 99 | $this->assertFalse(Hash::needsRehash($model->password)); 100 | } 101 | 102 | public function test_password_hydrator_does_nothing_when_password_column_is_disabled() 103 | { 104 | $entry = new Entry; 105 | $model = new TestModelStub; 106 | $hydrator = new PasswordHydrator(['password_column' => false]); 107 | 108 | $hydrator->hydrate($entry, $model); 109 | 110 | $this->assertNull($model->password); 111 | } 112 | 113 | public function test_password_hydrator_uses_given_password_when_password_sync_is_enabled() 114 | { 115 | $entry = new Entry; 116 | $model = new TestModelStub; 117 | $hydrator = new PasswordHydrator(['sync_passwords' => true], ['password' => 'secret']); 118 | 119 | $hydrator->hydrate($entry, $model); 120 | 121 | $this->assertFalse(Hash::needsRehash($model->password)); 122 | $this->assertTrue(Hash::check('secret', $model->password)); 123 | } 124 | 125 | public function test_password_hydrator_ignores_password_when_password_sync_is_disabled() 126 | { 127 | $entry = new Entry; 128 | $model = new TestModelStub; 129 | $hydrator = new PasswordHydrator(['sync_passwords' => false], ['password' => 'secret']); 130 | 131 | $hydrator->hydrate($entry, $model); 132 | 133 | $this->assertFalse(Hash::needsRehash($model->password)); 134 | $this->assertFalse(Hash::check('secret', $model->password)); 135 | } 136 | 137 | public function test_password_hydrator_uses_models_get_auth_password_name_if_available() 138 | { 139 | $entry = new Entry; 140 | $model = new TestModelWithCustomPasswordStub; 141 | $hydrator = new PasswordHydrator; 142 | 143 | $hydrator->hydrate($entry, $model); 144 | 145 | $this->assertFalse(Hash::needsRehash($model->custom_password)); 146 | } 147 | 148 | public function test_hydrator_uses_all_hydrators() 149 | { 150 | $entry = new Entry([ 151 | 'bar' => 'baz', 152 | 'objectguid' => 'bf9679e7-0de6-11d0-a285-00aa003049e2', 153 | ]); 154 | 155 | $model = new TestModelStub; 156 | 157 | (new EloquentHydrator(['sync_attributes' => ['foo' => 'bar']])) 158 | ->hydrate($entry, $model); 159 | 160 | $this->assertEquals('baz', $model->foo); 161 | $this->assertEquals('default', $model->domain); 162 | $this->assertEquals($entry->getConvertedGuid(), $model->guid); 163 | } 164 | } 165 | 166 | class TestModelStub extends Model implements LdapAuthenticatable 167 | { 168 | use AuthenticatesWithLdap; 169 | } 170 | 171 | class TestModelWithCustomPasswordStub extends Model implements LdapAuthenticatable 172 | { 173 | use AuthenticatesWithLdap; 174 | 175 | public function getAuthPasswordName() 176 | { 177 | return 'custom_password'; 178 | } 179 | } 180 | 181 | class TestAttributeHandlerHandleStub 182 | { 183 | public function handle($object, $eloquent) 184 | { 185 | $eloquent->foo = $object->getFirstAttribute('bar'); 186 | } 187 | } 188 | 189 | class TestAttributeHandlerInvokableStub 190 | { 191 | public function __invoke($object, $eloquent) 192 | { 193 | $eloquent->foo = $object->getFirstAttribute('bar'); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /tests/Unit/LdapDatabaseManagerTest.php: -------------------------------------------------------------------------------- 1 | connection('default'); 15 | 16 | $this->assertInstanceOf(Connection::class, $connection); 17 | $this->assertCount(1, $manager->getConnections()); 18 | $this->assertArrayHasKey('default', $manager->getConnections()); 19 | } 20 | 21 | public function test_database_connections_are_not_resolved_twice() 22 | { 23 | $manager = app(LdapDatabaseManager::class); 24 | 25 | $manager->connection('default'); 26 | $manager->connection('default'); 27 | 28 | $this->assertCount(1, $manager->getConnections()); 29 | $this->assertArrayHasKey('default', $manager->getConnections()); 30 | } 31 | 32 | public function test_initialized_database_sets_up_configuration() 33 | { 34 | app(LdapDatabaseManager::class)->connection('default'); 35 | 36 | $this->assertEquals([ 37 | 'driver' => 'sqlite', 38 | 'database' => ':memory:', 39 | ], config('database.connections.ldap_default')); 40 | } 41 | 42 | public function test_cached_database_can_be_initialized() 43 | { 44 | $manager = app(LdapDatabaseManager::class); 45 | 46 | $file = storage_path('framework/cache/ldap_directory.sqlite'); 47 | 48 | file_put_contents($file, ''); 49 | 50 | $manager->connection('default', ['database' => $file]); 51 | 52 | $this->assertFileExists($file); 53 | 54 | $this->assertEquals([ 55 | 'driver' => 'sqlite', 56 | 'database' => $file, 57 | ], config('database.connections.ldap_default')); 58 | 59 | unlink($file); 60 | } 61 | 62 | public function test_tear_down_removes_tables() 63 | { 64 | $manager = app(LdapDatabaseManager::class); 65 | 66 | $schema = $manager->connection('default')->getSchemaBuilder(); 67 | $this->assertTrue($schema->hasTable('ldap_objects')); 68 | 69 | $manager->teardown(); 70 | $this->assertFalse($schema->hasTable('ldap_objects')); 71 | } 72 | 73 | public function test_tear_down_deletes_cached_database() 74 | { 75 | $manager = app(LdapDatabaseManager::class); 76 | 77 | $file = storage_path('framework/cache/ldap_directory.sqlite'); 78 | 79 | file_put_contents($file, ''); 80 | 81 | $manager->connection('default', ['database' => $file]); 82 | 83 | $this->assertFileExists($file); 84 | 85 | $manager->teardown(); 86 | 87 | $this->assertFileDoesNotExist($file); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Unit/LdapImporterTest.php: -------------------------------------------------------------------------------- 1 | increments('id'); 30 | $table->softDeletes(); 31 | $table->string('guid')->unique()->nullable(); 32 | $table->string('domain')->nullable(); 33 | $table->string('name')->nullable(); 34 | }); 35 | 36 | DirectoryEmulator::setup(); 37 | } 38 | 39 | protected function tearDown(): void 40 | { 41 | Schema::dropIfExists('test_importer_group_model_stubs'); 42 | 43 | parent::tearDown(); 44 | } 45 | 46 | public function test_class_based_import_works() 47 | { 48 | $object = LdapGroup::create([ 49 | 'objectguid' => $this->faker->uuid, 50 | 'cn' => 'Group', 51 | ]); 52 | 53 | $imported = (new Importer) 54 | ->setLdapModel(LdapGroup::class) 55 | ->setEloquentModel(TestImporterGroupModelStub::class) 56 | ->setSyncAttributes(['name' => 'cn']) 57 | ->execute(); 58 | 59 | $this->assertCount(1, $imported); 60 | $this->assertTrue($imported->first()->exists); 61 | $this->assertEquals($object->getFirstAttribute('cn'), $imported->first()->name); 62 | } 63 | 64 | public function test_class_based_import_can_have_callable_importer() 65 | { 66 | $object = LdapGroup::create([ 67 | 'objectguid' => $this->faker->uuid, 68 | 'cn' => 'Group', 69 | ]); 70 | 71 | $imported = (new Importer) 72 | ->setLdapModel(LdapGroup::class) 73 | ->setEloquentModel(TestImporterGroupModelStub::class) 74 | ->syncAttributesUsing(function ($object, $database) { 75 | $database 76 | ->forceFill(['name' => $object->getFirstAttribute('cn')]) 77 | ->save(); 78 | })->execute(); 79 | 80 | $this->assertCount(1, $imported); 81 | $this->assertEquals($object->getFirstAttribute('cn'), $imported->first()->name); 82 | } 83 | 84 | public function test_scopes_can_be_applied_to_import_query() 85 | { 86 | LdapGroup::create([ 87 | 'objectguid' => $this->faker->uuid, 88 | 'cn' => 'First Group', 89 | ]); 90 | 91 | LdapGroup::create([ 92 | 'objectguid' => $this->faker->uuid, 93 | 'cn' => 'Second Group', 94 | ]); 95 | 96 | $imported = (new Importer) 97 | ->setLdapModel(LdapGroup::class) 98 | ->setSyncAttributes(['name' => 'cn']) 99 | ->setLdapScopes(TestImporterScopeStub::class) 100 | ->setEloquentModel(TestImporterGroupModelStub::class) 101 | ->execute(); 102 | 103 | $this->assertCount(1, $imported); 104 | $this->assertEquals('Second Group', $imported->first()->name); 105 | } 106 | } 107 | 108 | class TestImporterScopeStub implements Scope 109 | { 110 | public function apply(Builder $query, LdapModel $model): void 111 | { 112 | $query->where('cn', 'Second Group'); 113 | } 114 | } 115 | 116 | class TestImporterGroupModelStub extends Model implements LdapImportable 117 | { 118 | use ImportableFromLdap, SoftDeletes; 119 | 120 | public $timestamps = false; 121 | 122 | protected $guarded = []; 123 | } 124 | -------------------------------------------------------------------------------- /tests/Unit/LdapServiceProviderTest.php: -------------------------------------------------------------------------------- 1 | artisan('vendor:publish', ['--provider' => LdapServiceProvider::class, '--no-interaction' => true]); 51 | 52 | $this->assertFileExists(config_path('ldap.php')); 53 | } 54 | 55 | public function test_connections_from_environment_variables_are_setup() 56 | { 57 | tap(Container::getConnection('alpha'), function (Connection $connection) { 58 | $config = $connection->getConfiguration(); 59 | 60 | $this->assertEquals(['10.0.0.1', '10.0.0.2'], $config->get('hosts')); 61 | $this->assertEquals('dc=alpha,dc=com', $config->get('base_dn')); 62 | $this->assertEquals('cn=user,dc=alpha,dc=com', $config->get('username')); 63 | $this->assertEquals('alpha-secret', $config->get('password')); 64 | }); 65 | 66 | tap(Container::getConnection('bravo'), function (Connection $connection) { 67 | $config = $connection->getConfiguration(); 68 | 69 | $this->assertEquals(['172.0.0.1', '172.0.0.2'], $config->get('hosts')); 70 | $this->assertEquals('dc=bravo,dc=com', $config->get('base_dn')); 71 | $this->assertEquals('cn=user,dc=bravo,dc=com', $config->get('username')); 72 | $this->assertEquals('bravo-secret', $config->get('password')); 73 | }); 74 | } 75 | 76 | public function test_custom_connection_options_from_env_are_loaded_into_configuration() 77 | { 78 | tap(Container::getConnection('bravo'), function (Connection $connection) { 79 | $config = $connection->getConfiguration(); 80 | 81 | $this->assertEquals([ 82 | LDAP_OPT_X_TLS_CACERTFILE => '/path', 83 | LDAP_OPT_X_TLS_CERTFILE => '/path', 84 | ], $config->get('options')); 85 | }); 86 | } 87 | 88 | public function test_env_config_is_loaded_and_cacheable() 89 | { 90 | $this->assertEquals([ 91 | 'default' => 'default', 92 | 'connections' => [ 93 | 'default' => [ 94 | 'hosts' => ['localhost'], 95 | 'username' => 'user', 96 | 'password' => 'secret', 97 | 'base_dn' => 'dc=local,dc=com', 98 | 'port' => 389, 99 | ], 100 | 'alpha' => [ 101 | 'hosts' => ['10.0.0.1', '10.0.0.2'], 102 | 'username' => 'cn=user,dc=alpha,dc=com', 103 | 'password' => 'alpha-secret', 104 | 'base_dn' => 'dc=alpha,dc=com', 105 | 'port' => 389, 106 | 'timeout' => 5, 107 | ], 108 | 'bravo' => [ 109 | 'hosts' => ['172.0.0.1', '172.0.0.2'], 110 | 'username' => 'cn=user,dc=bravo,dc=com', 111 | 'password' => 'bravo-secret', 112 | 'base_dn' => 'dc=bravo,dc=com', 113 | 'port' => 389, 114 | 'timeout' => 5, 115 | 'options' => [ 116 | 24578 => '/path', 117 | 24580 => '/path', 118 | ], 119 | ], 120 | ], 121 | 'logging' => [ 122 | 'enabled' => false, 123 | 'channel' => 'stack', 124 | 'level' => 'info', 125 | ], 126 | 'cache' => [ 127 | 'enabled' => false, 128 | 'driver' => 'file', 129 | ], 130 | ], app('config')->all()['ldap']); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tests/Unit/LdapSynchronizerTest.php: -------------------------------------------------------------------------------- 1 | increments('id'); 28 | $table->softDeletes(); 29 | $table->string('guid')->unique()->nullable(); 30 | $table->string('domain')->nullable(); 31 | $table->string('name')->nullable(); 32 | }); 33 | 34 | DirectoryEmulator::setup(); 35 | } 36 | 37 | protected function tearDown(): void 38 | { 39 | Schema::dropIfExists('test_synchronizer_group_model_stubs'); 40 | 41 | parent::tearDown(); 42 | } 43 | 44 | public function test_importer_fails_on_object_that_does_not_contain_guid() 45 | { 46 | $object = LdapGroup::create(['cn' => 'Group']); 47 | 48 | $synchronizer = new Synchronizer(TestSynchronizerGroupModelStub::class, [ 49 | 'sync_attributes' => ['name' => 'cn'], 50 | ]); 51 | 52 | $this->expectException(LdapRecordException::class); 53 | 54 | $synchronizer->run($object); 55 | } 56 | 57 | public function test_importer_sets_configured_attributes() 58 | { 59 | $object = LdapGroup::create([ 60 | 'objectguid' => $this->faker->uuid, 61 | 'cn' => 'Group', 62 | ]); 63 | 64 | $synchronizer = new Synchronizer(TestSynchronizerGroupModelStub::class, [ 65 | 'sync_attributes' => ['name' => 'cn'], 66 | ]); 67 | 68 | /** @var TestSynchronizerGroupModelStub $group */ 69 | $group = $synchronizer->run($object); 70 | 71 | $this->assertEquals('default', $group->getLdapDomain()); 72 | $this->assertEquals($object->getConvertedGuid(), $group->getLdapGuid()); 73 | $this->assertEquals($object->getName(), $group->name); 74 | $this->assertFalse($group->exists); 75 | } 76 | 77 | public function test_importer_locates_existing_model() 78 | { 79 | $guid = $this->faker->uuid; 80 | 81 | TestSynchronizerGroupModelStub::create(['guid' => $guid]); 82 | 83 | $object = LdapGroup::create([ 84 | 'objectguid' => $guid, 85 | 'cn' => 'Group', 86 | ]); 87 | 88 | $synchronizer = new Synchronizer(TestSynchronizerGroupModelStub::class, [ 89 | 'sync_attributes' => ['name' => 'cn'], 90 | ]); 91 | 92 | $group = $synchronizer->run($object); 93 | 94 | $this->assertTrue($group->exists); 95 | $this->assertEquals($guid, $group->guid); 96 | } 97 | 98 | public function test_importer_locates_soft_deleted_model() 99 | { 100 | $guid = $this->faker->uuid; 101 | 102 | $group = TestSynchronizerGroupModelStub::create(['guid' => $guid]); 103 | 104 | $group->delete(); 105 | 106 | $object = LdapGroup::create([ 107 | 'objectguid' => $guid, 108 | 'cn' => 'Group', 109 | ]); 110 | 111 | $synchronizer = new Synchronizer(TestSynchronizerGroupModelStub::class, [ 112 | 'sync_attributes' => ['name' => 'cn'], 113 | ]); 114 | 115 | $imported = $synchronizer->run($object); 116 | 117 | $this->assertEquals($group->id, $imported->id); 118 | $this->assertTrue($imported->trashed()); 119 | } 120 | } 121 | 122 | class TestSynchronizerGroupModelStub extends Model implements LdapImportable 123 | { 124 | use ImportableFromLdap, SoftDeletes; 125 | 126 | public $timestamps = false; 127 | 128 | protected $guarded = []; 129 | } 130 | -------------------------------------------------------------------------------- /tests/Unit/LdapUserAuthenticatorTest.php: -------------------------------------------------------------------------------- 1 | getAuthenticatingModelMock($dn); 28 | 29 | $model->shouldReceive('getConnection')->once()->andReturn( 30 | m::mock(Connection::class, function ($connection) use ($dn) { 31 | $auth = m::mock(Guard::class); 32 | $auth->shouldReceive('attempt')->once()->withArgs([$dn, 'password'])->andReturnTrue(); 33 | 34 | $connection->shouldReceive('auth')->once()->andReturn($auth); 35 | }) 36 | ); 37 | 38 | $auth = new LdapUserAuthenticator; 39 | 40 | Event::fake(); 41 | 42 | $this->assertTrue($auth->attempt($model, 'password')); 43 | 44 | Event::assertDispatched(Binding::class); 45 | Event::assertDispatched(Bound::class); 46 | Event::assertNotDispatched(BindFailed::class); 47 | } 48 | 49 | public function test_attempt_failed() 50 | { 51 | $dn = 'cn=John Doe,dc=local,dc=com'; 52 | 53 | $model = $this->getAuthenticatingModelMock($dn); 54 | 55 | $model->shouldReceive('getConnection')->once()->andReturn( 56 | m::mock(Connection::class, function ($connection) use ($dn) { 57 | $auth = m::mock(Guard::class); 58 | $auth->shouldReceive('attempt')->once()->withArgs([$dn, 'password'])->andReturnFalse(); 59 | 60 | $connection->shouldReceive('auth')->once()->andReturn($auth); 61 | }) 62 | ); 63 | 64 | $auth = new LdapUserAuthenticator; 65 | 66 | Event::fake(); 67 | 68 | $this->assertFalse($auth->attempt($model, 'password')); 69 | 70 | Event::assertDispatched(Binding::class); 71 | Event::assertDispatched(BindFailed::class); 72 | Event::assertNotDispatched(Bound::class); 73 | } 74 | 75 | public function test_auth_fails_due_to_rules() 76 | { 77 | $dn = 'cn=John Doe,dc=local,dc=com'; 78 | 79 | $model = $this->getAuthenticatingModelMock($dn); 80 | 81 | $model->shouldReceive('getConnection')->once()->andReturn( 82 | m::mock(Connection::class, function ($connection) use ($dn) { 83 | $auth = m::mock(Guard::class); 84 | $auth->shouldReceive('attempt')->once()->withArgs([$dn, 'password'])->andReturnTrue(); 85 | 86 | $connection->shouldReceive('auth')->once()->andReturn($auth); 87 | }) 88 | ); 89 | 90 | $auth = new LdapUserAuthenticator([TestFailingLdapAuthRule::class]); 91 | 92 | Event::fake(); 93 | 94 | $this->assertFalse($auth->attempt($model, 'password')); 95 | 96 | Event::assertDispatched(Binding::class); 97 | Event::assertDispatched(Rejected::class); 98 | Event::assertNotDispatched(Bound::class); 99 | Event::assertNotDispatched(BindFailed::class); 100 | } 101 | 102 | public function test_eloquent_model_can_be_set() 103 | { 104 | $dn = 'cn=John Doe,dc=local,dc=com'; 105 | 106 | $model = $this->getAuthenticatingModelMock($dn); 107 | 108 | $model->shouldReceive('getConnection')->once()->andReturn( 109 | m::mock(Connection::class, function ($connection) use ($dn) { 110 | $auth = m::mock(Guard::class); 111 | $auth->shouldReceive('attempt')->once()->withArgs([$dn, 'password'])->andReturnTrue(); 112 | 113 | $connection->shouldReceive('auth')->once()->andReturn($auth); 114 | }) 115 | ); 116 | 117 | $auth = new LdapUserAuthenticator([TestLdapAuthRuleWithEloquentModel::class]); 118 | 119 | $auth->setEloquentModel(new TestLdapUserAuthenticatedModelStub); 120 | 121 | Event::fake(); 122 | 123 | $this->assertTrue($auth->attempt($model, 'password')); 124 | 125 | Event::assertDispatched(Binding::class); 126 | Event::assertDispatched(Bound::class); 127 | Event::assertNotDispatched(Rejected::class); 128 | Event::assertNotDispatched(BindFailed::class); 129 | } 130 | 131 | protected function getAuthenticatingModelMock($dn) 132 | { 133 | $model = m::mock(Model::class); 134 | $model->shouldReceive('getDn')->once()->andReturn($dn); 135 | 136 | return $model; 137 | } 138 | } 139 | 140 | class TestLdapUserAuthenticatedModelStub extends EloquentModel 141 | { 142 | // 143 | } 144 | 145 | class TestLdapAuthRuleWithEloquentModel implements Rule 146 | { 147 | public function passes(LdapRecord $user, ?Eloquent $model = null): bool 148 | { 149 | return ! is_null($model); 150 | } 151 | } 152 | 153 | class TestFailingLdapAuthRule implements Rule 154 | { 155 | public function passes(LdapRecord $user, ?Eloquent $model = null): bool 156 | { 157 | return false; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/Unit/LdapUserRepositoryTest.php: -------------------------------------------------------------------------------- 1 | createModel(); 17 | 18 | $this->assertInstanceOf(Entry::class, $model); 19 | } 20 | 21 | public function test_query_can_be_created() 22 | { 23 | $repository = new LdapUserRepository(Entry::class); 24 | 25 | $query = $repository->query(); 26 | 27 | $this->assertInstanceOf(Builder::class, $query); 28 | $this->assertInstanceOf(Entry::class, $query->getModel()); 29 | } 30 | 31 | public function test_query_selects_are_merged() 32 | { 33 | $repository = new LdapUserRepository(Entry::class); 34 | 35 | $this->assertEquals(['objectguid', '*'], $repository->query()->getSelects()); 36 | } 37 | 38 | public function test_find_by_credentials_returns_null_with_empty_array() 39 | { 40 | $repository = new LdapUserRepository(Entry::class); 41 | 42 | $this->assertNull($repository->findByCredentials()); 43 | } 44 | 45 | public function test_find_by_credentials_ignores_password() 46 | { 47 | $repository = m::mock(LdapUserRepository::class, function ($repository) { 48 | $query = m::mock(Builder::class); 49 | $query->shouldNotReceive('where'); 50 | $query->shouldReceive('first')->once()->andReturnNull(); 51 | 52 | $repository->makePartial()->shouldAllowMockingProtectedMethods(); 53 | $repository->shouldReceive('newModelQuery')->once()->andReturn($query); 54 | }); 55 | 56 | $this->assertNull($repository->findByCredentials(['password' => 'secret'])); 57 | } 58 | 59 | public function test_find_by_credentials_returns_model() 60 | { 61 | $user = new Entry; 62 | 63 | $repository = m::mock(LdapUserRepository::class, function ($repository) use ($user) { 64 | $query = m::mock(Builder::class); 65 | $query->shouldReceive('where')->with('username', 'foo')->andReturnSelf(); 66 | $query->shouldReceive('first')->once()->andReturn($user); 67 | 68 | $repository->makePartial()->shouldAllowMockingProtectedMethods(); 69 | $repository->shouldReceive('newModelQuery')->once()->andReturn($query); 70 | }); 71 | 72 | $this->assertSame($user, $repository->findByCredentials(['username' => 'foo'])); 73 | } 74 | 75 | public function test_find_by_credentials_with_fallback_returns_model() 76 | { 77 | $user = new Entry; 78 | 79 | $repository = m::mock(LdapUserRepository::class, function ($repository) use ($user) { 80 | $query = m::mock(Builder::class); 81 | $query->shouldReceive('where')->with('username', 'foo')->andReturnSelf(); 82 | $query->shouldReceive('first')->once()->andReturn($user); 83 | 84 | $repository->makePartial()->shouldAllowMockingProtectedMethods(); 85 | $repository->shouldReceive('newModelQuery')->once()->andReturn($query); 86 | }); 87 | 88 | $this->assertSame($user, $repository->findByCredentials(['username' => 'foo', 'fallback' => ['username' => 'foo']])); 89 | } 90 | 91 | public function test_find_by_attribute_and_value_returns_model() 92 | { 93 | $model = new Entry; 94 | 95 | $repository = m::mock(LdapUserRepository::class, function ($repository) use ($model) { 96 | $query = m::mock(Builder::class); 97 | $query->shouldReceive('findBy')->once()->with('foo', 'bar')->andReturn($model); 98 | 99 | $repository->makePartial()->shouldAllowMockingProtectedMethods(); 100 | $repository->shouldReceive('newModelQuery')->once()->andReturn($query); 101 | }); 102 | 103 | $this->assertSame($model, $repository->findBy('foo', 'bar')); 104 | } 105 | 106 | public function test_find_by_model_returns_model() 107 | { 108 | $model = new Entry; 109 | 110 | $repository = m::mock(LdapUserRepository::class, function ($repository) use ($model) { 111 | $query = m::mock(Builder::class); 112 | $query->shouldReceive('findByGuid')->once()->with('guid')->andReturn($model); 113 | 114 | $repository->makePartial()->shouldAllowMockingProtectedMethods(); 115 | $repository->shouldReceive('newModelQuery')->once()->andReturn($query); 116 | }); 117 | 118 | $authenticatable = m::mock(LdapAuthenticatable::class); 119 | $authenticatable->shouldReceive('getLdapGuid')->once()->andReturn('guid'); 120 | 121 | $this->assertSame($model, $repository->findByModel($authenticatable)); 122 | } 123 | 124 | public function test_find_by_model_returns_null_when_no_guid_is_present() 125 | { 126 | $repository = m::mock(LdapUserRepository::class, function ($repository) { 127 | $repository->makePartial()->shouldAllowMockingProtectedMethods(); 128 | $repository->shouldNotReceive('newModelQuery'); 129 | }); 130 | 131 | $authenticatable = m::mock(LdapAuthenticatable::class); 132 | $authenticatable->shouldReceive('getLdapGuid')->once()->andReturnNull(); 133 | 134 | $this->assertNull($repository->findByModel($authenticatable)); 135 | } 136 | 137 | public function test_find_by_guid_returns_model() 138 | { 139 | $model = new Entry; 140 | 141 | $repository = m::mock(LdapUserRepository::class, function ($repository) use ($model) { 142 | $query = m::mock(Builder::class); 143 | $query->shouldReceive('findByGuid')->once()->with('guid')->andReturn($model); 144 | 145 | $repository->makePartial()->shouldAllowMockingProtectedMethods(); 146 | $repository->shouldReceive('newModelQuery')->once()->andReturn($query); 147 | }); 148 | 149 | $this->assertSame($model, $repository->findByGuid('guid')); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /tests/Unit/ListenForLdapBindFailureTest.php: -------------------------------------------------------------------------------- 1 | listenForLdapBindFailure(); 19 | } 20 | 21 | protected function username() 22 | { 23 | return 'email'; 24 | } 25 | 26 | protected function makeDiagnosticErrorMessage($code) 27 | { 28 | return "80090308: LdapErr: DSID-0C09030B, comment: AcceptSecurityContext error, data {$code}, v893"; 29 | } 30 | 31 | public function test_validation_exception_is_not_thrown_when_no_error_is_given() 32 | { 33 | $fake = DirectoryEmulator::setup('default'); 34 | 35 | $this->assertFalse($fake->auth()->attempt('user', 'secret')); 36 | } 37 | 38 | public function test_validation_exception_is_not_thrown_when_invalid_credentials_is_returned() 39 | { 40 | $fake = DirectoryEmulator::setup('default'); 41 | 42 | /** @var \LdapRecord\Testing\LdapFake $ldap */ 43 | $ldap = $fake->getLdapConnection(); 44 | $ldap->shouldReturnDiagnosticMessage(null); 45 | $ldap->shouldReturnError('Invalid credentials'); 46 | 47 | $this->assertFalse($fake->auth()->attempt('user', 'secret')); 48 | } 49 | 50 | public function test_validation_exception_is_thrown_on_lost_connection() 51 | { 52 | $fake = DirectoryEmulator::setup('default'); 53 | 54 | /** @var \LdapRecord\Testing\LdapFake $ldap */ 55 | $ldap = $fake->getLdapConnection(); 56 | $ldap->shouldReturnError("Can't contact LDAP server"); 57 | 58 | $this->expectException(ValidationException::class); 59 | 60 | $fake->auth()->attempt('user', 'secret'); 61 | } 62 | 63 | /** 64 | * @testWith 65 | * ["525"] 66 | * ["530"] 67 | * ["531"] 68 | * ["532"] 69 | * ["533"] 70 | * ["701"] 71 | * ["773"] 72 | * ["775"] 73 | */ 74 | public function test_directory_throws_validation_error_with_code($code) 75 | { 76 | $fake = DirectoryEmulator::setup('default'); 77 | 78 | /** @var \LdapRecord\Testing\LdapFake $ldap */ 79 | $ldap = $fake->getLdapConnection(); 80 | $ldap->shouldReturnDiagnosticMessage($this->makeDiagnosticErrorMessage($code)); 81 | 82 | $this->expectException(ValidationException::class); 83 | 84 | $fake->auth()->attempt('user', 'secret'); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Unit/ValidatorRuleOnlyImportedTest.php: -------------------------------------------------------------------------------- 1 | assertFalse( 15 | (new OnlyImported)->passes(new Entry, new TestNonExistingOnlyImportedRuleModelStub) 16 | ); 17 | 18 | $this->assertTrue( 19 | (new OnlyImported)->passes(new Entry, new TestExistingOnlyImportedRuleModelStub) 20 | ); 21 | } 22 | } 23 | 24 | class TestNonExistingOnlyImportedRuleModelStub extends Model 25 | { 26 | public $exists = false; 27 | } 28 | 29 | class TestExistingOnlyImportedRuleModelStub extends Model 30 | { 31 | public $exists = true; 32 | } 33 | -------------------------------------------------------------------------------- /tests/Unit/ValidatorTest.php: -------------------------------------------------------------------------------- 1 | assertEmpty((new Validator)->getRules()); 21 | } 22 | 23 | public function test_rules_can_be_added() 24 | { 25 | $rule = new TestPassingRule; 26 | $validator = new Validator([$rule]); 27 | 28 | $this->assertCount(1, $validator->getRules()); 29 | $this->assertSame($rule, $validator->getRules()[0]); 30 | } 31 | 32 | public function test_passing_validation_rule() 33 | { 34 | Event::fake(RulePassed::class); 35 | 36 | $rule = new TestPassingRule; 37 | $this->assertTrue((new Validator([$rule]))->passes(new Entry, new TestRuleModelStub)); 38 | 39 | Event::assertDispatched(RulePassed::class); 40 | } 41 | 42 | public function test_failing_validation_rule() 43 | { 44 | Event::fake(RuleFailed::class); 45 | 46 | $rule = new TestFailingRule; 47 | $this->assertFalse((new Validator([$rule]))->passes(new Entry, new TestRuleModelStub)); 48 | 49 | Event::assertDispatched(RuleFailed::class); 50 | } 51 | 52 | public function test_all_rules_are_validated() 53 | { 54 | $rule = new TestPassingRule; 55 | 56 | $validator = new Validator([$rule]); 57 | 58 | $this->assertTrue($validator->passes(new Entry, new TestRuleModelStub)); 59 | 60 | $validator->addRule(new TestFailingRule); 61 | 62 | $this->assertFalse($validator->passes(new Entry, new TestRuleModelStub)); 63 | } 64 | } 65 | 66 | class TestRuleModelStub extends Model 67 | { 68 | // 69 | } 70 | 71 | class TestPassingRule implements Rule 72 | { 73 | public function passes(LdapRecord $user, ?Eloquent $model = null): bool 74 | { 75 | return true; 76 | } 77 | } 78 | 79 | class TestFailingRule implements Rule 80 | { 81 | public function passes(LdapRecord $user, ?Eloquent $model = null): bool 82 | { 83 | return false; 84 | } 85 | } 86 | --------------------------------------------------------------------------------