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