marcus welz

Logging in users via Zend_Auth without Sessions in PHP / Zend Framework

Posted on January 3, 2009

While sessions come in handy for many things, I like to avoid them for the sake of scalability. PHP itself uses a shared-nothing architecture, with only sessions needing special treatment.

Once you've got more than one web server, your options are to either use a custom session handler, a load balancer with IP affinity, the commercial Zend Platform with clustered sessions, or simply no sessions at all.

For the purpose of authenticating users, I use the Zend_Auth component, sometimes with custom adapters as an application may require.

The issue at hand, however, is that Zend_Auth's default storage backend uses Zend_Session. Luckily it is super easy to write a different Zend_Auth_Storage_Backend that uses cookies instead. The one pitfall in this case is ensuring that malicious users cannot manipulate the cookie value in order to impersonate users or otherwise gain any kind of advantage. So we don't just store the identity that Zend_Auth throws at the backend, we also generate a salted hash that prevents tampered cookies from throwing us for a loop.

Aside from now being able to scale a web application in a clustered environment, there are additional side-effects, which may be seen as benefits. Sessions can expire. Even though the cookie containing the session id (in PHP the default is PHPSESSID) may still exist on the client side, the PHP's session garbage collector may have already cleaned up the actual session data file after a certain timeout period (the default is 1800 seconds, which is half an hour).

That's something that won't happen when you're using the cookie backend, since the cookie (by default) expires after the browser closes. That means that unless you've specificially added a mechanism to time out users after a certain amount of inactivity, they'll remain logged in as long as they keep their browser windows open.

You may also want to let users remain logged in for longer than just the current browser session, you can achieve that by simply setting the cookie's expiration to a particular date in the future — Ideally in combination with a "[ ] remember me" checkbox on your web site's login form.

Here's the start of a class that implements this mechanism. It's only a draft, but should be functional as-is.


/**
 * A cookie-based Zend_Auth storage backend that does not require sessions
 *
 */
class My_Auth_Storage_Cookie implements Zend_Auth_Storage_Interface
{

    /**
     * @var string
     */
    protected $_basedir = '/';

    /**
     * @var string
     */
    protected $_cookieName;

    /**
     * @var string
     */
    protected $_domain;

    /**
     * @var string
     */
    protected $_secret;

    /**
     * @var mixed
     */
    protected $_cached;

    /**
     * Constructor
     *
     * @param string $cookieName The name of the cookie where the identity is stored
     * @param string $secret The salt used in the hashing of the data
     */
    public function __construct($cookieName = null, $secret = null)
    {
        if (null !== $cookieName) {
            $this->setCookieName($cookieName);
        }

        if (null !== $secret) {
            $this->setSecret($secret);
        }
    }

    /**
     * The basedir of the cookie
     *
     * @return string
     */
    public function getBasedir()
    {
        return $this->_basedir;
    }

    /**
     * Set the basedir of the cookie
     *
     * @param string $basedir
     * @return Portal_Auth_Storage_Cookie
     */
    public function setBasedir($basedir)
    {
        $this->_basedir = $basedir;
        return $this;
    }

    /**
     * Get the name of the cookie
     *
     * @return string
     */
    public function getCookieName()
    {
        return $this->_cookieName;
    }

    /**
     * Set the name of the cookie
     *
     * @param string $cookieName
     * @return Portal_Auth_Storage_Cookie
     */
    public function setCookieName($cookieName)
    {
        $this->_cookieName = $cookieName;
        return $this;
    }

    /**
     * Get the (sub)domain the cookie is set for
     *
     * @return string
     */
    public function getDomain()
    {
        return $this->_domain;
    }

    /**
     * Set the (sub)domain the cookie is set for
     *
     * @param string $domain
     * @return Portal_Auth_Storage_Cookie
     */
    public function setDomain($domain)
    {
        $this->_domain = $domain;
        return $this;
    }

    /**
     * Get the Secret (or salt) used to store the data
     *
     * @return string
     */
    public function getSecret()
    {
        return $this->_secret;
    }

    /**
     * Set Secret (or salt) used to store the data
     *
     * @param string $secret
     * @return Portal_Auth_Storage_Cookie
     */
    public function setSecret($secret)
    {
        $this->_secret = $secret;
        return $this;
    }

    /**
     * Returns true if and only if storage is empty
     *
     * @throws Zend_Auth_Storage_Exception If it is impossible to determine whether storage is empty
     * @return boolean
     */
    public function isEmpty()
    {
        return $this->read() == null;
    }

    /**
     * Returns the contents of storage
     *
     * Behavior is undefined when storage is empty.
     *
     * @throws Zend_Auth_Storage_Exception If reading contents from storage is impossible
     * @return mixed
     */
    public function read()
    {
        if (!$this->_cached) {

            if (array_key_exists($this->_cookieName, $_COOKIE)) {
                $value = $_COOKIE[$this->_cookieName];

                list ($contents, $now, $checksum) = explode('|', $value);

                if (md5(base64_decode($contents) . $now . $this->_secret) == $checksum) {
                    $this->_cached = $contents;
                }

            }
        }

        return $this->_cached;
    }

    /**
     * Writes $contents to storage
     *
     * @param  mixed $contents
     * @throws Zend_Auth_Storage_Exception If writing $contents to storage is impossible
     * @return void
     */
    public function write($contents)
    {
        $this->_cached = $contents;

        $now = time();
        $checksum = md5($contents . $now . $this->_secret);
        $value = base64_encode($contents) . '|' . $now . '|' . $checksum;

        if (!setcookie($this->getCookieName(),
                       $value,
                       0, // end of the session
                       $this->getBasedir(),
                       $this->getDomain(),
                       null, // https
                       null)) {
            throw new Zend_Auth_Storage_Exception('Failed to set cookie');
        }

    }

    /**
     * Clears contents from storage
     *
     * @throws Zend_Auth_Storage_Exception If clearing contents from storage is impossible
     * @return void
     */
    public function clear()
    {
        if (!setcookie($this->_cookieName,
                       false, // clears the cookie
                       0,
                       $this->getBasedir())) {
            throw new Zend_Auth_Storage_Exception('Failed to clear cookie');
        }
    }
}

Please Note: The class stores the "identity" set by the Zend_Auth adapter as clear text. It also cannot handle anything other than scalar values. To be safe, the read() and write() methods really *should* handle arrays or other data types. This can be achieved by simply serializing when writing and unserializing when reading.

Now that you have the class, using it is straight forward. In your bootstrap, configure Zend_Auth like so:

Zend_Auth::getInstance()->setStorage(new My_Auth_Storage_Cookie('i', 'secretsalt'));

That's it already. Now you may use Zend_Auth as described in the Zend Framework Manual. And again, anything other than scalar values such as arrays is not supported without modifications, so persisting a DbTable Result Object won't work out of the box. Implementing the serialize() and unserialize() is left as an exercise for the reader. :-)

Print This Post Print This Post

Zend Framework and JQuery

Posted on October 26, 2008

I've been using Zend Framework v1.7.0PR to take advantage of the new JQuery support, which can be found under the ZendX (Zend Extras) directory. ZendX_JQuery is a great starting point and makes using JQuery in Zend Framework based applications quick and intuitive.

The ZendX_JQuery component helps set up the JQuery environment, and supports using Google's CDN to load both JQuery, and JQueryUI. In addition, there are a number of view helpers that make autocompletion, datepickers, and other JQueryUI features a breeze.

What currently isn't supported yet, is loading additional JQuery plugins, although it's a breeze to implement since the usage of most plugins is similar to the existing JQuery view helpers.

Print This Post Print This Post

Dirty Rows and Audit Trails with Zend_Db_Table

Posted on September 27, 2008

There are various ways to update rows in a database table using the Zend_Db_Table components. You can use use Zend_Db_Table::update(), like so:

$table = My_Table();
$table->update(array('age' => 22), 'id = 1');

or retrieve the row, and update it:

$table = My_Table();
$row = $table->find(1)->current();
$row->age = 22;
$row->save();

The big difference between the two approaches is that by first retrieving the row, and then updating it, you're actually using three queries. The first one to find the row, the second one to save it, and a third, which is used internally in to Zend_Db_Table_Row_Abstract to refresh data that might have gotten changed due to TIMESTAMP columns, triggers, etc.

If you dig into Zend/Db/Table/Row/Abstract.php, you can see that the class already tracks which columns were changed, so if you only change the value of a single column like the age in the example, not all columns of that row are updated in the database — only those that were actually modified. That's what the protected $_modifiedFields property is for; it records which properties on the Row object were set and only writes those fields to the database. It doesn't, however, check whether the new value is different from the old value.

There's also another protected property, called $_cleanData, which contains the row data as it is currently stored in the database. With that in mind, it is pretty simple to add additional logic to take advantage of that fact.

For instance, we can take it to the next level and only update the record if the column data differs from its previous data. Or perhaps we have a separate audit trail log that needs to capture any column data that was modified.

<?php

require_once 'Zend/Db/Table/Row/Abstract.php';

abstract class My_Db_Table_Row_Abstract extends Zend_Db_Table_Row_Abstract
{

    /**
     * Returns the values that have *actually* been changed
     *
     * @return array
     */
    public function getDirty()
    {
        return array_diff_assoc($this->_data, $this->_cleanData);
    }

    /**
     * Whether the record has been modified
     *
     * @return bool
     */
    public function isDirty()
    {
        return (bool) count($this->getDirty());
    }

    /**
     * Saves the properties to the database.
     *
     * This performs an intelligent insert/update, and reloads the
     * properties with fresh data from the table on success.
     *
     * Saving will only occur if any column values have been modified
     *
     * @return mixed The primary key value(s), as an associative array if the
     *     key is compound, or a scalar if the key is single-column.
     */
    public function save()
    {
        if ($this->isDirty()) {
            return parent::save();
        }
    }
}

I built a feature based on this to record when a row was modified, exactly which columns were updated, when, and by whom, in order to provide a rock-solid audit trail for a web application in a corporate environment.

Print This Post Print This Post

Zend_Db: Setting MySQL's timezone per connection

Posted on September 16, 2008

I have a Linux server with a system timezone of ET (US/Eastern). But I also have a web application that needs to run in a timezone of PT (US/Pacific). Of course that's not a problem at all. I just set the timezone in my web application's bootstrap:

date_default_timezone_set('America/Los_Angeles'); // Pacific timezone

Now I have another problem; the database. Sometimes I use PHP to generate dates such as date('Y-m-d H:i:s', strtotime('-2 minutes')). Other times I insert records and use new Zend_Db_Expr('NOW()');. But because MySQL isn't aware that I'd like to use pacific time, dates end up being inconsistent and are off by three hour.

It's a fairly easy fix, though, with a bit of logic added to a custom MySQL database adapter:

<?php

/**
 * @see Zend_Db_Adapter_Pdo_Mysql
 */
require_once 'Zend/Db/Adapter/Pdo/Mysql.php';

/**
 * MySQL PDO adapter extended to set the timezone
 */
class My_Db_Adapter_Pdo_Mysql extends Zend_Db_Adapter_Pdo_Mysql
{
    /**
     * @var bool
     */
    protected $_initialized = false;

    /**
     * Connects to the database.
     *
     */
    protected function _connect()
    {
        parent::_connect();

        if (!$this->_initialized) {
            $this->_initialized = true;

            if ($this->_config['timezone']) {

                // Requires PHP 5.2+
                $dtz = new DateTimeZone($this->_config['timezone']);
                $offset = $dtz->getOffset(new DateTime('NOW')) / 60 / 60;

                $this->query(sprintf("SET time_zone = '%d:00'", $offset));
            }
        }
    }
}

To kick this all off my bootstrap contains:

$config = array();
$config['host'] = 'localhost';
$config['username'] = 'username';
$config['password'] = 'password';
$config['dbname'] = 'mydatabase';
$config['timezone'] = 'America/Los_Angeles';
$config['adapterNamespace'] = 'My_Db_Adapter';

$db = Zend_Db::factory('PDO_MYSQL', $config);
Zend_Db_Table::setDefaultAdapter($db);

date_default_timezone_set('America/Los_Angeles');

And now I am free to continue my habit of inconsistency when specifying dates.

Print This Post Print This Post

The WSDL Blower: The state of SOAP in Zend Framework 1.6

Posted on September 13, 2008

There are all kinds of ways to expose APIs as web services. SOAP, XML-RPC, REST, JSON-RPC. Out of all of these, SOAP is arguably the most complex, but also one of the oldest ways to expose an API (I remember preliminary SOAP and WSDL support in Delphi 6, circa 2001).

Exposing an API as a web service in Zend Framework is fairly straight forward, in fact it is (or should be) as easy as one, two, three:

  1. Pick your favorite style (Zend_Soap_Server, Zend_XmlRpc_Server, Zend_Rest_Server, Zend_Json_Server).
  2. Define a properly documented class with the methods and business logic that you want to expose.
  3. Let your Zend_*_Server::handle(); everything else.

That's pretty much it. Of course each Zend_*_Server has its own settings and options that you can (and sometimes must) configure. XML-RPC is also the only server that supports namespaces.

If you decide on using SOAP, there are a few things to watch out for.

  1. The WSDL generator doesn't let you set the TargetNamespace attribute explicitly. This becomes an issue when you're working with different environments (development, testing, staging, production, etc.) since the URL of the service determines the namespace. And that makes automatic code generation based on the WSDL problematic. I've contributed a patch to address this, as part of ZF-4117.
  2. Zend_Soap_Client contains a bug that prevents it from properly proxying method calls. Instead, it'll end up recursing infinitely, or at least 100 times before PHP detects the issue and kills it. The quick fix of removing a single underscore froma method call is outlined in ZF-4152.
  3. Zend_Soap_Server doesn't properly turn Exceptions into SoapFaults (due to lack of typecasting) as described in ZF-3958. You can still throw SoapFaults explicitly, but that's just bad form since your class shouldn't be SOAP specific. After all, you may decide to also expose it as XML-RPC or what have you, and then the SoapFault, while it might still work properly, is semantically incorrect.
  4. While Zend_Soap_Server and Zend_Soap_Client are mostly wrappers for the native PHP SoapClient and SoapServer classes, PHP itself lacks WSDL generation which Zend_Soap_AutoDiscovery provides in conjunction with Zend_Soap_Wsdl. Unfortunately the notion of "array of datatype" is not supported, since arrays in PHP are simply declared as "array" and can contain anything. Zend Studio's WSDL generator supports the syntax of "string[]" to specify an array of strings, and this works with complex types as well. "User[]" is an array of User objects. The issue is outlined in ZF-3900, to which I didn't provide one, but two patches, neither of which actually do what I thought they'd do (go me!). However, I have put together an embarrassing hack (Zend_Soap_Wsdl arrayOfType patch) that finally does work — at least for me. So far I haven't worked up the courage to submit it to JIRA. Not one of my finest moments.

I doubt most of these issues will hang around for long, but if you're developing something SOAPy with ZF1.6, you'll like encounter at least one of them.

Print This Post Print This Post