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