29.2. Zend_OpenId_Consumer Basics

Zend_OpenId_Consumer is used to implement the OpenID authentication schema on web sites.

29.2.1. OpenID Authentication

From a site developers point of view, the OpenID authentication process consists of three steps:

  1. Show OpenID authentication form.

  2. Accept OpenID identity and pass it to the OpenID provider.

  3. Verify response from the OpenID provider.

In actual fact the OpenID authentication protocol performs more steps, but most of them are encapsulated inside the Zend_OpenId_Consumer, and they are transparent to the developer.

The OpenID authentication process is initiated by the end-user by filling in their identification into the appropriate form and submiting it. The following example shows a simple form that accepts an OpenID identifier. Note that the example shows only a login.

Пример 29.1. The Simple OpenID Login form

<html><body>
<form method="post" action="example-1_2.php"><fieldset>
<legend>OpenID Login</legend>
<input type="text" name="openid_identifier">
<input type="submit" name="openid_action" value="login">
</fieldset></form></body></html>
            

On submit this form passes the OpenID identity to the following PHP script that performs a second step of authentication. The only thing the PHP script needs to do in this step is call the Zend_OpenId_Consumer::login() method. The first argument of this method is an accepted OpenID identity and the second is a URL of a script that handles the third and last step of authentication.

Пример 29.2. The Authentication Request Handler

<?php
require_once "Zend/OpenId/Consumer.php";

$consumer = new Zend_OpenId_Consumer();
if (!$consumer->login($_POST['openid_identifier'], 'example-1_3.php')) {
    die("OpenID login failed.");
}
            

The Zend_OpenId_Consumer::login() performs discovery on a given identifier and on success, finds out the address of the identity provider and its local identifier. Then, it creates an association to the given provider so that both the site and provider know the same secret that is used to sign the following messages. Then it passes an authentication request to the provider. Note this request redirects the end-user's web browser to an OpenID server site, where users are able to continue the authentication process.

An OpenID Server usually asks users for; their password (if they weren't previously logged-in), if the user trusts this site and what information may be returned to the site. These interactions are not visible to the OpenID-enabled site so there is no what for it to get the user's password or other information that was not opened.

On success, Zend_OpenId_Consumer::login() never returns, because it performs an HTTP redirection, however in case of error it may return false. Errors may occure due to an invalid identity, dead provider, communication error, etc

The third step of authentication is initiated by a response from the OpenID provider, after it has already authenticated the user's password. This response is passed indirectly, as an HTTP redirection of the end-user's web browser. And the only thing that site must do is to check if this response is valid.

Пример 29.3. The Authentication Response Verifier

<?php
require_once "Zend/OpenId/Consumer.php";

$consumer = new Zend_OpenId_Consumer();
if ($consumer->verify($_GET, $id)) {
    echo "VALID $id";
} else {
    echo "INVALID $id";
}
            

This check is performed using the Zend_OpenId_Consumer::verify method, that takes the whole array of the HTTP request's arguments and checks if this response is properly signed by an appropriate OpenID provider. It also may assign the claimed OpenID identity that was entered by end-user in the first step into the second (optional) argument.

29.2.2. Combine all Steps in One Page

The following example combines all three steps together. It doesn't provide any additional functionality. The only advantage is that now developers don't need to specify any URL's of scripts that handle the next step. By default, all steps use the same URL. However, the script now includes a dispatch code that calls appropriate code for each step of authentication.

Пример 29.4. The Complete OpenID Login Script

<?php
require_once "Zend/OpenId/Consumer.php";

$status = "";
if (isset($_POST['openid_action']) &&
    $_POST['openid_action'] == "login" &&
    !empty($_POST['openid_identifier'])) {

    $consumer = new Zend_OpenId_Consumer();
    if (!$consumer->login($_POST['openid_identifier'])) {
        $status = "OpenID login failed.<br>";
    }
} else if (isset($_GET['openid_mode'])) {
    if ($_GET['openid_mode'] == "id_res") {
        $consumer = new Zend_OpenId_Consumer();
        if ($consumer->verify($_GET, $id)) {
            $status = "VALID $id";
        } else {
            $status = "INVALID $id";
        }
    } else if ($_GET['openid_mode'] == "cancel") {
        $status = "CANCELED";
    }
}
?>
<html><body>
<?php echo "$status<br>";?>
<form method="post"><fieldset>
<legend>OpenID Login</legend>
<input type="text" name="openid_identifier" value="">
<input type="submit" name="openid_action" value="login">
</fieldset></form></body></html>
            

In addition, this code differenciates between canceled and wrong authentication responses. The provider retuns a canceled responce in cases when an identity provider doesn't know the supplied identity or the user is not logged-in or they don't trust the site. A wrong response assumes that the responce is wrong or incorrectly signed.

29.2.3. Realm

When an OpenID-enabled site passes authentication requests to a provider, it identifies itself with a realm URL. This URL may be considered as a root of a trusted site. If the user trusts the URL they will also trust to matched and subsequent URLs.

By default, the realm URL is automatically set to the URL of the directory where the login script is. This decision is useful for most, but not all cases. Sometimes a whole site and not directory is used, or even a combination of several servers from one domain.

To implement this ability, developers may pass the realm value as a third argument to the Zend_OpenId_Consumer::login method. In the following example the single interaction asks for trusted access to all php.net sites.

Пример 29.5. Authentication Request for Specified Realm

<?php
require_once "Zend/OpenId/Consumer.php";

$consumer = new Zend_OpenId_Consumer();
if (!$consumer->login($_POST['openid_identifier'], 'example-3_3.php', 'http://*.php.net/')) {
    die("OpenID login failed.");
}
            

The example below only implements the second step of authentication, the first and third steps are the same as in the first example.

29.2.4. Immediate Check

In some situations it is necissary to see if a user is already logged-in into a trusted OpenID server without any interaction with the user. The Zend_OpenId_Consumer::check method does precisely that. It is executed with exactly the same arguments as Zend_OpenId_Consumer::login but it doesn't show the user any OpenID server pages. Therefore from the users point of view it is transparent and it seems as if they never left the site. The third step succeedes if user is already logged-in and trusted to the site otherwise it will fail.

Пример 29.6. Immediate Check without Interaction

<?php
require_once "Zend/OpenId/Consumer.php";

$consumer = new Zend_OpenId_Consumer();
if (!$consumer->check($_POST['openid_identifier'], 'example-4_3.php')) {
    die("OpenID login failed.");
}
            

The example below only implements the second step of authentication, first and third steps are the same as in the first example.

29.2.5. Zend_OpenId_Consumer_Storage

There are three steps to the OpenID authentication procedure, each step is performed by a separate HTTP request. To store information between requests Zend_OpenId_Consumer uses internal storage.

Developers may not care about this storage because by default Zend_OpenId_Consumer uses file-based storage under /tmp similar to PHP sessions. However, this storage may be not suitable in all cases. Some may want to store information in a database while others may need to use common storage suitable for big web-farms. Fortunately, developers may easily replace the default storage with their own. The only thing to implement is it's own storage class as a child of the Zend_OpenId_Consumer_Storage method and pass it as a first argument to the Zend_OpenId_Consumer constructor.

The following example demonstrates a simple storage that uses Zend_Db as the backend containing three groups of functions. the first is for working with associations, the second is to cache discovery information and the third is to check responce uniqueness. The class is implemented in such a way that it can be easily used with existing or new databases. If necessary, it will create database tables if they don't exist.

Пример 29.7. Databse Storage

<?php class DbStorage extends Zend_OpenId_Consumer_Storage
{
    private $_db;
    private $_association_table;
    private $_discovery_table;
    private $_nonce_table;

    public function __construct($db,
                                $association_table = "association",
                                $discovery_table = "discovery",
                                $nonce_table = "nonce")
    {
        $this->_db = $db;
        $this->_association_table = $association_table;
        $this->_discovery_table = $discovery_table;
        $this->_nonce_table = $nonce_table;
        $tables = $this->_db->listTables();
        if (!in_array($association_table, $tables)) {
            $this->_db->getConnection()->exec(
                "create table $association_table (" .
                " url     varchar(256) not null primary key," .
                " handle  varchar(256) not null," .
                " macFunc char(16) not null," .
                " secret  varchar(256) not null," .
                " expires timestamp" .
                ")");
        }
        if (!in_array($discovery_table, $tables)) {
            $this->_db->getConnection()->exec(
                "create table $discovery_table (" .
                " id      varchar(256) not null primary key," .
                " realId  varchar(256) not null," .
                " server  varchar(256) not null," .
                " version float," .
                " expires timestamp" .
                ")");
        }
        if (!in_array($nonce_table, $tables)) {
            $this->_db->getConnection()->exec(
                "create table $nonce_table (" .
                " nonce   varchar(256) not null primary key," .
                " created timestamp default current_timestamp" .
                ")");
        }
    }

    public function addAssociation($url, $handle, $macFunc, $secret, $expires)
    {
        $table = $this->_association_table;
        $secret = base64_encode($secret);
        $this->_db->query("insert into $table (url, handle, macFunc, secret, expires) " .
                          "values ('$url', '$handle', '$macFunc', '$secret', $expires)");
        return true;
    }

    public function getAssociation($url, &$handle, &$macFunc, &$secret, &$expires)
    {
        $table = $this->_association_table;
        $this->_db->query("delete from $table where expires < " . time());
        $res = $this->_db->fetchRow("select handle, macFunc, secret, expires from $table where url = '$url'");
        if (is_array($res)) {
            $handle  = $res['handle'];
            $macFunc = $res['macFunc'];
            $secret  = base64_decode($res['secret']);
            $expires = $res['expires'];
            return true;
        }
        return false;
    }

    public function getAssociationByHandle($handle, &$url, &$macFunc, &$secret, &$expires)
    {
        $table = $this->_association_table;
        $this->_db->query("delete from $table where expires < " . time());
        $res = $this->_db->fetchRow("select url, macFunc, secret, expires from $table where handle = '$handle'");
        if (is_array($res)) {
            $url     = $res['url'];
            $macFunc = $res['macFunc'];
            $secret  = base64_decode($res['secret']);
            $expires = $res['expires'];
            return true;
        }
        return false;
    }

    public function delAssociation($url)
    {
        $table = $this->_association_table;
        $this->_db->query("delete from $table where url = '$url'");
        return true;
    }

    public function addDiscoveryInfo($id, $realId, $server, $version, $expires)
    {
        $table = $this->_discovery_table;
        $this->_db->query("insert into $table (id, realId, server, version, expires) " .
                          "values ('$id', '$realId', '$server', $version, $expires)");
        return true;
    }

    public function getDiscoveryInfo($id, &$realId, &$server, &$version, &$expires)
    {
        $table = $this->_discovery_table;
        $this->_db->query("delete from $table where expires < " . time());
        $res = $this->_db->fetchRow("select realId, server, version, expires from $table where id = '$id'");
        if (is_array($res)) {
            $realId  = $res['realId'];
            $server  = $res['server'];
            $version = $res['version'];
            $expires = $res['expires'];
            return true;
        }
        return false;
    }

    public function delDiscoveryInfo($id)
    {
        $table = $this->_discovery_table;
        $this->_db->query("delete from $table where id = '$id'");
        return true;
    }

    public function isUniqueNonce($nonce)
    {
        $table = $this->_nonce_table;
        try {
            $ret = $this->_db->query("insert into $table (nonce) values ('$nonce')");
        } catch (Zend_Db_Statement_Exception $e) {
            return false;
        }
        return true;
    }

    public function purgeNonces($date=null)
    {
    }
}

$db = Zend_Db::factory('Pdo_Sqlite',
    array('dbname'=>'/tmp/openid_consumer.db'));
$storage = new DbStorage($db);
$consumer = new Zend_OpenId_Consumer($storage);
            

The example doesn't include OpenID authentication code itself, but it is based on the same logic as in the previous or following examples.

29.2.6. Simple Registration Extension

In addition to authentication, the OpenID can be used for light-weight profile exchange. This feature is not covered by OpenID authentication specification but by the OpenID Simple Registration Extension protocol. This protocol allows OpenID-enabled sites to ask for information about an end-user from OpenID providers. Such information may include:

  • nickname - any UTF-8 string that the end user wants to use as a nickname.

  • email - the email address of the end user as specified in section 3.4.1 of RFC2822.

  • fullname - a UTF-8 string representation of the end user's full name.

  • dob - the end user's date of birth as YYYY-MM-DD. Any values whose representation uses fewer than the specified number of digits should be zero-padded. The length of this value must always be 10. If the end user does not want to reveal any particular component of this value, it must be set to zero. For instance, if a end user wants to specify that his date of birth is in 1980, but not the month or day, the value returned shall be "1980-00-00".

  • gender - the end user's gender, "M" for male, "F" for female.

  • postcode - UTF-8 string that should conform to the end user's country's postal system.

  • country - the End User's country of residence as specified by ISO3166.

  • language - end User's preferred language as specified by ISO639.

  • timezone - ASCII string from TimeZone database. For example, "Europe/Paris" or "America/Los_Angeles".

An OpenID-enabled web site may ask for any combination of these fields. It may also strictly require some information and allow end-users to provide or hide other information. The following example creates an object of the Zend_OpenId_Extension_Sreg class that requires a nickname and optionally ask for email and fullname.

Пример 29.8. Sending Requests with a Simple Registration Extension

<?php
require_once "Zend/OpenId/Consumer.php";
require_once "Zend/OpenId/Extension/Sreg.php";

$sreg = new Zend_OpenId_Extension_Sreg(array(
    'nickname'=>true,
    'email'=>false,
    'fullname'=>false), null, 1.1);
$consumer = new Zend_OpenId_Consumer();
if (!$consumer->login($_POST['openid_identifier'], 'example-6_3.php', null, $sreg)) {
    die("OpenID login failed.");
}
            

As you can see the Zend_OpenId_Extension_Sreg constructor accepts an array of asked fields. This array has the names of fields as indexes and requirements flag as values. true means the field is required and false means the field is optional. The Zend_OpenId_Consumer::login accepts extensions or list of extensions as a fourth argument.

On the third step of authentication, the Zend_OpenId_Extension_Sreg object should be passed to Zend_OpenId_Consumer::verify. Then on successful authentication Zend_OpenId_Extension_Sreg::getProperties will return an associative array of requested fields.

Пример 29.9. Verifying Responses with a Simple Registration Extension

<?php
require_once "Zend/OpenId/Consumer.php";
require_once "Zend/OpenId/Extension/Sreg.php";

$sreg = new Zend_OpenId_Extension_Sreg(array(
    'nickname'=>true,
    'email'=>false,
    'fullname'=>false), null, 1.1);
$consumer = new Zend_OpenId_Consumer();
if ($consumer->verify($_GET, $id, $sreg)) {
    echo "VALID $id<br>\n";
    $data = $sreg->getProperties();
    if (isset($data['nickname'])) {
        echo "nickname: " . $data['nickname'] . "<br>\n";
    }
    if (isset($data['email'])) {
        echo "email: " . $data['email'] . "<br>\n";
    }
    if (isset($data['fullname'])) {
        echo "fullname: " . $data['fullname'] . "<br>\n";
    }
} else {
    echo "INVALID $id";
}
            

If Zend_OpenId_Extension_Sreg was created without any arguments, the user code should check for the existence of the required data itself. However, if the object is created with the same list of required fields as on the second step, it will automatically check for the existence of required data. In this case, Zend_OpenId_Consumer::verify will return false if any of the required fields are missing.

By default, Zend_OpenId_Extension_Sreg uses version 1.0, because the specification for version 1.1 is not yet finalized. However, some libraries don't fully support version 1.0. For example, www.myopenid.com requires an SREG namespace in requests which is only available in 1.1. To work with this server, explicitly set the version to 1.1 in the Zend_OpenId_Extension_Sreg constructor.

The second argument of the Zend_OpenId_Extension_Sreg constructor is a policy URL, that should be provided to the end-user by the identity provider.

29.2.7. Integration with Zend_Auth

Zend Framework provides a special class to support user authentication - Zend_Auth. This class can be used together with Zend_OpenId_Consumer. The following example shows how OpenIdAdapter implements the Zend_Auth_Adapter_Interface with the authenticate method.This performs an authentication query and verification.

The big difference between this adapter and existing ones, is that it works on two HTTP requests and includes a dispatch code to perform the second or third step of OpenID authentication.

Пример 29.10. Zend_Auth Adapter for OpenID

<?php
require_once "Zend/OpenId/Consumer.php";
require_once "Zend/Auth.php";
require_once "Zend/Auth/Adapter/Interface.php";
 class OpenIdAdapter implements Zend_Auth_Adapter_Interface {
    private $_id = null;

    public function __construct($id = null) {
        $this->_id = $id;
    }

    public function authenticate() {
        $id = $this->_id;
        if (!empty($id)) {
            $consumer = new Zend_OpenId_Consumer();
            if (!$consumer->login($id)) {
                $ret = false;
                $mdg = "Authentication failed.";
            }
        } else {
            $consumer = new Zend_OpenId_Consumer();
            if ($consumer->verify($_GET, $id)) {
                $ret = true;
                $msg = "Authentication successful";
            } else {
                $ret = false;
                $msg = "Authentication failed";
            }
        }
        return new Zend_Auth_Result($ret, $id, array($msg));
    }
}

$status = "";
$auth = Zend_Auth::getInstance();
if ((isset($_POST['openid_action']) &&
     $_POST['openid_action'] == "login" &&
     !empty($_POST['openid_identifier'])) ||
    isset($_GET['openid_mode'])) {
    $adapter = new OpenIdAdapter(@$_POST['openid_identifier']);
    $result = $auth->authenticate($adapter);
    if ($result->isValid()) {
        Zend_OpenId::redirect(Zend_OpenId::selfURL());
    } else {
        $auth->clearIdentity();
        foreach ($result->getMessages() as $message) {
            $status .= "$message<br>\n";
        }
    }
} else if ($auth->hasIdentity()) {
    if (isset($_POST['openid_action']) &&
        $_POST['openid_action'] == "logout") {
        $auth->clearIdentity();
    } else {
        $status = "Yoy are logged-in as " . $auth->getIdentity() . "<br>\n";
    }
}
?>
<html><body>
<?php echo "$status";?>
<form method="post"><fieldset>
<legend>OpenID Login</legend>
<input type="text" name="openid_identifier" value="">
<input type="submit" name="openid_action" value="login">
<input type="submit" name="openid_action" value="logout">
</fieldset></form></body></html>
            

With Zend_Auth the end-user's identity is saved in the session's data. It may be checked with Zend_Auth::hasIdentity and Zend_Auth::getIdentity.

29.2.8. Integration with Zend_Controller

Finally a couple of words about integration into Model-View-Controller applications. Such Zend Framework applications are implemented using the Zend_Controller class and they use objects of the Zend_Controller_Response_Http class to prepare HTTP responses and send them back to the end user's web-browser.

Zend_OpenId_Consumer doesn't provide any GUI capabilities but it performs HTTP redirections on success of Zend_OpenId_Consumer::login and Zend_OpenId_Consumer::check. These redirections, may work incorrectly or not work at all if some data was already sent to the web-browser. To properly perform HTTP redirection in MVC code the real Zend_Controller_Response_Http should be sent to Zend_OpenId_Consumer::login or Zend_OpenId_Consumer::check as the last argument.

    Поддержать сайт на родительском проекте КГБ