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\Protocol\ClientProtocolHandler;
13
14use FreeDSx\Ldap\Exception\ConnectionException;
15use FreeDSx\Ldap\Exception\FilterParseException;
16use FreeDSx\Ldap\Exception\OperationException;
17use FreeDSx\Ldap\Exception\ReferralException;
18use FreeDSx\Ldap\Exception\RuntimeException;
19use FreeDSx\Ldap\Exception\SkipReferralException;
20use FreeDSx\Ldap\LdapClient;
21use FreeDSx\Ldap\LdapUrl;
22use FreeDSx\Ldap\Operation\LdapResult;
23use FreeDSx\Ldap\Operation\Request\BindRequest;
24use FreeDSx\Ldap\Operation\Request\DnRequestInterface;
25use FreeDSx\Ldap\Operation\Request\RequestInterface;
26use FreeDSx\Ldap\Operation\Request\SearchRequest;
27use FreeDSx\Ldap\Operation\ResultCode;
28use FreeDSx\Ldap\Protocol\LdapMessageRequest;
29use FreeDSx\Ldap\Protocol\LdapMessageResponse;
30use FreeDSx\Ldap\Protocol\Queue\ClientQueue;
31use FreeDSx\Ldap\Protocol\ReferralContext;
32use FreeDSx\Ldap\ReferralChaserInterface;
33use FreeDSx\Ldap\Search\Filters;
34use Throwable;
35use function count;
36
37/**
38 * Logic for handling referrals.
39 *
40 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
41 */
42class ClientReferralHandler implements ResponseHandlerInterface
43{
44    /**
45     * @var array
46     */
47    protected $options = [];
48
49    /**
50     * @param LdapMessageRequest $messageTo
51     * @param LdapMessageResponse $messageFrom
52     * @param ClientQueue $queue
53     * @param array $options
54     * @return LdapMessageResponse|null
55     * @throws OperationException
56     * @throws ReferralException
57     */
58    public function handleResponse(
59        LdapMessageRequest $messageTo,
60        LdapMessageResponse $messageFrom,
61        ClientQueue $queue,
62        array $options
63    ): ?LdapMessageResponse {
64        $this->options = $options;
65        $result = $messageFrom->getResponse();
66        switch ($this->options['referral']) {
67            case 'throw':
68                $message = $result instanceof LdapResult ? $result->getDiagnosticMessage() : 'Referral response encountered.';
69                $referrals = $result instanceof LdapResult ? $result->getReferrals() : [];
70
71                throw new ReferralException($message, ...$referrals);
72            case 'follow':
73                return $this->followReferral($messageTo, $messageFrom);
74            default:
75                throw new RuntimeException(sprintf(
76                    'The referral option "%s" is invalid.',
77                    $this->options['referral']
78                ));
79        }
80    }
81
82    /**
83     * @param LdapMessageRequest $messageTo
84     * @param LdapMessageResponse $messageFrom
85     * @return LdapMessageResponse|null
86     * @throws OperationException
87     * @throws SkipReferralException
88     * @throws FilterParseException
89     */
90    protected function followReferral(LdapMessageRequest $messageTo, LdapMessageResponse $messageFrom): ?LdapMessageResponse
91    {
92        $referralChaser = $this->options['referral_chaser'];
93        if (!($referralChaser === null || $referralChaser instanceof ReferralChaserInterface)) {
94            throw new RuntimeException(sprintf(
95                'The referral_chaser must implement "%s" or be null.',
96                ReferralChaserInterface::class
97            ));
98        }
99        if (!$messageFrom->getResponse() instanceof LdapResult || count($messageFrom->getResponse()->getReferrals()) === 0) {
100            throw new OperationException(
101                'Encountered a referral request, but no referrals were supplied.',
102                ResultCode::REFERRAL
103            );
104        }
105
106        # Initialize a referral context to track the referrals we have already visited as well as count.
107        if (!isset($this->options['_referral_context'])) {
108            $this->options['_referral_context'] = new ReferralContext();
109        }
110
111        foreach ($messageFrom->getResponse()->getReferrals() as $referral) {
112            # We must skip referrals we have already visited to avoid a referral loop
113            if ($this->options['_referral_context']->hasReferral($referral)) {
114                continue;
115            }
116
117            $this->options['_referral_context']->addReferral($referral);
118            if ($this->options['_referral_context']->count() > $this->options['referral_limit']) {
119                throw new OperationException(sprintf(
120                    'The referral limit of %s has been reached.',
121                    $this->options['referral_limit']
122                ));
123            }
124
125            $bind = null;
126            try {
127                # @todo Remove the bind parameter from the interface in a future release.
128                if ($referralChaser !== null) {
129                    $bind = $referralChaser->chase($messageTo, $referral, null);
130                }
131            } catch (SkipReferralException $e) {
132                continue;
133            }
134            $options = $this->options;
135            $options['servers'] = $referral->getHost() !== null ? [$referral->getHost()] : [];
136            $options['port'] = $referral->getPort() ?? 389;
137            $options['use_ssl'] = $referral->getUseSsl();
138
139            # Each referral could potentially modify different aspects of the request, depending on the URL. Clone it
140            # here, merge the options, then use that request to send to LDAP. This makes sure we don't accidentally mix
141            # options from different referrals.
142            $request = clone $messageTo->getRequest();
143            $this->mergeReferralOptions($request, $referral);
144
145            try {
146                $client = $referralChaser !== null ? $referralChaser->client($options) : new LdapClient($options);
147
148                # If we have a referral on a bind request, then do not bind initially.
149                #
150                # It's not clear that this should even be allowed, though RFC 4511 makes no indication that referrals
151                # should not be followed on a bind request. The problem is that while we bind on a different server,
152                # this client continues on with a different bind state, which seems confusing / problematic.
153                if ($bind !== null && !$messageTo->getRequest() instanceof BindRequest) {
154                    $client->send($bind);
155                }
156
157                $response = $client->send($messageTo->getRequest(), ...$messageTo->controls());
158
159                return $response;
160                # Skip referrals that fail due to connection issues and not other issues
161            } catch (ConnectionException $e) {
162                continue;
163                # If the referral encountered other referrals but exhausted them, continue to the next one.
164            } catch (OperationException $e) {
165                if ($e->getCode() === ResultCode::REFERRAL) {
166                    continue;
167                }
168                # Other operation errors should bubble up, so throw it
169                throw  $e;
170            } catch (Throwable $e) {
171                throw $e;
172            }
173        }
174
175        # If we have exhausted all referrals consider it an operation exception.
176        throw new OperationException(sprintf(
177            'All referral attempts have been exhausted. %s',
178            $messageFrom->getResponse()->getDiagnosticMessage()
179        ), ResultCode::REFERRAL);
180    }
181
182    /**
183     * @param RequestInterface $request
184     * @param LdapUrl $referral
185     * @throws FilterParseException
186     */
187    protected function mergeReferralOptions(RequestInterface $request, LdapUrl $referral): void
188    {
189        if ($referral->getDn() !== null && $request instanceof SearchRequest) {
190            $request->setBaseDn($referral->getDn());
191        } elseif ($referral->getDn() !== null && $request instanceof DnRequestInterface) {
192            $request->setDn($referral->getDn());
193        }
194
195        if ($referral->getScope() !== null && $request instanceof SearchRequest) {
196            if ($referral->getScope() === LdapUrl::SCOPE_SUB) {
197                $request->setScope(SearchRequest::SCOPE_WHOLE_SUBTREE);
198            } elseif ($referral->getScope() === LdapUrl::SCOPE_BASE) {
199                $request->setScope(SearchRequest::SCOPE_SINGLE_LEVEL);
200            } else {
201                $request->setScope(SearchRequest::SCOPE_BASE_OBJECT);
202            }
203        }
204
205        if ($referral->getFilter() !== null && $request instanceof SearchRequest) {
206            $request->setFilter(Filters::raw($referral->getFilter()));
207        }
208    }
209}
210