Categories

Archives

Did You Know?

My first computer was a Commodore 16, at age 5. By the time I was 8, I had familiarized myself with BASIC enough to write simple games with sound and joystick support.

Recent Comments

Tags

asp audio browser bug business coalesce code crash Database db debian extension framework imap internet legions linux metaverse mysql obscurity patch PHP postgresql properties release scp Second Life second life security session social media sound sql ssh subversion tables tortoisesvn tribes ubuntu virtual world web windows zend zend framework zf

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

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. :-)

Comments

Comment from Cliff
Time April 28, 2009 at 8:31 am

Just a heads up.

In line 189 (writing the cookie): you base64_encode() the $contents _after_ you've created the MD5 hash.

In line 166 (reading the cookie): you don't base64_decode() the $contents before performing the MD5 to compare against the checksum held in the cookie.

Line 166-167 should be:
if (md5(base64_decode($contents) . $now . $this->_secret) == $checksum) {
$this->_cached = $contents;
}

~ Cliff

Comment from marcus
Time May 5, 2009 at 8:06 pm

Thanks for pointing that out, Cliff, I appreciate it. I've updated the code.

As one might imagine, the base64 encoding was an untested last-minute change that I am not using in my production code because of the overhead.

Keeping the identity (say, just a username) in clear text also allows for some neat features, such as the ability to read it out via Javascript and update otherwise static HTML with a seemingly dynamic header. I'll save that for another post.

Write a comment