1<?php 2 3/** 4 * This file is part of the FreeDSx LDAP package. 5 * 6 * (c) Chad Sikorra <Chad.Sikorra@gmail.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace FreeDSx\Ldap; 13 14use FreeDSx\Ldap\Control\Control; 15use FreeDSx\Ldap\Control\ControlBag; 16use FreeDSx\Ldap\Control\Sorting\SortingControl; 17use FreeDSx\Ldap\Control\Sorting\SortKey; 18use FreeDSx\Ldap\Entry\Dn; 19use FreeDSx\Ldap\Entry\Entries; 20use FreeDSx\Ldap\Entry\Entry; 21use FreeDSx\Ldap\Entry\Rdn; 22use FreeDSx\Ldap\Exception\OperationException; 23use FreeDSx\Ldap\Operation\Request\ExtendedRequest; 24use FreeDSx\Ldap\Operation\Request\RequestInterface; 25use FreeDSx\Ldap\Operation\Request\SearchRequest; 26use FreeDSx\Ldap\Operation\ResultCode; 27use FreeDSx\Ldap\Protocol\ClientProtocolHandler; 28use FreeDSx\Ldap\Protocol\LdapMessageResponse; 29use FreeDSx\Ldap\Search\DirSync; 30use FreeDSx\Ldap\Search\Filter\FilterInterface; 31use FreeDSx\Ldap\Search\Paging; 32use FreeDSx\Ldap\Search\RangeRetrieval; 33use FreeDSx\Ldap\Search\Vlv; 34use FreeDSx\Sasl\Exception\SaslException; 35 36/** 37 * The LDAP client. 38 * 39 * @author Chad Sikorra <Chad.Sikorra@gmail.com> 40 */ 41class LdapClient 42{ 43 public const REFERRAL_IGNORE = 'ignore'; 44 45 public const REFERRAL_FOLLOW = 'follow'; 46 47 public const REFERRAL_THROW = 'throw'; 48 49 /** 50 * @var array 51 */ 52 protected $options = [ 53 'version' => 3, 54 'servers' => [], 55 'port' => 389, 56 'transport' => 'tcp', 57 'base_dn' => null, 58 'page_size' => 1000, 59 'use_ssl' => false, 60 'ssl_validate_cert' => true, 61 'ssl_allow_self_signed' => null, 62 'ssl_ca_cert' => null, 63 'ssl_peer_name' => null, 64 'timeout_connect' => 3, 65 'timeout_read' => 10, 66 'referral' => 'throw', 67 'referral_chaser' => null, 68 'referral_limit' => 10, 69 ]; 70 71 /** 72 * @var ClientProtocolHandler|null 73 */ 74 protected $handler; 75 76 /** 77 * @param array $options 78 */ 79 public function __construct(array $options = []) 80 { 81 $this->options = array_merge($this->options, $options); 82 } 83 84 /** 85 * A Simple Bind to LDAP with a username and password. 86 * 87 * @param string $username 88 * @param string $password 89 * @return LdapMessageResponse 90 * @throws Exception\BindException 91 */ 92 public function bind(string $username, string $password): LdapMessageResponse 93 { 94 return $this->sendAndReceive(Operations::bind($username, $password)->setVersion($this->options['version'])); 95 } 96 97 /** 98 * A SASL Bind to LDAP with SASL options and an optional specific mechanism type. 99 * 100 * @param array $options The SASL options (ie. ['username' => '...', 'password' => '...']) 101 * @param string $mechanism A specific mechanism to use. If none is supplied, one will be selected. 102 * @return LdapMessageResponse 103 * @throws Exception\BindException 104 * @throws OperationException 105 * @throws SaslException 106 */ 107 public function bindSasl(array $options = [], string $mechanism = ''): LdapMessageResponse 108 { 109 return $this->sendAndReceive(Operations::bindSasl($options, $mechanism)->setVersion($this->options['version'])); 110 } 111 112 /** 113 * Check whether an entry matches a certain attribute and value. 114 * 115 * @param string|Dn $dn 116 * @param string $attributeName 117 * @param string $value 118 * @param Control ...$controls 119 * @return bool 120 * @throws OperationException 121 */ 122 public function compare($dn, string $attributeName, string $value, Control ...$controls): bool 123 { 124 /** @var \FreeDSx\Ldap\Operation\Response\CompareResponse $response */ 125 $response = $this->sendAndReceive(Operations::compare($dn, $attributeName, $value), ...$controls)->getResponse(); 126 127 return $response->getResultCode() === ResultCode::COMPARE_TRUE; 128 } 129 130 /** 131 * Create a new entry. 132 * 133 * @param Entry $entry 134 * @param Control ...$controls 135 * @return LdapMessageResponse 136 * @throws OperationException 137 */ 138 public function create(Entry $entry, Control ...$controls): LdapMessageResponse 139 { 140 $response = $this->sendAndReceive(Operations::add($entry), ...$controls); 141 $entry->changes()->reset(); 142 143 return $response; 144 } 145 146 /** 147 * Read an entry. 148 * 149 * @param string $entry 150 * @param string[] $attributes 151 * @param Control ...$controls 152 * @return Entry|null 153 * @throws OperationException 154 */ 155 public function read(string $entry = '', $attributes = [], Control ...$controls): ?Entry 156 { 157 try { 158 return $this->readOrFail($entry, $attributes, ...$controls); 159 } catch (Exception\OperationException $e) { 160 if ($e->getCode() === ResultCode::NO_SUCH_OBJECT) { 161 return null; 162 } 163 throw $e; 164 } 165 } 166 167 /** 168 * Read an entry from LDAP. If the entry is not found an OperationException is thrown. 169 * 170 * @param string $entry 171 * @param string[] $attributes 172 * @param Control ...$controls 173 * @return Entry 174 * @throws OperationException 175 */ 176 public function readOrFail(string $entry = '', $attributes = [], Control ...$controls): Entry 177 { 178 $entryObj = $this->search(Operations::read($entry, ...$attributes), ...$controls)->first(); 179 if ($entryObj === null) { 180 throw new OperationException(sprintf( 181 'The entry "%s" was not found.', 182 $entry 183 ), ResultCode::NO_SUCH_OBJECT); 184 } 185 186 return $entryObj; 187 } 188 189 /** 190 * Delete an entry. 191 * 192 * @param string $entry 193 * @param Control ...$controls 194 * @return LdapMessageResponse 195 * @throws OperationException 196 */ 197 public function delete(string $entry, Control ...$controls): LdapMessageResponse 198 { 199 return $this->sendAndReceive(Operations::delete($entry), ...$controls); 200 } 201 202 /** 203 * Update an existing entry. 204 * 205 * @param Entry $entry 206 * @param Control ...$controls 207 * @return LdapMessageResponse 208 * @throws OperationException 209 */ 210 public function update(Entry $entry, Control ...$controls): LdapMessageResponse 211 { 212 $response = $this->sendAndReceive(Operations::modify($entry->getDn(), ...$entry->changes()), ...$controls); 213 $entry->changes()->reset(); 214 215 return $response; 216 } 217 218 /** 219 * Move an entry to a new location. 220 * 221 * @param string|Entry $dn 222 * @param string|Entry $newParentDn 223 * @return LdapMessageResponse 224 * @throws OperationException 225 */ 226 public function move($dn, $newParentDn): LdapMessageResponse 227 { 228 return $this->sendAndReceive(Operations::move($dn, $newParentDn)); 229 } 230 231 /** 232 * Rename an entry (changing the RDN). 233 * 234 * @param string|Entry $dn 235 * @param string|Rdn $newRdn 236 * @param bool $deleteOldRdn 237 * @return LdapMessageResponse 238 * @throws OperationException 239 */ 240 public function rename($dn, $newRdn, bool $deleteOldRdn = true): LdapMessageResponse 241 { 242 return $this->sendAndReceive(Operations::rename($dn, $newRdn, $deleteOldRdn)); 243 } 244 245 /** 246 * Send a search response and return the entries. 247 * 248 * @param SearchRequest $request 249 * @param Control ...$controls 250 * @return Entries 251 * @throws OperationException 252 */ 253 public function search(SearchRequest $request, Control ...$controls): Entries 254 { 255 /** @var \FreeDSx\Ldap\Operation\Response\SearchResponse $response */ 256 $response = $this->sendAndReceive($request, ...$controls)->getResponse(); 257 258 return $response->getEntries(); 259 } 260 261 /** 262 * A helper for performing a paging based search. 263 * 264 * @param SearchRequest $search 265 * @param null|int $size 266 * @return Paging 267 */ 268 public function paging(SearchRequest $search, ?int $size = null): Paging 269 { 270 return new Paging($this, $search, $size ?? $this->options['page_size']); 271 } 272 273 /** 274 * A helper for performing a VLV (Virtual List View) based search. 275 * 276 * @param SearchRequest $search 277 * @param SortingControl|string|SortKey $sort 278 * @param int $afterCount 279 * @return Vlv 280 */ 281 public function vlv(SearchRequest $search, $sort, int $afterCount): Vlv 282 { 283 return new Vlv($this, $search, $sort, $afterCount); 284 } 285 286 /** 287 * A helper for performing a DirSync search operation against AD. 288 * 289 * @param string|null $rootNc 290 * @param FilterInterface|null $filter 291 * @param mixed ...$attributes 292 * @return DirSync 293 */ 294 public function dirSync(?string $rootNc = null, FilterInterface $filter = null, ...$attributes): DirSync 295 { 296 return new DirSync($this, $rootNc, $filter, ...$attributes); 297 } 298 299 /** 300 * Send a request operation to LDAP. This may return null if the request expects no response. 301 * 302 * @param RequestInterface $request 303 * @param Control ...$controls 304 * @return LdapMessageResponse|null 305 * @throws Exception\BindException 306 * @throws Exception\ConnectionException 307 * @throws OperationException 308 */ 309 public function send(RequestInterface $request, Control ...$controls): ?LdapMessageResponse 310 { 311 return $this->handler()->send($request, ...$controls); 312 } 313 314 /** 315 * Send a request to LDAP that expects a response. If none is received an OperationException is thrown. 316 * 317 * @param RequestInterface $request 318 * @param Control ...$controls 319 * @return LdapMessageResponse 320 * @throws Exception\BindException 321 * @throws Exception\ConnectionException 322 * @throws OperationException 323 */ 324 public function sendAndReceive(RequestInterface $request, Control ...$controls): LdapMessageResponse 325 { 326 $response = $this->send($request, ...$controls); 327 if ($response === null) { 328 throw new OperationException('Expected an LDAP message response, but none was received.'); 329 } 330 331 return $response; 332 } 333 334 /** 335 * Issue a startTLS to encrypt the LDAP connection. 336 * 337 * @return $this 338 * @throws Exception\ConnectionException 339 * @throws OperationException 340 */ 341 public function startTls(): self 342 { 343 $this->send(Operations::extended(ExtendedRequest::OID_START_TLS)); 344 345 return $this; 346 } 347 348 /** 349 * Unbind and close the LDAP TCP connection. 350 * 351 * @return $this 352 * @throws Exception\ConnectionException 353 * @throws OperationException 354 */ 355 public function unbind(): self 356 { 357 $this->send(Operations::unbind()); 358 359 return $this; 360 } 361 362 /** 363 * Perform a whoami request and get the returned value. 364 * 365 * @return string 366 * @throws OperationException 367 */ 368 public function whoami(): ?string 369 { 370 /** @var \FreeDSx\Ldap\Operation\Response\ExtendedResponse $response */ 371 $response = $this->sendAndReceive(Operations::whoami())->getResponse(); 372 373 return $response->getValue(); 374 } 375 376 /** 377 * Get a helper class for handling ranged attributes. 378 * 379 * @return RangeRetrieval 380 */ 381 public function range(): RangeRetrieval 382 { 383 return new RangeRetrieval($this); 384 } 385 386 /** 387 * Access to add/set/remove/reset the controls to be used for each request. If you want request specific controls in 388 * addition to these, then pass them as a parameter to the send() method. 389 * 390 * @return ControlBag 391 */ 392 public function controls(): ControlBag 393 { 394 return $this->handler()->controls(); 395 } 396 397 /** 398 * Get the options currently set. 399 * 400 * @return array 401 */ 402 public function getOptions(): array 403 { 404 return $this->options; 405 } 406 407 /** 408 * Merge a set of options. Depending on what you are changing, you many want to set the $forceDisconnect param to 409 * true, which forces the client to disconnect. After which you would have to manually bind again. 410 * 411 * @param array $options The set of options to merge in. 412 * @param bool $forceDisconnect Whether the client should disconnect; forcing a manual re-connect / bind. This is 413 * false by default. 414 * @return $this 415 */ 416 public function setOptions( 417 array $options, 418 bool $forceDisconnect = false 419 ): self { 420 $this->options = array_merge( 421 $this->options, 422 $options 423 ); 424 if ($forceDisconnect) { 425 $this->unbindIfConnected(); 426 } 427 428 return $this; 429 } 430 431 /** 432 * @param ClientProtocolHandler|null $handler 433 * @return $this 434 */ 435 public function setProtocolHandler(ClientProtocolHandler $handler = null): self 436 { 437 $this->handler = $handler; 438 439 return $this; 440 } 441 442 /** 443 * A simple check to determine if this client has an established connection to a server. 444 * 445 * @return bool 446 */ 447 public function isConnected(): bool 448 { 449 return ($this->handler !== null && $this->handler->isConnected()); 450 } 451 452 /** 453 * Try to clean-up if needed. 454 * 455 * @throws Exception\ConnectionException 456 * @throws OperationException 457 */ 458 public function __destruct() 459 { 460 $this->unbindIfConnected(); 461 } 462 463 protected function handler(): ClientProtocolHandler 464 { 465 if ($this->handler === null) { 466 $this->handler = new Protocol\ClientProtocolHandler($this->options); 467 } 468 469 return $this->handler; 470 } 471 472 /** 473 * @throws Exception\ConnectionException 474 * @throws OperationException 475 */ 476 private function unbindIfConnected(): void 477 { 478 if ($this->handler !== null && $this->handler->isConnected()) { 479 $this->unbind(); 480 } 481 } 482} 483