<?php
/**
 * Zend Framework
 *
 * LICENSE
 *
 * This source file is subject to the new BSD license that is bundled
 * with this package in the file LICENSE.txt.
 * It is also available through the world-wide-web at this URL:
 * http://framework.zend.com/license/new-bsd
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@zend.com so we can send you a copy immediately.
 *
 * @category   Zend
 * @package    Zend_Auth
 * @subpackage UnitTests
 * @copyright  Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
 * @license    http://framework.zend.com/license/new-bsd     New BSD License
 * @version    $Id: ProxyTest.php 20096 2010-01-06 02:05:09Z bkarwin $
 */


/**
 * Test helper
 */
require_once dirname(__FILE__) . '/../../../../TestHelper.php';


/**
 * @see Zend_Auth_Adapter_Http
 */
require_once 'Zend/Auth/Adapter/Http.php';


/**
 * @see Zend_Auth_Adapter_Http_Resolver_File
 */
require_once 'Zend/Auth/Adapter/Http/Resolver/File.php';


/**
 * @see Zend_Controller_Request_Http
 */
require_once 'Zend/Controller/Request/Http.php';


/**
 * @see Zend_Controller_Response_Http
 */
require_once 'Zend/Controller/Response/Http.php';


/**
 * @category   Zend
 * @package    Zend_Auth
 * @subpackage UnitTests
 * @copyright  Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
 * @license    http://framework.zend.com/license/new-bsd     New BSD License
 * @group      Zend_Auth
 */
class Zend_Auth_Adapter_Http_ProxyTest extends PHPUnit_Framework_TestCase
{
    /**
     * Path to test files
     *
     * @var string
     */
    protected $_filesPath;

    /**
     * HTTP Basic configuration
     *
     * @var array
     */
    protected $_basicConfig;

    /**
     * HTTP Digest configuration
     *
     * @var array
     */
    protected $_digestConfig;

    /**
     * HTTP Basic Digest configuration
     *
     * @var array
     */
    protected $_bothConfig;

    /**
     * File resolver setup against with HTTP Basic auth file
     *
     * @var Zend_Auth_Adapter_Http_Resolver_File
     */
    protected $_basicResolver;

    /**
     * File resolver setup against with HTTP Digest auth file
     *
     * @var Zend_Auth_Adapter_Http_Resolver_File
     */
    protected $_digestResolver;

    /**
     * Sets up test configuration
     *
     * @return void
     */
    public function __construct()
    {
        $this->_filesPath      = dirname(__FILE__) . '/_files';
        $this->_basicResolver  = new Zend_Auth_Adapter_Http_Resolver_File("{$this->_filesPath}/htbasic.1");
        $this->_digestResolver = new Zend_Auth_Adapter_Http_Resolver_File("{$this->_filesPath}/htdigest.3");
        $this->_basicConfig    = array(
            'accept_schemes' => 'basic',
            'realm'          => 'Test Realm',
            'proxy_auth'     => true
        );
        $this->_digestConfig   = array(
            'accept_schemes' => 'digest',
            'realm'          => 'Test Realm',
            'digest_domains' => '/ http://localhost/',
            'nonce_timeout'  => 300,
            'proxy_auth'     => true
        );
        $this->_bothConfig     = array(
            'accept_schemes' => 'basic digest',
            'realm'          => 'Test Realm',
            'digest_domains' => '/ http://localhost/',
            'nonce_timeout'  => 300,
            'proxy_auth'     => true
        );
    }

    public function testBasicChallenge()
    {
        // Trying to authenticate without sending an Proxy-Authorization header
        // should result in a 407 reply with a Proxy-Authenticate header, and a
        // false result.

        // The expected Basic Proxy-Authenticate header value
        $basic = 'Basic realm="' . $this->_bothConfig['realm'] . '"';

        $data = $this->_doAuth('', 'basic');
        $this->_checkUnauthorized($data, $basic);
    }

    public function testDigestChallenge()
    {
        // Trying to authenticate without sending an Proxy-Authorization header
        // should result in a 407 reply with a Proxy-Authenticate header, and a
        // false result.

        // The expected Digest Proxy-Authenticate header value
        $digest = $this->_digestChallenge();

        $data = $this->_doAuth('', 'digest');
        $this->_checkUnauthorized($data, $digest);
    }

    public function testBothChallenges()
    {
        // Trying to authenticate without sending an Proxy-Authorization header
        // should result in a 407 reply with at least one Proxy-Authenticate
        // header, and a false result.

        $data = $this->_doAuth('', 'both');
        extract($data); // $result, $status, $headers

        // The expected Proxy-Authenticate header values
        $basic  = 'Basic realm="' . $this->_bothConfig['realm'] . '"';
        $digest = $this->_digestChallenge();

        // Make sure the result is false
        $this->assertType('Zend_Auth_Result', $result);
        $this->assertFalse($result->isValid());

        // Verify the status code and the presence of both challenges
        $this->assertEquals(407, $status);
        $this->assertEquals('Proxy-Authenticate', $headers[0]['name']);
        $this->assertEquals('Proxy-Authenticate', $headers[1]['name']);

        // Check to see if the expected challenges match the actual
        $this->assertEquals($basic,  $headers[0]['value']);
        $this->assertEquals($digest, $headers[1]['value']);
    }

    public function testBasicAuthValidCreds()
    {
        // Attempt Basic Authentication with a valid username and password

        $data = $this->_doAuth('Basic ' . base64_encode('Bryce:ThisIsNotMyPassword'), 'basic');
        $this->_checkOK($data);
    }

    public function testBasicAuthBadCreds()
    {
        // Ensure that credentials containing invalid characters are treated as
        // a bad username or password.

        // The expected Basic WWW-Authenticate header value
        $basic = 'Basic realm="' . $this->_basicConfig['realm'] . '"';

        $data = $this->_doAuth('Basic ' . base64_encode("Bad\tChars:In:Creds"), 'basic');
        $this->_checkUnauthorized($data, $basic);
    }

    public function testBasicAuthBadUser()
    {
        // Attempt Basic Authentication with a bad username and password

        // The expected Basic Proxy-Authenticate header value
        $basic = 'Basic realm="' . $this->_basicConfig['realm'] . '"';

        $data = $this->_doAuth('Basic ' . base64_encode('Nobody:NotValid'), 'basic');
        $this->_checkUnauthorized($data, $basic);
    }

    public function testBasicAuthBadPassword()
    {
        // Attempt Basic Authentication with a valid username, but invalid
        // password

        // The expected Basic WWW-Authenticate header value
        $basic = 'Basic realm="' . $this->_basicConfig['realm'] . '"';

        $data = $this->_doAuth('Basic ' . base64_encode('Bryce:Invalid'), 'basic');
        $this->_checkUnauthorized($data, $basic);
    }

    public function testDigestAuthValidCreds()
    {
        // Attempt Digest Authentication with a valid username and password

        $data = $this->_doAuth($this->_digestReply('Bryce', 'ThisIsNotMyPassword'), 'digest');
        $this->_checkOK($data);
    }

    public function testDigestAuthDefaultAlgo()
    {
        // If the client omits the aglorithm argument, it should default to MD5,
        // and work just as above

        $cauth = $this->_digestReply('Bryce', 'ThisIsNotMyPassword');
        $cauth = preg_replace('/algorithm="MD5", /', '', $cauth);

        $data = $this->_doAuth($cauth, 'digest');
        $this->_checkOK($data);
    }

    public function testDigestAuthQuotedNC()
    {
        // The nonce count isn't supposed to be quoted, but apparently some
        // clients do anyway.

        $cauth = $this->_digestReply('Bryce', 'ThisIsNotMyPassword');
        $cauth = preg_replace('/nc=00000001/', 'nc="00000001"', $cauth);

        $data = $this->_doAuth($cauth, 'digest');
        $this->_checkOK($data);
    }

    public function testDigestAuthBadCreds()
    {
        // Attempt Digest Authentication with a bad username and password

        // The expected Digest Proxy-Authenticate header value
        $digest = $this->_digestChallenge();

        $data = $this->_doAuth($this->_digestReply('Nobody', 'NotValid'), 'digest');
        $this->_checkUnauthorized($data, $digest);
    }

    public function testDigestTampered()
    {
        // Create the tampered header value
        $tampered = $this->_digestReply('Bryce', 'ThisIsNotMyPassword');
        $tampered = preg_replace(
            '/ nonce="[a-fA-F0-9]{32}", /',
            ' nonce="' . str_repeat('0', 32).'", ',
            $tampered
        );

        // The expected Digest Proxy-Authenticate header value
        $digest = $this->_digestChallenge();

        $data = $this->_doAuth($tampered, 'digest');
        $this->_checkUnauthorized($data, $digest);
    }

    public function testBadSchemeRequest()
    {
        // Sending a request for an invalid authentication scheme should result
        // in a 400 Bad Request response.

        $data = $this->_doAuth('Invalid ' . base64_encode('Nobody:NotValid'), 'basic');
        $this->_checkBadRequest($data);
    }

    public function testBadDigestRequest()
    {
        // If any of the individual parts of the Digest Proxy-Authorization header
        // are bad, it results in a 400 Bad Request. But that's a lot of
        // possibilities, so we're just going to pick one for now.
        $bad = $this->_digestReply('Bryce', 'ThisIsNotMyPassword');
        $bad = preg_replace(
            '/realm="([^"]+)"/',  // cut out the realm
            '', $bad
        );

        $data = $this->_doAuth($bad, 'digest');
        $this->_checkBadRequest($data);
    }

    /**
     * Acts like a client sending the given Authenticate header value.
     *
     * @param  string $clientHeader Authenticate header value
     * @param  string $scheme       Which authentication scheme to use
     * @return array Containing the result, the response headers, and the status
     */
    public function _doAuth($clientHeader, $scheme)
    {
        // Set up stub request and response objects
        $request  = $this->getMock('Zend_Controller_Request_Http');
        $response = new Zend_Controller_Response_Http;
        $response->setHttpResponseCode(200);
        $response->headersSentThrowsException = false;

        // Set stub method return values
        $request->expects($this->any())
                ->method('getRequestUri')
                ->will($this->returnValue('/'));
        $request->expects($this->any())
                ->method('getMethod')
                ->will($this->returnValue('GET'));
        $request->expects($this->any())
                ->method('getServer')
                ->will($this->returnValue('PHPUnit'));
        $request->expects($this->any())
                ->method('getHeader')
                ->will($this->returnValue($clientHeader));

        // Select an Authentication scheme
        switch ($scheme) {
            case 'basic':
                $use = $this->_basicConfig;
                break;
            case 'digest':
                $use = $this->_digestConfig;
                break;
            case 'both':
            default:
                $use = $this->_bothConfig;
        }

        // Create the HTTP Auth adapter
        $a = new Zend_Auth_Adapter_Http($use);
        $a->setBasicResolver($this->_basicResolver);
        $a->setDigestResolver($this->_digestResolver);

        // Send the authentication request
        $a->setRequest($request);
        $a->setResponse($response);
        $result = $a->authenticate();

        $return = array(
            'result'  => $result,
            'status'  => $response->getHttpResponseCode(),
            'headers' => $response->getHeaders()
        );
        return $return;
    }

    /**
     * Constructs a local version of the digest challenge we expect to receive
     *
     * @return string
     */
    protected function _digestChallenge()
    {
        $timeout = ceil(time() / 300) * 300;
        $nonce   = md5($timeout . ':PHPUnit:Zend_Auth_Adapter_Http');
        $opaque  = md5('Opaque Data:Zend_Auth_Adapter_Http');
        $wwwauth = 'Digest '
                 . 'realm="' . $this->_digestConfig['realm'] . '", '
                 . 'domain="' . $this->_digestConfig['digest_domains'] . '", '
                 . 'nonce="' . $nonce . '", '
                 . 'opaque="' . $opaque . '", '
                 . 'algorithm="MD5", '
                 . 'qop="auth"';

        return $wwwauth;
    }

    /**
     * Constructs a client digest Proxy-Authorization header
     *
     * @param  string $user
     * @param  string $pass
     * @return string
     */
    protected function _digestReply($user, $pass)
    {
        $nc       = '00000001';
        $timeout  = ceil(time() / 300) * 300;
        $nonce    = md5($timeout . ':PHPUnit:Zend_Auth_Adapter_Http');
        $opaque   = md5('Opaque Data:Zend_Auth_Adapter_Http');
        $cnonce   = md5('cnonce');
        $response = md5(md5($user . ':' . $this->_digestConfig['realm'] . ':' . $pass) . ":$nonce:$nc:$cnonce:auth:"
                  . md5('GET:/'));
        $cauth = 'Digest '
               . 'username="Bryce", '
               . 'realm="' . $this->_digestConfig['realm'] . '", '
               . 'nonce="' . $nonce . '", '
               . 'uri="/", '
               . 'response="' . $response . '", '
               . 'algorithm="MD5", '
               . 'cnonce="' . $cnonce . '", '
               . 'opaque="' . $opaque . '", '
               . 'qop="auth", '
               . 'nc=' . $nc;

        return $cauth;
    }

    /**
     * Checks for an expected 407 Proxy-Unauthorized response
     *
     * @param  array  $data     Authentication results
     * @param  string $expected Expected Proxy-Authenticate header value
     * @return void
     */
    protected function _checkUnauthorized($data, $expected)
    {
        extract($data); // $result, $status, $headers

        // Make sure the result is false
        $this->assertType('Zend_Auth_Result', $result);
        $this->assertFalse($result->isValid());

        // Verify the status code and the presence of the challenge
        $this->assertEquals(407, $status);
        $this->assertEquals('Proxy-Authenticate', $headers[0]['name']);

        // Check to see if the expected challenge matches the actual
        $this->assertEquals($expected, $headers[0]['value']);
    }

    /**
     * Checks for an expected 200 OK response
     *
     * @param  array $data Authentication results
     * @return void
     */
    protected function _checkOK($data)
    {
        extract($data); // $result, $status, $headers

        // Make sure the result is true
        $this->assertType('Zend_Auth_Result', $result);
        $this->assertTrue($result->isValid());

        // Verify we got a 200 response
        $this->assertEquals(200, $status);
    }

    /**
     * Checks for an expected 400 Bad Request response
     *
     * @param  array $data Authentication results
     * @return void
     */
    protected function _checkBadRequest($data)
    {
        extract($data); // $result, $status, $headers

        // Make sure the result is false
        $this->assertType('Zend_Auth_Result', $result);
        $this->assertFalse($result->isValid());

        // Make sure it set the right HTTP code
        $this->assertEquals(400, $status);
    }
}
