source: branches/version-2_13-dev/data/module/HTTP/Request2/Adapter/Socket.php @ 23125

Revision 23125, 39.6 KB checked in by kimoto, 11 years ago (diff)

#2275 PEAR更新
不要なrequire_onceの削除
レガシーなPEARモジュールは使わない
SearchReplace?.phpのパスが間違っているので修正

Line 
1<?php
2/**
3 * Socket-based adapter for HTTP_Request2
4 *
5 * PHP version 5
6 *
7 * LICENSE:
8 *
9 * Copyright (c) 2008-2012, Alexey Borzov <avb@php.net>
10 * All rights reserved.
11 *
12 * Redistribution and use in source and binary forms, with or without
13 * modification, are permitted provided that the following conditions
14 * are met:
15 *
16 *    * Redistributions of source code must retain the above copyright
17 *      notice, this list of conditions and the following disclaimer.
18 *    * Redistributions in binary form must reproduce the above copyright
19 *      notice, this list of conditions and the following disclaimer in the
20 *      documentation and/or other materials provided with the distribution.
21 *    * The names of the authors may not be used to endorse or promote products
22 *      derived from this software without specific prior written permission.
23 *
24 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
25 * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
26 * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
27 * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
28 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
29 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
30 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
31 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
32 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
33 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
34 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
35 *
36 * @category HTTP
37 * @package  HTTP_Request2
38 * @author   Alexey Borzov <avb@php.net>
39 * @license  http://opensource.org/licenses/bsd-license.php New BSD License
40 * @version  SVN: $Id: Socket.php 324953 2012-04-08 07:24:12Z avb $
41 * @link     http://pear.php.net/package/HTTP_Request2
42 */
43
44/** Base class for HTTP_Request2 adapters */
45require_once 'HTTP/Request2/Adapter.php';
46
47/** Socket wrapper class */
48require_once 'HTTP/Request2/SocketWrapper.php';
49
50/**
51 * Socket-based adapter for HTTP_Request2
52 *
53 * This adapter uses only PHP sockets and will work on almost any PHP
54 * environment. Code is based on original HTTP_Request PEAR package.
55 *
56 * @category HTTP
57 * @package  HTTP_Request2
58 * @author   Alexey Borzov <avb@php.net>
59 * @license  http://opensource.org/licenses/bsd-license.php New BSD License
60 * @version  Release: 2.1.1
61 * @link     http://pear.php.net/package/HTTP_Request2
62 */
63class HTTP_Request2_Adapter_Socket extends HTTP_Request2_Adapter
64{
65    /**
66     * Regular expression for 'token' rule from RFC 2616
67     */
68    const REGEXP_TOKEN = '[^\x00-\x1f\x7f-\xff()<>@,;:\\\\"/\[\]?={}\s]+';
69
70    /**
71     * Regular expression for 'quoted-string' rule from RFC 2616
72     */
73    const REGEXP_QUOTED_STRING = '"(?:\\\\.|[^\\\\"])*"';
74
75    /**
76     * Connected sockets, needed for Keep-Alive support
77     * @var  array
78     * @see  connect()
79     */
80    protected static $sockets = array();
81
82    /**
83     * Data for digest authentication scheme
84     *
85     * The keys for the array are URL prefixes.
86     *
87     * The values are associative arrays with data (realm, nonce, nonce-count,
88     * opaque...) needed for digest authentication. Stored here to prevent making
89     * duplicate requests to digest-protected resources after we have already
90     * received the challenge.
91     *
92     * @var  array
93     */
94    protected static $challenges = array();
95
96    /**
97     * Connected socket
98     * @var  HTTP_Request2_SocketWrapper
99     * @see  connect()
100     */
101    protected $socket;
102
103    /**
104     * Challenge used for server digest authentication
105     * @var  array
106     */
107    protected $serverChallenge;
108
109    /**
110     * Challenge used for proxy digest authentication
111     * @var  array
112     */
113    protected $proxyChallenge;
114
115    /**
116     * Remaining length of the current chunk, when reading chunked response
117     * @var  integer
118     * @see  readChunked()
119     */
120    protected $chunkLength = 0;
121
122    /**
123     * Remaining amount of redirections to follow
124     *
125     * Starts at 'max_redirects' configuration parameter and is reduced on each
126     * subsequent redirect. An Exception will be thrown once it reaches zero.
127     *
128     * @var  integer
129     */
130    protected $redirectCountdown = null;
131
132    /**
133     * Sends request to the remote server and returns its response
134     *
135     * @param HTTP_Request2 $request HTTP request message
136     *
137     * @return   HTTP_Request2_Response
138     * @throws   HTTP_Request2_Exception
139     */
140    public function sendRequest(HTTP_Request2 $request)
141    {
142        $this->request = $request;
143
144        try {
145            $keepAlive = $this->connect();
146            $headers   = $this->prepareHeaders();
147            $this->socket->write($headers);
148            // provide request headers to the observer, see request #7633
149            $this->request->setLastEvent('sentHeaders', $headers);
150            $this->writeBody();
151
152            $response = $this->readResponse();
153
154            if ($jar = $request->getCookieJar()) {
155                $jar->addCookiesFromResponse($response, $request->getUrl());
156            }
157
158            if (!$this->canKeepAlive($keepAlive, $response)) {
159                $this->disconnect();
160            }
161
162            if ($this->shouldUseProxyDigestAuth($response)) {
163                return $this->sendRequest($request);
164            }
165            if ($this->shouldUseServerDigestAuth($response)) {
166                return $this->sendRequest($request);
167            }
168            if ($authInfo = $response->getHeader('authentication-info')) {
169                $this->updateChallenge($this->serverChallenge, $authInfo);
170            }
171            if ($proxyInfo = $response->getHeader('proxy-authentication-info')) {
172                $this->updateChallenge($this->proxyChallenge, $proxyInfo);
173            }
174
175        } catch (Exception $e) {
176            $this->disconnect();
177        }
178
179        unset($this->request, $this->requestBody);
180
181        if (!empty($e)) {
182            $this->redirectCountdown = null;
183            throw $e;
184        }
185
186        if (!$request->getConfig('follow_redirects') || !$response->isRedirect()) {
187            $this->redirectCountdown = null;
188            return $response;
189        } else {
190            return $this->handleRedirect($request, $response);
191        }
192    }
193
194    /**
195     * Connects to the remote server
196     *
197     * @return   bool    whether the connection can be persistent
198     * @throws   HTTP_Request2_Exception
199     */
200    protected function connect()
201    {
202        $secure  = 0 == strcasecmp($this->request->getUrl()->getScheme(), 'https');
203        $tunnel  = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod();
204        $headers = $this->request->getHeaders();
205        $reqHost = $this->request->getUrl()->getHost();
206        if (!($reqPort = $this->request->getUrl()->getPort())) {
207            $reqPort = $secure? 443: 80;
208        }
209
210        $httpProxy = $socksProxy = false;
211        if (!($host = $this->request->getConfig('proxy_host'))) {
212            $host = $reqHost;
213            $port = $reqPort;
214        } else {
215            if (!($port = $this->request->getConfig('proxy_port'))) {
216                throw new HTTP_Request2_LogicException(
217                    'Proxy port not provided',
218                    HTTP_Request2_Exception::MISSING_VALUE
219                );
220            }
221            if ('http' == ($type = $this->request->getConfig('proxy_type'))) {
222                $httpProxy = true;
223            } elseif ('socks5' == $type) {
224                $socksProxy = true;
225            } else {
226                throw new HTTP_Request2_NotImplementedException(
227                    "Proxy type '{$type}' is not supported"
228                );
229            }
230        }
231
232        if ($tunnel && !$httpProxy) {
233            throw new HTTP_Request2_LogicException(
234                "Trying to perform CONNECT request without proxy",
235                HTTP_Request2_Exception::MISSING_VALUE
236            );
237        }
238        if ($secure && !in_array('ssl', stream_get_transports())) {
239            throw new HTTP_Request2_LogicException(
240                'Need OpenSSL support for https:// requests',
241                HTTP_Request2_Exception::MISCONFIGURATION
242            );
243        }
244
245        // RFC 2068, section 19.7.1: A client MUST NOT send the Keep-Alive
246        // connection token to a proxy server...
247        if ($httpProxy && !$secure && !empty($headers['connection'])
248            && 'Keep-Alive' == $headers['connection']
249        ) {
250            $this->request->setHeader('connection');
251        }
252
253        $keepAlive = ('1.1' == $this->request->getConfig('protocol_version') &&
254                      empty($headers['connection'])) ||
255                     (!empty($headers['connection']) &&
256                      'Keep-Alive' == $headers['connection']);
257
258        $options = array();
259        if ($secure || $tunnel) {
260            foreach ($this->request->getConfig() as $name => $value) {
261                if ('ssl_' == substr($name, 0, 4) && null !== $value) {
262                    if ('ssl_verify_host' == $name) {
263                        if ($value) {
264                            $options['CN_match'] = $reqHost;
265                        }
266                    } else {
267                        $options[substr($name, 4)] = $value;
268                    }
269                }
270            }
271            ksort($options);
272        }
273
274        // Use global request timeout if given, see feature requests #5735, #8964
275        if ($timeout = $this->request->getConfig('timeout')) {
276            $deadline = time() + $timeout;
277        } else {
278            $deadline = null;
279        }
280
281        // Changing SSL context options after connection is established does *not*
282        // work, we need a new connection if options change
283        $remote    = ((!$secure || $httpProxy || $socksProxy)? 'tcp://': 'ssl://')
284                     . $host . ':' . $port;
285        $socketKey = $remote . (
286                        ($secure && $httpProxy || $socksProxy)
287                        ? "->{$reqHost}:{$reqPort}" : ''
288                     ) . (empty($options)? '': ':' . serialize($options));
289        unset($this->socket);
290
291        // We use persistent connections and have a connected socket?
292        // Ensure that the socket is still connected, see bug #16149
293        if ($keepAlive && !empty(self::$sockets[$socketKey])
294            && !self::$sockets[$socketKey]->eof()
295        ) {
296            $this->socket =& self::$sockets[$socketKey];
297
298        } else {
299            if ($socksProxy) {
300                require_once 'HTTP/Request2/SOCKS5.php';
301
302                $this->socket = new HTTP_Request2_SOCKS5(
303                    $remote, $this->request->getConfig('connect_timeout'),
304                    $options, $this->request->getConfig('proxy_user'),
305                    $this->request->getConfig('proxy_password')
306                );
307                // handle request timeouts ASAP
308                $this->socket->setDeadline($deadline, $this->request->getConfig('timeout'));
309                $this->socket->connect($reqHost, $reqPort);
310                if (!$secure) {
311                    $conninfo = "tcp://{$reqHost}:{$reqPort} via {$remote}";
312                } else {
313                    $this->socket->enableCrypto();
314                    $conninfo = "ssl://{$reqHost}:{$reqPort} via {$remote}";
315                }
316
317            } elseif ($secure && $httpProxy && !$tunnel) {
318                $this->establishTunnel();
319                $conninfo = "ssl://{$reqHost}:{$reqPort} via {$remote}";
320
321            } else {
322                $this->socket = new HTTP_Request2_SocketWrapper(
323                    $remote, $this->request->getConfig('connect_timeout'), $options
324                );
325            }
326            $this->request->setLastEvent('connect', empty($conninfo)? $remote: $conninfo);
327            self::$sockets[$socketKey] =& $this->socket;
328        }
329        $this->socket->setDeadline($deadline, $this->request->getConfig('timeout'));
330        return $keepAlive;
331    }
332
333    /**
334     * Establishes a tunnel to a secure remote server via HTTP CONNECT request
335     *
336     * This method will fail if 'ssl_verify_peer' is enabled. Probably because PHP
337     * sees that we are connected to a proxy server (duh!) rather than the server
338     * that presents its certificate.
339     *
340     * @link     http://tools.ietf.org/html/rfc2817#section-5.2
341     * @throws   HTTP_Request2_Exception
342     */
343    protected function establishTunnel()
344    {
345        $donor   = new self;
346        $connect = new HTTP_Request2(
347            $this->request->getUrl(), HTTP_Request2::METHOD_CONNECT,
348            array_merge($this->request->getConfig(), array('adapter' => $donor))
349        );
350        $response = $connect->send();
351        // Need any successful (2XX) response
352        if (200 > $response->getStatus() || 300 <= $response->getStatus()) {
353            throw new HTTP_Request2_ConnectionException(
354                'Failed to connect via HTTPS proxy. Proxy response: ' .
355                $response->getStatus() . ' ' . $response->getReasonPhrase()
356            );
357        }
358        $this->socket = $donor->socket;
359        $this->socket->enableCrypto();
360    }
361
362    /**
363     * Checks whether current connection may be reused or should be closed
364     *
365     * @param boolean                $requestKeepAlive whether connection could
366     *                               be persistent in the first place
367     * @param HTTP_Request2_Response $response         response object to check
368     *
369     * @return   boolean
370     */
371    protected function canKeepAlive($requestKeepAlive, HTTP_Request2_Response $response)
372    {
373        // Do not close socket on successful CONNECT request
374        if (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod()
375            && 200 <= $response->getStatus() && 300 > $response->getStatus()
376        ) {
377            return true;
378        }
379
380        $lengthKnown = 'chunked' == strtolower($response->getHeader('transfer-encoding'))
381                       || null !== $response->getHeader('content-length')
382                       // no body possible for such responses, see also request #17031
383                       || HTTP_Request2::METHOD_HEAD == $this->request->getMethod()
384                       || in_array($response->getStatus(), array(204, 304));
385        $persistent  = 'keep-alive' == strtolower($response->getHeader('connection')) ||
386                       (null === $response->getHeader('connection') &&
387                        '1.1' == $response->getVersion());
388        return $requestKeepAlive && $lengthKnown && $persistent;
389    }
390
391    /**
392     * Disconnects from the remote server
393     */
394    protected function disconnect()
395    {
396        if (!empty($this->socket)) {
397            $this->socket = null;
398            $this->request->setLastEvent('disconnect');
399        }
400    }
401
402    /**
403     * Handles HTTP redirection
404     *
405     * This method will throw an Exception if redirect to a non-HTTP(S) location
406     * is attempted, also if number of redirects performed already is equal to
407     * 'max_redirects' configuration parameter.
408     *
409     * @param HTTP_Request2          $request  Original request
410     * @param HTTP_Request2_Response $response Response containing redirect
411     *
412     * @return   HTTP_Request2_Response      Response from a new location
413     * @throws   HTTP_Request2_Exception
414     */
415    protected function handleRedirect(
416        HTTP_Request2 $request, HTTP_Request2_Response $response
417    ) {
418        if (is_null($this->redirectCountdown)) {
419            $this->redirectCountdown = $request->getConfig('max_redirects');
420        }
421        if (0 == $this->redirectCountdown) {
422            $this->redirectCountdown = null;
423            // Copying cURL behaviour
424            throw new HTTP_Request2_MessageException(
425                'Maximum (' . $request->getConfig('max_redirects') . ') redirects followed',
426                HTTP_Request2_Exception::TOO_MANY_REDIRECTS
427            );
428        }
429        $redirectUrl = new Net_URL2(
430            $response->getHeader('location'),
431            array(Net_URL2::OPTION_USE_BRACKETS => $request->getConfig('use_brackets'))
432        );
433        // refuse non-HTTP redirect
434        if ($redirectUrl->isAbsolute()
435            && !in_array($redirectUrl->getScheme(), array('http', 'https'))
436        ) {
437            $this->redirectCountdown = null;
438            throw new HTTP_Request2_MessageException(
439                'Refusing to redirect to a non-HTTP URL ' . $redirectUrl->__toString(),
440                HTTP_Request2_Exception::NON_HTTP_REDIRECT
441            );
442        }
443        // Theoretically URL should be absolute (see http://tools.ietf.org/html/rfc2616#section-14.30),
444        // but in practice it is often not
445        if (!$redirectUrl->isAbsolute()) {
446            $redirectUrl = $request->getUrl()->resolve($redirectUrl);
447        }
448        $redirect = clone $request;
449        $redirect->setUrl($redirectUrl);
450        if (303 == $response->getStatus()
451            || (!$request->getConfig('strict_redirects')
452                && in_array($response->getStatus(), array(301, 302)))
453        ) {
454            $redirect->setMethod(HTTP_Request2::METHOD_GET);
455            $redirect->setBody('');
456        }
457
458        if (0 < $this->redirectCountdown) {
459            $this->redirectCountdown--;
460        }
461        return $this->sendRequest($redirect);
462    }
463
464    /**
465     * Checks whether another request should be performed with server digest auth
466     *
467     * Several conditions should be satisfied for it to return true:
468     *   - response status should be 401
469     *   - auth credentials should be set in the request object
470     *   - response should contain WWW-Authenticate header with digest challenge
471     *   - there is either no challenge stored for this URL or new challenge
472     *     contains stale=true parameter (in other case we probably just failed
473     *     due to invalid username / password)
474     *
475     * The method stores challenge values in $challenges static property
476     *
477     * @param HTTP_Request2_Response $response response to check
478     *
479     * @return   boolean whether another request should be performed
480     * @throws   HTTP_Request2_Exception in case of unsupported challenge parameters
481     */
482    protected function shouldUseServerDigestAuth(HTTP_Request2_Response $response)
483    {
484        // no sense repeating a request if we don't have credentials
485        if (401 != $response->getStatus() || !$this->request->getAuth()) {
486            return false;
487        }
488        if (!$challenge = $this->parseDigestChallenge($response->getHeader('www-authenticate'))) {
489            return false;
490        }
491
492        $url    = $this->request->getUrl();
493        $scheme = $url->getScheme();
494        $host   = $scheme . '://' . $url->getHost();
495        if ($port = $url->getPort()) {
496            if ((0 == strcasecmp($scheme, 'http') && 80 != $port)
497                || (0 == strcasecmp($scheme, 'https') && 443 != $port)
498            ) {
499                $host .= ':' . $port;
500            }
501        }
502
503        if (!empty($challenge['domain'])) {
504            $prefixes = array();
505            foreach (preg_split('/\\s+/', $challenge['domain']) as $prefix) {
506                // don't bother with different servers
507                if ('/' == substr($prefix, 0, 1)) {
508                    $prefixes[] = $host . $prefix;
509                }
510            }
511        }
512        if (empty($prefixes)) {
513            $prefixes = array($host . '/');
514        }
515
516        $ret = true;
517        foreach ($prefixes as $prefix) {
518            if (!empty(self::$challenges[$prefix])
519                && (empty($challenge['stale']) || strcasecmp('true', $challenge['stale']))
520            ) {
521                // probably credentials are invalid
522                $ret = false;
523            }
524            self::$challenges[$prefix] =& $challenge;
525        }
526        return $ret;
527    }
528
529    /**
530     * Checks whether another request should be performed with proxy digest auth
531     *
532     * Several conditions should be satisfied for it to return true:
533     *   - response status should be 407
534     *   - proxy auth credentials should be set in the request object
535     *   - response should contain Proxy-Authenticate header with digest challenge
536     *   - there is either no challenge stored for this proxy or new challenge
537     *     contains stale=true parameter (in other case we probably just failed
538     *     due to invalid username / password)
539     *
540     * The method stores challenge values in $challenges static property
541     *
542     * @param HTTP_Request2_Response $response response to check
543     *
544     * @return   boolean whether another request should be performed
545     * @throws   HTTP_Request2_Exception in case of unsupported challenge parameters
546     */
547    protected function shouldUseProxyDigestAuth(HTTP_Request2_Response $response)
548    {
549        if (407 != $response->getStatus() || !$this->request->getConfig('proxy_user')) {
550            return false;
551        }
552        if (!($challenge = $this->parseDigestChallenge($response->getHeader('proxy-authenticate')))) {
553            return false;
554        }
555
556        $key = 'proxy://' . $this->request->getConfig('proxy_host') .
557               ':' . $this->request->getConfig('proxy_port');
558
559        if (!empty(self::$challenges[$key])
560            && (empty($challenge['stale']) || strcasecmp('true', $challenge['stale']))
561        ) {
562            $ret = false;
563        } else {
564            $ret = true;
565        }
566        self::$challenges[$key] = $challenge;
567        return $ret;
568    }
569
570    /**
571     * Extracts digest method challenge from (WWW|Proxy)-Authenticate header value
572     *
573     * There is a problem with implementation of RFC 2617: several of the parameters
574     * are defined as quoted-string there and thus may contain backslash escaped
575     * double quotes (RFC 2616, section 2.2). However, RFC 2617 defines unq(X) as
576     * just value of quoted-string X without surrounding quotes, it doesn't speak
577     * about removing backslash escaping.
578     *
579     * Now realm parameter is user-defined and human-readable, strange things
580     * happen when it contains quotes:
581     *   - Apache allows quotes in realm, but apparently uses realm value without
582     *     backslashes for digest computation
583     *   - Squid allows (manually escaped) quotes there, but it is impossible to
584     *     authorize with either escaped or unescaped quotes used in digest,
585     *     probably it can't parse the response (?)
586     *   - Both IE and Firefox display realm value with backslashes in
587     *     the password popup and apparently use the same value for digest
588     *
589     * HTTP_Request2 follows IE and Firefox (and hopefully RFC 2617) in
590     * quoted-string handling, unfortunately that means failure to authorize
591     * sometimes
592     *
593     * @param string $headerValue value of WWW-Authenticate or Proxy-Authenticate header
594     *
595     * @return   mixed   associative array with challenge parameters, false if
596     *                   no challenge is present in header value
597     * @throws   HTTP_Request2_NotImplementedException in case of unsupported challenge parameters
598     */
599    protected function parseDigestChallenge($headerValue)
600    {
601        $authParam   = '(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' .
602                       self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')';
603        $challenge   = "!(?<=^|\\s|,)Digest ({$authParam}\\s*(,\\s*|$))+!";
604        if (!preg_match($challenge, $headerValue, $matches)) {
605            return false;
606        }
607
608        preg_match_all('!' . $authParam . '!', $matches[0], $params);
609        $paramsAry   = array();
610        $knownParams = array('realm', 'domain', 'nonce', 'opaque', 'stale',
611                             'algorithm', 'qop');
612        for ($i = 0; $i < count($params[0]); $i++) {
613            // section 3.2.1: Any unrecognized directive MUST be ignored.
614            if (in_array($params[1][$i], $knownParams)) {
615                if ('"' == substr($params[2][$i], 0, 1)) {
616                    $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1);
617                } else {
618                    $paramsAry[$params[1][$i]] = $params[2][$i];
619                }
620            }
621        }
622        // we only support qop=auth
623        if (!empty($paramsAry['qop'])
624            && !in_array('auth', array_map('trim', explode(',', $paramsAry['qop'])))
625        ) {
626            throw new HTTP_Request2_NotImplementedException(
627                "Only 'auth' qop is currently supported in digest authentication, " .
628                "server requested '{$paramsAry['qop']}'"
629            );
630        }
631        // we only support algorithm=MD5
632        if (!empty($paramsAry['algorithm']) && 'MD5' != $paramsAry['algorithm']) {
633            throw new HTTP_Request2_NotImplementedException(
634                "Only 'MD5' algorithm is currently supported in digest authentication, " .
635                "server requested '{$paramsAry['algorithm']}'"
636            );
637        }
638
639        return $paramsAry;
640    }
641
642    /**
643     * Parses [Proxy-]Authentication-Info header value and updates challenge
644     *
645     * @param array  &$challenge  challenge to update
646     * @param string $headerValue value of [Proxy-]Authentication-Info header
647     *
648     * @todo     validate server rspauth response
649     */
650    protected function updateChallenge(&$challenge, $headerValue)
651    {
652        $authParam   = '!(' . self::REGEXP_TOKEN . ')\\s*=\\s*(' .
653                       self::REGEXP_TOKEN . '|' . self::REGEXP_QUOTED_STRING . ')!';
654        $paramsAry   = array();
655
656        preg_match_all($authParam, $headerValue, $params);
657        for ($i = 0; $i < count($params[0]); $i++) {
658            if ('"' == substr($params[2][$i], 0, 1)) {
659                $paramsAry[$params[1][$i]] = substr($params[2][$i], 1, -1);
660            } else {
661                $paramsAry[$params[1][$i]] = $params[2][$i];
662            }
663        }
664        // for now, just update the nonce value
665        if (!empty($paramsAry['nextnonce'])) {
666            $challenge['nonce'] = $paramsAry['nextnonce'];
667            $challenge['nc']    = 1;
668        }
669    }
670
671    /**
672     * Creates a value for [Proxy-]Authorization header when using digest authentication
673     *
674     * @param string $user       user name
675     * @param string $password   password
676     * @param string $url        request URL
677     * @param array  &$challenge digest challenge parameters
678     *
679     * @return   string  value of [Proxy-]Authorization request header
680     * @link     http://tools.ietf.org/html/rfc2617#section-3.2.2
681     */
682    protected function createDigestResponse($user, $password, $url, &$challenge)
683    {
684        if (false !== ($q = strpos($url, '?'))
685            && $this->request->getConfig('digest_compat_ie')
686        ) {
687            $url = substr($url, 0, $q);
688        }
689
690        $a1 = md5($user . ':' . $challenge['realm'] . ':' . $password);
691        $a2 = md5($this->request->getMethod() . ':' . $url);
692
693        if (empty($challenge['qop'])) {
694            $digest = md5($a1 . ':' . $challenge['nonce'] . ':' . $a2);
695        } else {
696            $challenge['cnonce'] = 'Req2.' . rand();
697            if (empty($challenge['nc'])) {
698                $challenge['nc'] = 1;
699            }
700            $nc     = sprintf('%08x', $challenge['nc']++);
701            $digest = md5(
702                $a1 . ':' . $challenge['nonce'] . ':' . $nc . ':' .
703                $challenge['cnonce'] . ':auth:' . $a2
704            );
705        }
706        return 'Digest username="' . str_replace(array('\\', '"'), array('\\\\', '\\"'), $user) . '", ' .
707               'realm="' . $challenge['realm'] . '", ' .
708               'nonce="' . $challenge['nonce'] . '", ' .
709               'uri="' . $url . '", ' .
710               'response="' . $digest . '"' .
711               (!empty($challenge['opaque'])?
712                ', opaque="' . $challenge['opaque'] . '"':
713                '') .
714               (!empty($challenge['qop'])?
715                ', qop="auth", nc=' . $nc . ', cnonce="' . $challenge['cnonce'] . '"':
716                '');
717    }
718
719    /**
720     * Adds 'Authorization' header (if needed) to request headers array
721     *
722     * @param array  &$headers    request headers
723     * @param string $requestHost request host (needed for digest authentication)
724     * @param string $requestUrl  request URL (needed for digest authentication)
725     *
726     * @throws   HTTP_Request2_NotImplementedException
727     */
728    protected function addAuthorizationHeader(&$headers, $requestHost, $requestUrl)
729    {
730        if (!($auth = $this->request->getAuth())) {
731            return;
732        }
733        switch ($auth['scheme']) {
734        case HTTP_Request2::AUTH_BASIC:
735            $headers['authorization'] = 'Basic ' . base64_encode(
736                $auth['user'] . ':' . $auth['password']
737            );
738            break;
739
740        case HTTP_Request2::AUTH_DIGEST:
741            unset($this->serverChallenge);
742            $fullUrl = ('/' == $requestUrl[0])?
743                       $this->request->getUrl()->getScheme() . '://' .
744                        $requestHost . $requestUrl:
745                       $requestUrl;
746            foreach (array_keys(self::$challenges) as $key) {
747                if ($key == substr($fullUrl, 0, strlen($key))) {
748                    $headers['authorization'] = $this->createDigestResponse(
749                        $auth['user'], $auth['password'],
750                        $requestUrl, self::$challenges[$key]
751                    );
752                    $this->serverChallenge =& self::$challenges[$key];
753                    break;
754                }
755            }
756            break;
757
758        default:
759            throw new HTTP_Request2_NotImplementedException(
760                "Unknown HTTP authentication scheme '{$auth['scheme']}'"
761            );
762        }
763    }
764
765    /**
766     * Adds 'Proxy-Authorization' header (if needed) to request headers array
767     *
768     * @param array  &$headers   request headers
769     * @param string $requestUrl request URL (needed for digest authentication)
770     *
771     * @throws   HTTP_Request2_NotImplementedException
772     */
773    protected function addProxyAuthorizationHeader(&$headers, $requestUrl)
774    {
775        if (!$this->request->getConfig('proxy_host')
776            || !($user = $this->request->getConfig('proxy_user'))
777            || (0 == strcasecmp('https', $this->request->getUrl()->getScheme())
778                && HTTP_Request2::METHOD_CONNECT != $this->request->getMethod())
779        ) {
780            return;
781        }
782
783        $password = $this->request->getConfig('proxy_password');
784        switch ($this->request->getConfig('proxy_auth_scheme')) {
785        case HTTP_Request2::AUTH_BASIC:
786            $headers['proxy-authorization'] = 'Basic ' . base64_encode(
787                $user . ':' . $password
788            );
789            break;
790
791        case HTTP_Request2::AUTH_DIGEST:
792            unset($this->proxyChallenge);
793            $proxyUrl = 'proxy://' . $this->request->getConfig('proxy_host') .
794                        ':' . $this->request->getConfig('proxy_port');
795            if (!empty(self::$challenges[$proxyUrl])) {
796                $headers['proxy-authorization'] = $this->createDigestResponse(
797                    $user, $password,
798                    $requestUrl, self::$challenges[$proxyUrl]
799                );
800                $this->proxyChallenge =& self::$challenges[$proxyUrl];
801            }
802            break;
803
804        default:
805            throw new HTTP_Request2_NotImplementedException(
806                "Unknown HTTP authentication scheme '" .
807                $this->request->getConfig('proxy_auth_scheme') . "'"
808            );
809        }
810    }
811
812
813    /**
814     * Creates the string with the Request-Line and request headers
815     *
816     * @return   string
817     * @throws   HTTP_Request2_Exception
818     */
819    protected function prepareHeaders()
820    {
821        $headers = $this->request->getHeaders();
822        $url     = $this->request->getUrl();
823        $connect = HTTP_Request2::METHOD_CONNECT == $this->request->getMethod();
824        $host    = $url->getHost();
825
826        $defaultPort = 0 == strcasecmp($url->getScheme(), 'https')? 443: 80;
827        if (($port = $url->getPort()) && $port != $defaultPort || $connect) {
828            $host .= ':' . (empty($port)? $defaultPort: $port);
829        }
830        // Do not overwrite explicitly set 'Host' header, see bug #16146
831        if (!isset($headers['host'])) {
832            $headers['host'] = $host;
833        }
834
835        if ($connect) {
836            $requestUrl = $host;
837
838        } else {
839            if (!$this->request->getConfig('proxy_host')
840                || 'http' != $this->request->getConfig('proxy_type')
841                || 0 == strcasecmp($url->getScheme(), 'https')
842            ) {
843                $requestUrl = '';
844            } else {
845                $requestUrl = $url->getScheme() . '://' . $host;
846            }
847            $path        = $url->getPath();
848            $query       = $url->getQuery();
849            $requestUrl .= (empty($path)? '/': $path) . (empty($query)? '': '?' . $query);
850        }
851
852        if ('1.1' == $this->request->getConfig('protocol_version')
853            && extension_loaded('zlib') && !isset($headers['accept-encoding'])
854        ) {
855            $headers['accept-encoding'] = 'gzip, deflate';
856        }
857        if (($jar = $this->request->getCookieJar())
858            && ($cookies = $jar->getMatching($this->request->getUrl(), true))
859        ) {
860            $headers['cookie'] = (empty($headers['cookie'])? '': $headers['cookie'] . '; ') . $cookies;
861        }
862
863        $this->addAuthorizationHeader($headers, $host, $requestUrl);
864        $this->addProxyAuthorizationHeader($headers, $requestUrl);
865        $this->calculateRequestLength($headers);
866
867        $headersStr = $this->request->getMethod() . ' ' . $requestUrl . ' HTTP/' .
868                      $this->request->getConfig('protocol_version') . "\r\n";
869        foreach ($headers as $name => $value) {
870            $canonicalName = implode('-', array_map('ucfirst', explode('-', $name)));
871            $headersStr   .= $canonicalName . ': ' . $value . "\r\n";
872        }
873        return $headersStr . "\r\n";
874    }
875
876    /**
877     * Sends the request body
878     *
879     * @throws   HTTP_Request2_MessageException
880     */
881    protected function writeBody()
882    {
883        if (in_array($this->request->getMethod(), self::$bodyDisallowed)
884            || 0 == $this->contentLength
885        ) {
886            return;
887        }
888
889        $position   = 0;
890        $bufferSize = $this->request->getConfig('buffer_size');
891        while ($position < $this->contentLength) {
892            if (is_string($this->requestBody)) {
893                $str = substr($this->requestBody, $position, $bufferSize);
894            } elseif (is_resource($this->requestBody)) {
895                $str = fread($this->requestBody, $bufferSize);
896            } else {
897                $str = $this->requestBody->read($bufferSize);
898            }
899            $this->socket->write($str);
900            // Provide the length of written string to the observer, request #7630
901            $this->request->setLastEvent('sentBodyPart', strlen($str));
902            $position += strlen($str);
903        }
904        $this->request->setLastEvent('sentBody', $this->contentLength);
905    }
906
907    /**
908     * Reads the remote server's response
909     *
910     * @return   HTTP_Request2_Response
911     * @throws   HTTP_Request2_Exception
912     */
913    protected function readResponse()
914    {
915        $bufferSize = $this->request->getConfig('buffer_size');
916
917        do {
918            $response = new HTTP_Request2_Response(
919                $this->socket->readLine($bufferSize), true, $this->request->getUrl()
920            );
921            do {
922                $headerLine = $this->socket->readLine($bufferSize);
923                $response->parseHeaderLine($headerLine);
924            } while ('' != $headerLine);
925        } while (in_array($response->getStatus(), array(100, 101)));
926
927        $this->request->setLastEvent('receivedHeaders', $response);
928
929        // No body possible in such responses
930        if (HTTP_Request2::METHOD_HEAD == $this->request->getMethod()
931            || (HTTP_Request2::METHOD_CONNECT == $this->request->getMethod()
932                && 200 <= $response->getStatus() && 300 > $response->getStatus())
933            || in_array($response->getStatus(), array(204, 304))
934        ) {
935            return $response;
936        }
937
938        $chunked = 'chunked' == $response->getHeader('transfer-encoding');
939        $length  = $response->getHeader('content-length');
940        $hasBody = false;
941        if ($chunked || null === $length || 0 < intval($length)) {
942            // RFC 2616, section 4.4:
943            // 3. ... If a message is received with both a
944            // Transfer-Encoding header field and a Content-Length header field,
945            // the latter MUST be ignored.
946            $toRead = ($chunked || null === $length)? null: $length;
947            $this->chunkLength = 0;
948
949            while (!$this->socket->eof() && (is_null($toRead) || 0 < $toRead)) {
950                if ($chunked) {
951                    $data = $this->readChunked($bufferSize);
952                } elseif (is_null($toRead)) {
953                    $data = $this->socket->read($bufferSize);
954                } else {
955                    $data    = $this->socket->read(min($toRead, $bufferSize));
956                    $toRead -= strlen($data);
957                }
958                if ('' == $data && (!$this->chunkLength || $this->socket->eof())) {
959                    break;
960                }
961
962                $hasBody = true;
963                if ($this->request->getConfig('store_body')) {
964                    $response->appendBody($data);
965                }
966                if (!in_array($response->getHeader('content-encoding'), array('identity', null))) {
967                    $this->request->setLastEvent('receivedEncodedBodyPart', $data);
968                } else {
969                    $this->request->setLastEvent('receivedBodyPart', $data);
970                }
971            }
972        }
973
974        if ($hasBody) {
975            $this->request->setLastEvent('receivedBody', $response);
976        }
977        return $response;
978    }
979
980    /**
981     * Reads a part of response body encoded with chunked Transfer-Encoding
982     *
983     * @param int $bufferSize buffer size to use for reading
984     *
985     * @return   string
986     * @throws   HTTP_Request2_MessageException
987     */
988    protected function readChunked($bufferSize)
989    {
990        // at start of the next chunk?
991        if (0 == $this->chunkLength) {
992            $line = $this->socket->readLine($bufferSize);
993            if (!preg_match('/^([0-9a-f]+)/i', $line, $matches)) {
994                throw new HTTP_Request2_MessageException(
995                    "Cannot decode chunked response, invalid chunk length '{$line}'",
996                    HTTP_Request2_Exception::DECODE_ERROR
997                );
998            } else {
999                $this->chunkLength = hexdec($matches[1]);
1000                // Chunk with zero length indicates the end
1001                if (0 == $this->chunkLength) {
1002                    $this->socket->readLine($bufferSize);
1003                    return '';
1004                }
1005            }
1006        }
1007        $data = $this->socket->read(min($this->chunkLength, $bufferSize));
1008        $this->chunkLength -= strlen($data);
1009        if (0 == $this->chunkLength) {
1010            $this->socket->readLine($bufferSize); // Trailing CRLF
1011        }
1012        return $data;
1013    }
1014}
1015
1016?>
Note: See TracBrowser for help on using the repository browser.