7
* This source file is subject to the new BSD license that is bundled
8
* with this package in the file LICENSE.txt.
9
* It is also available through the world-wide-web at this URL:
10
* http://framework.zend.com/license/new-bsd
11
* If you did not receive a copy of the license and are unable to
12
* obtain it through the world-wide-web, please send an email
13
* to license@zend.com so we can send you a copy immediately.
17
* @subpackage Zend_Auth_Adapter_Http
18
* @copyright Copyright (c) 2005-2008 Zend Technologies USA Inc. (http://www.zend.com)
19
* @license http://framework.zend.com/license/new-bsd New BSD License
20
* @version $Id: Http.php 12503 2008-11-10 16:28:40Z matthew $
25
* @see Zend_Auth_Adapter_Interface
27
require_once 'Zend/Auth/Adapter/Interface.php';
31
* HTTP Authentication Adapter
33
* Implements a pretty good chunk of RFC 2617.
37
* @subpackage Zend_Auth_Adapter_Http
38
* @copyright Copyright (c) 2005-2008 Zend Technologies USA Inc. (http://www.zend.com)
39
* @license http://framework.zend.com/license/new-bsd New BSD License
40
* @todo Support auth-int
41
* @todo Track nonces, nonce-count, opaque for replay protection and stale support
42
* @todo Support Authentication-Info header
44
class Zend_Auth_Adapter_Http implements Zend_Auth_Adapter_Interface
47
* Reference to the HTTP Request object
49
* @var Zend_Controller_Request_Http
54
* Reference to the HTTP Response object
56
* @var Zend_Controller_Response_Http
61
* Object that looks up user credentials for the Basic scheme
63
* @var Zend_Auth_Adapter_Http_Resolver_Interface
65
protected $_basicResolver;
68
* Object that looks up user credentials for the Digest scheme
70
* @var Zend_Auth_Adapter_Http_Resolver_Interface
72
protected $_digestResolver;
75
* List of authentication schemes supported by this class
79
protected $_supportedSchemes = array('basic', 'digest');
82
* List of schemes this class will accept from the client
86
protected $_acceptSchemes;
89
* Space-delimited list of protected domains for Digest Auth
96
* The protection realm to use
103
* Nonce timeout period
107
protected $_nonceTimeout;
110
* Whether to send the opaque value in the header. True by default
114
protected $_useOpaque;
117
* List of the supported digest algorithms. I want to support both MD5 and
118
* MD5-sess, but MD5-sess won't make it into the first version.
122
protected $_supportedAlgos = array('MD5');
125
* The actual algorithm to use. Defaults to MD5
132
* List of supported qop options. My intetion is to support both 'auth' and
133
* 'auth-int', but 'auth-int' won't make it into the first version.
137
protected $_supportedQops = array('auth');
140
* Whether or not to do Proxy Authentication instead of origin server
141
* authentication (send 407's instead of 401's). Off by default.
145
protected $_imaProxy;
148
* Flag indicating the client is IE and didn't bother to return the opaque string
152
protected $_ieNoOpaque;
157
* @param array $config Configuration settings:
158
* 'accept_schemes' => 'basic'|'digest'|'basic digest'
159
* 'realm' => <string>
160
* 'digest_domains' => <string> Space-delimited list of URIs
161
* 'nonce_timeout' => <int>
162
* 'use_opaque' => <bool> Whether to send the opaque value in the header
163
* 'alogrithm' => <string> See $_supportedAlgos. Default: MD5
164
* 'proxy_auth' => <bool> Whether to do authentication as a Proxy
165
* @throws Zend_Auth_Adapter_Exception
168
public function __construct(array $config)
170
if (!extension_loaded('hash')) {
172
* @see Zend_Auth_Adapter_Exception
174
require_once 'Zend/Auth/Adapter/Exception.php';
175
throw new Zend_Auth_Adapter_Exception(__CLASS__ . ' requires the \'hash\' extension');
178
$this->_request = null;
179
$this->_response = null;
180
$this->_ieNoOpaque = false;
183
if (empty($config['accept_schemes'])) {
185
* @see Zend_Auth_Adapter_Exception
187
require_once 'Zend/Auth/Adapter/Exception.php';
188
throw new Zend_Auth_Adapter_Exception('Config key \'accept_schemes\' is required');
191
$schemes = explode(' ', $config['accept_schemes']);
192
$this->_acceptSchemes = array_intersect($schemes, $this->_supportedSchemes);
193
if (empty($this->_acceptSchemes)) {
195
* @see Zend_Auth_Adapter_Exception
197
require_once 'Zend/Auth/Adapter/Exception.php';
198
throw new Zend_Auth_Adapter_Exception('No supported schemes given in \'accept_schemes\'. Valid values: '
199
. implode(', ', $this->_supportedSchemes));
202
// Double-quotes are used to delimit the realm string in the HTTP header,
203
// and colons are field delimiters in the password file.
204
if (empty($config['realm']) ||
205
!ctype_print($config['realm']) ||
206
strpos($config['realm'], ':') !== false ||
207
strpos($config['realm'], '"') !== false) {
209
* @see Zend_Auth_Adapter_Exception
211
require_once 'Zend/Auth/Adapter/Exception.php';
212
throw new Zend_Auth_Adapter_Exception('Config key \'realm\' is required, and must contain only printable '
213
. 'characters, excluding quotation marks and colons');
215
$this->_realm = $config['realm'];
218
if (in_array('digest', $this->_acceptSchemes)) {
219
if (empty($config['digest_domains']) ||
220
!ctype_print($config['digest_domains']) ||
221
strpos($config['digest_domains'], '"') !== false) {
223
* @see Zend_Auth_Adapter_Exception
225
require_once 'Zend/Auth/Adapter/Exception.php';
226
throw new Zend_Auth_Adapter_Exception('Config key \'digest_domains\' is required, and must contain '
227
. 'only printable characters, excluding quotation marks');
229
$this->_domains = $config['digest_domains'];
232
if (empty($config['nonce_timeout']) ||
233
!is_numeric($config['nonce_timeout'])) {
235
* @see Zend_Auth_Adapter_Exception
237
require_once 'Zend/Auth/Adapter/Exception.php';
238
throw new Zend_Auth_Adapter_Exception('Config key \'nonce_timeout\' is required, and must be an '
241
$this->_nonceTimeout = (int) $config['nonce_timeout'];
244
// We use the opaque value unless explicitly told not to
245
if (isset($config['use_opaque']) && false == (bool) $config['use_opaque']) {
246
$this->_useOpaque = false;
248
$this->_useOpaque = true;
251
if (isset($config['algorithm']) && in_array($config['algorithm'], $this->_supportedAlgos)) {
252
$this->_algo = $config['algorithm'];
254
$this->_algo = 'MD5';
258
// Don't be a proxy unless explicitly told to do so
259
if (isset($config['proxy_auth']) && true == (bool) $config['proxy_auth']) {
260
$this->_imaProxy = true; // I'm a Proxy
262
$this->_imaProxy = false;
267
* Setter for the _basicResolver property
269
* @param Zend_Auth_Adapter_Http_Resolver_Interface $resolver
270
* @return Zend_Auth_Adapter_Http Provides a fluent interface
272
public function setBasicResolver(Zend_Auth_Adapter_Http_Resolver_Interface $resolver)
274
$this->_basicResolver = $resolver;
280
* Getter for the _basicResolver property
282
* @return Zend_Auth_Adapter_Http_Resolver_Interface
284
public function getBasicResolver()
286
return $this->_basicResolver;
290
* Setter for the _digestResolver property
292
* @param Zend_Auth_Adapter_Http_Resolver_Interface $resolver
293
* @return Zend_Auth_Adapter_Http Provides a fluent interface
295
public function setDigestResolver(Zend_Auth_Adapter_Http_Resolver_Interface $resolver)
297
$this->_digestResolver = $resolver;
303
* Getter for the _digestResolver property
305
* @return Zend_Auth_Adapter_Http_Resolver_Interface
307
public function getDigestResolver()
309
return $this->_digestResolver;
313
* Setter for the Request object
315
* @param Zend_Controller_Request_Http $request
316
* @return Zend_Auth_Adapter_Http Provides a fluent interface
318
public function setRequest(Zend_Controller_Request_Http $request)
320
$this->_request = $request;
326
* Getter for the Request object
328
* @return Zend_Controller_Request_Http
330
public function getRequest()
332
return $this->_request;
336
* Setter for the Response object
338
* @param Zend_Controller_Response_Http $response
339
* @return Zend_Auth_Adapter_Http Provides a fluent interface
341
public function setResponse(Zend_Controller_Response_Http $response)
343
$this->_response = $response;
349
* Getter for the Response object
351
* @return Zend_Controller_Response_Http
353
public function getResponse()
355
return $this->_response;
361
* @throws Zend_Auth_Adapter_Exception
362
* @return Zend_Auth_Result
364
public function authenticate()
366
if (empty($this->_request) ||
367
empty($this->_response)) {
369
* @see Zend_Auth_Adapter_Exception
371
require_once 'Zend/Auth/Adapter/Exception.php';
372
throw new Zend_Auth_Adapter_Exception('Request and Response objects must be set before calling '
376
if ($this->_imaProxy) {
377
$getHeader = 'Proxy-Authorization';
379
$getHeader = 'Authorization';
382
$authHeader = $this->_request->getHeader($getHeader);
384
return $this->_challengeClient();
387
list($clientScheme) = explode(' ', $authHeader);
388
$clientScheme = strtolower($clientScheme);
390
// The server can issue multiple challenges, but the client should
391
// answer with only the selected auth scheme.
392
if (!in_array($clientScheme, $this->_supportedSchemes)) {
393
$this->_response->setHttpResponseCode(400);
394
return new Zend_Auth_Result(
395
Zend_Auth_Result::FAILURE_UNCATEGORIZED,
397
array('Client requested an incorrect or unsupported authentication scheme')
401
// client sent a scheme that is not the one required
402
if (!in_array($clientScheme, $this->_acceptSchemes)) {
403
// challenge again the client
404
return $this->_challengeClient();
407
switch ($clientScheme) {
409
$result = $this->_basicAuth($authHeader);
412
$result = $this->_digestAuth($authHeader);
416
* @see Zend_Auth_Adapter_Exception
418
require_once 'Zend/Auth/Adapter/Exception.php';
419
throw new Zend_Auth_Adapter_Exception('Unsupported authentication scheme');
428
* Sets a 401 or 407 Unauthorized response code, and creates the
429
* appropriate Authenticate header(s) to prompt for credentials.
431
* @return Zend_Auth_Result Always returns a non-identity Auth result
433
protected function _challengeClient()
435
if ($this->_imaProxy) {
437
$headerName = 'Proxy-Authenticate';
440
$headerName = 'WWW-Authenticate';
443
$this->_response->setHttpResponseCode($statusCode);
445
// Send a challenge in each acceptable authentication scheme
446
if (in_array('basic', $this->_acceptSchemes)) {
447
$this->_response->setHeader($headerName, $this->_basicHeader());
449
if (in_array('digest', $this->_acceptSchemes)) {
450
$this->_response->setHeader($headerName, $this->_digestHeader());
452
return new Zend_Auth_Result(
453
Zend_Auth_Result::FAILURE_CREDENTIAL_INVALID,
455
array('Invalid or absent credentials; challenging client')
462
* Generates a Proxy- or WWW-Authenticate header value in the Basic
463
* authentication scheme.
465
* @return string Authenticate header value
467
protected function _basicHeader()
469
return 'Basic realm="' . $this->_realm . '"';
475
* Generates a Proxy- or WWW-Authenticate header value in the Digest
476
* authentication scheme.
478
* @return string Authenticate header value
480
protected function _digestHeader()
482
$wwwauth = 'Digest realm="' . $this->_realm . '", '
483
. 'domain="' . $this->_domains . '", '
484
. 'nonce="' . $this->_calcNonce() . '", '
485
. ($this->_useOpaque ? 'opaque="' . $this->_calcOpaque() . '", ' : '')
486
. 'algorithm="' . $this->_algo . '", '
487
. 'qop="' . implode(',', $this->_supportedQops) . '"';
493
* Basic Authentication
495
* @param string $header Client's Authorization header
496
* @throws Zend_Auth_Adapter_Exception
497
* @return Zend_Auth_Result
499
protected function _basicAuth($header)
501
if (empty($header)) {
503
* @see Zend_Auth_Adapter_Exception
505
require_once 'Zend/Auth/Adapter/Exception.php';
506
throw new Zend_Auth_Adapter_Exception('The value of the client Authorization header is required');
508
if (empty($this->_basicResolver)) {
510
* @see Zend_Auth_Adapter_Exception
512
require_once 'Zend/Auth/Adapter/Exception.php';
513
throw new Zend_Auth_Adapter_Exception('A basicResolver object must be set before doing Basic '
517
// Decode the Authorization header
518
$auth = substr($header, strlen('Basic '));
519
$auth = base64_decode($auth);
522
* @see Zend_Auth_Adapter_Exception
524
require_once 'Zend/Auth/Adapter/Exception.php';
525
throw new Zend_Auth_Adapter_Exception('Unable to base64_decode Authorization header value');
528
// See ZF-1253. Validate the credentials the same way the digest
529
// implementation does. If invalid credentials are detected,
530
// re-challenge the client.
531
if (!ctype_print($auth)) {
532
return $this->_challengeClient();
534
// Fix for ZF-1515: Now re-challenges on empty username or password
535
$creds = array_filter(explode(':', $auth));
536
if (count($creds) != 2) {
537
return $this->_challengeClient();
540
$password = $this->_basicResolver->resolve($creds[0], $this->_realm);
541
if ($password && $password == $creds[1]) {
542
$identity = array('username'=>$creds[0], 'realm'=>$this->_realm);
543
return new Zend_Auth_Result(Zend_Auth_Result::SUCCESS, $identity);
545
return $this->_challengeClient();
550
* Digest Authentication
552
* @param string $header Client's Authorization header
553
* @throws Zend_Auth_Adapter_Exception
554
* @return Zend_Auth_Result Valid auth result only on successful auth
556
protected function _digestAuth($header)
558
if (empty($header)) {
560
* @see Zend_Auth_Adapter_Exception
562
require_once 'Zend/Auth/Adapter/Exception.php';
563
throw new Zend_Auth_Adapter_Exception('The value of the client Authorization header is required');
565
if (empty($this->_digestResolver)) {
567
* @see Zend_Auth_Adapter_Exception
569
require_once 'Zend/Auth/Adapter/Exception.php';
570
throw new Zend_Auth_Adapter_Exception('A digestResolver object must be set before doing Digest authentication');
573
$data = $this->_parseDigestAuth($header);
574
if ($data === false) {
575
$this->_response->setHttpResponseCode(400);
576
return new Zend_Auth_Result(
577
Zend_Auth_Result::FAILURE_UNCATEGORIZED,
579
array('Invalid Authorization header format')
583
// See ZF-1052. This code was a bit too unforgiving of invalid
584
// usernames. Now, if the username is bad, we re-challenge the client.
585
if ('::invalid::' == $data['username']) {
586
return $this->_challengeClient();
589
// Verify that the client sent back the same nonce
590
if ($this->_calcNonce() != $data['nonce']) {
591
return $this->_challengeClient();
593
// The opaque value is also required to match, but of course IE doesn't
595
if (!$this->_ieNoOpaque && $this->_calcOpaque() != $data['opaque']) {
596
return $this->_challengeClient();
599
// Look up the user's password hash. If not found, deny access.
600
// This makes no assumptions about how the password hash was
601
// constructed beyond that it must have been built in such a way as
602
// to be recreatable with the current settings of this object.
603
$ha1 = $this->_digestResolver->resolve($data['username'], $data['realm']);
604
if ($ha1 === false) {
605
return $this->_challengeClient();
608
// If MD5-sess is used, a1 value is made of the user's password
609
// hash with the server and client nonce appended, separated by
611
if ($this->_algo == 'MD5-sess') {
612
$ha1 = hash('md5', $ha1 . ':' . $data['nonce'] . ':' . $data['cnonce']);
615
// Calculate h(a2). The value of this hash depends on the qop
616
// option selected by the client and the supported hash functions
617
switch ($data['qop']) {
619
$a2 = $this->_request->getMethod() . ':' . $data['uri'];
622
// Should be REQUEST_METHOD . ':' . uri . ':' . hash(entity-body),
623
// but this isn't supported yet, so fall through to default case
626
* @see Zend_Auth_Adapter_Exception
628
require_once 'Zend/Auth/Adapter/Exception.php';
629
throw new Zend_Auth_Adapter_Exception('Client requested an unsupported qop option');
631
// Using hash() should make parameterizing the hash algorithm
633
$ha2 = hash('md5', $a2);
636
// Calculate the server's version of the request-digest. This must
637
// match $data['response']. See RFC 2617, section 3.2.2.1
638
$message = $data['nonce'] . ':' . $data['nc'] . ':' . $data['cnonce'] . ':' . $data['qop'] . ':' . $ha2;
639
$digest = hash('md5', $ha1 . ':' . $message);
641
// If our digest matches the client's let them in, otherwise return
642
// a 401 code and exit to prevent access to the protected resource.
643
if ($digest == $data['response']) {
644
$identity = array('username'=>$data['username'], 'realm'=>$data['realm']);
645
return new Zend_Auth_Result(Zend_Auth_Result::SUCCESS, $identity);
647
return $this->_challengeClient();
654
* @return string The nonce value
656
protected function _calcNonce()
658
// Once subtle consequence of this timeout calculation is that it
659
// actually divides all of time into _nonceTimeout-sized sections, such
660
// that the value of timeout is the point in time of the next
661
// approaching "boundary" of a section. This allows the server to
662
// consistently generate the same timeout (and hence the same nonce
663
// value) across requests, but only as long as one of those
664
// "boundaries" is not crossed between requests. If that happens, the
665
// nonce will change on its own, and effectively log the user out. This
666
// would be surprising if the user just logged in.
667
$timeout = ceil(time() / $this->_nonceTimeout) * $this->_nonceTimeout;
669
$nonce = hash('md5', $timeout . ':' . $this->_request->getServer('HTTP_USER_AGENT') . ':' . __CLASS__);
676
* The opaque string can be anything; the client must return it exactly as
677
* it was sent. It may be useful to store data in this string in some
678
* applications. Ideally, a new value for this would be generated each time
679
* a WWW-Authenticate header is sent (in order to reduce predictability),
680
* but we would have to be able to create the same exact value across at
681
* least two separate requests from the same client.
683
* @return string The opaque value
685
protected function _calcOpaque()
687
return hash('md5', 'Opaque Data:' . __CLASS__);
691
* Parse Digest Authorization header
693
* @param string $header Client's Authorization: HTTP header
694
* @return array|false Data elements from header, or false if any part of
695
* the header is invalid
697
protected function _parseDigestAuth($header)
702
// See ZF-1052. Detect invalid usernames instead of just returning a
704
$ret = preg_match('/username="([^"]+)"/', $header, $temp);
705
if (!$ret || empty($temp[1])
706
|| !ctype_print($temp[1])
707
|| strpos($temp[1], ':') !== false) {
708
$data['username'] = '::invalid::';
710
$data['username'] = $temp[1];
714
$ret = preg_match('/realm="([^"]+)"/', $header, $temp);
715
if (!$ret || empty($temp[1])) {
718
if (!ctype_print($temp[1]) || strpos($temp[1], ':') !== false) {
721
$data['realm'] = $temp[1];
725
$ret = preg_match('/nonce="([^"]+)"/', $header, $temp);
726
if (!$ret || empty($temp[1])) {
729
if (!ctype_xdigit($temp[1])) {
732
$data['nonce'] = $temp[1];
736
$ret = preg_match('/uri="([^"]+)"/', $header, $temp);
737
if (!$ret || empty($temp[1])) {
740
// Section 3.2.2.5 in RFC 2617 says the authenticating server must
741
// verify that the URI field in the Authorization header is for the
742
// same resource requested in the Request Line.
743
$rUri = @parse_url($this->_request->getRequestUri());
744
$cUri = @parse_url($temp[1]);
745
if (false === $rUri || false === $cUri) {
748
// Make sure the path portion of both URIs is the same
749
if ($rUri['path'] != $cUri['path']) {
752
// Section 3.2.2.5 seems to suggest that the value of the URI
753
// Authorization field should be made into an absolute URI if the
754
// Request URI is absolute, but it's vague, and that's a bunch of
755
// code I don't want to write right now.
756
$data['uri'] = $temp[1];
760
$ret = preg_match('/response="([^"]+)"/', $header, $temp);
761
if (!$ret || empty($temp[1])) {
764
if (32 != strlen($temp[1]) || !ctype_xdigit($temp[1])) {
767
$data['response'] = $temp[1];
771
// The spec says this should default to MD5 if omitted. OK, so how does
772
// that square with the algo we send out in the WWW-Authenticate header,
773
// if it can easily be overridden by the client?
774
$ret = preg_match('/algorithm="?(' . $this->_algo . ')"?/', $header, $temp);
775
if ($ret && !empty($temp[1])
776
&& in_array($temp[1], $this->_supportedAlgos)) {
777
$data['algorithm'] = $temp[1];
779
$data['algorithm'] = 'MD5'; // = $this->_algo; ?
783
// Not optional in this implementation
784
$ret = preg_match('/cnonce="([^"]+)"/', $header, $temp);
785
if (!$ret || empty($temp[1])) {
788
if (!ctype_print($temp[1])) {
791
$data['cnonce'] = $temp[1];
795
// If the server sent an opaque value, the client must send it back
796
if ($this->_useOpaque) {
797
$ret = preg_match('/opaque="([^"]+)"/', $header, $temp);
798
if (!$ret || empty($temp[1])) {
800
// Big surprise: IE isn't RFC 2617-compliant.
801
if (false !== strpos($this->_request->getHeader('User-Agent'), 'MSIE')) {
803
$this->_ieNoOpaque = true;
808
// This implementation only sends MD5 hex strings in the opaque value
809
if (!$this->_ieNoOpaque &&
810
(32 != strlen($temp[1]) || !ctype_xdigit($temp[1]))) {
813
$data['opaque'] = $temp[1];
818
// Not optional in this implementation, but must be one of the supported
820
$ret = preg_match('/qop="?(' . implode('|', $this->_supportedQops) . ')"?/', $header, $temp);
821
if (!$ret || empty($temp[1])) {
824
if (!in_array($temp[1], $this->_supportedQops)) {
827
$data['qop'] = $temp[1];
831
// Not optional in this implementation. The spec says this value
832
// shouldn't be a quoted string, but apparently some implementations
833
// quote it anyway. See ZF-1544.
834
$ret = preg_match('/nc="?([0-9A-Fa-f]{8})"?/', $header, $temp);
835
if (!$ret || empty($temp[1])) {
838
if (8 != strlen($temp[1]) || !ctype_xdigit($temp[1])) {
841
$data['nc'] = $temp[1];