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