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