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