Home | History | Annotate | Download | only in lightopenid
      1 <?php
      2 /**
      3  * This class provides a simple interface for OpenID (1.1 and 2.0) authentication.
      4  * Supports Yadis discovery.
      5  * The authentication process is stateless/dumb.
      6  *
      7  * Usage:
      8  * Sign-on with OpenID is a two step process:
      9  * Step one is authentication with the provider:
     10  * <code>
     11  * $openid = new LightOpenID;
     12  * $openid->identity = 'ID supplied by user';
     13  * header('Location: ' . $openid->authUrl());
     14  * </code>
     15  * The provider then sends various parameters via GET, one of them is openid_mode.
     16  * Step two is verification:
     17  * <code>
     18  * if ($this->data['openid_mode']) {
     19  *     $openid = new LightOpenID;
     20  *     echo $openid->validate() ? 'Logged in.' : 'Failed';
     21  * }
     22  * </code>
     23  *
     24  * Optionally, you can set $returnUrl and $realm (or $trustRoot, which is an alias).
     25  * The default values for those are:
     26  * $openid->realm     = (!empty($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
     27  * $openid->returnUrl = $openid->realm . $_SERVER['REQUEST_URI'];
     28  * If you don't know their meaning, refer to any openid tutorial, or specification. Or just guess.
     29  *
     30  * AX and SREG extensions are supported.
     31  * To use them, specify $openid->required and/or $openid->optional.
     32  * These are arrays, with values being AX schema paths (the 'path' part of the URL).
     33  * For example:
     34  *   $openid->required = array('namePerson/friendly', 'contact/email');
     35  *   $openid->optional = array('namePerson/first');
     36  * If the server supports only SREG or OpenID 1.1, these are automaticaly
     37  * mapped to SREG names, so that user doesn't have to know anything about the server.
     38  *
     39  * To get the values, use $openid->getAttributes().
     40  *
     41  *
     42  * The library depends on curl, and requires PHP 5.
     43  * @author Mewp
     44  * @copyright Copyright (c) 2010, Mewp
     45  * @license http://www.opensource.org/licenses/mit-license.php MIT
     46  */
     47 class LightOpenID
     48 {
     49     public $returnUrl
     50          , $required = array()
     51          , $optional = array();
     52     private $identity, $claimed_id;
     53     protected $server, $version, $trustRoot, $aliases, $identifier_select = false
     54             , $ax = false, $sreg = false, $data;
     55     static protected $ax_to_sreg = array(
     56         'namePerson/friendly'     => 'nickname',
     57         'contact/email'           => 'email',
     58         'namePerson'              => 'fullname',
     59         'birthDate'               => 'dob',
     60         'person/gender'           => 'gender',
     61         'contact/postalCode/home' => 'postcode',
     62         'contact/country/home'    => 'country',
     63         'pref/language'           => 'language',
     64         'pref/timezone'           => 'timezone',
     65         );
     66 
     67     function __construct()
     68     {
     69         $this->trustRoot = (!empty($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
     70         $this->returnUrl = $this->trustRoot . $_SERVER['REQUEST_URI'];
     71 
     72         if (!function_exists('curl_exec')) {
     73             throw new ErrorException('Curl extension is required.');
     74         }
     75 
     76         $this->data = $_POST + $_GET; # OPs may send data as POST or GET.
     77     }
     78 
     79     function __set($name, $value)
     80     {
     81         switch ($name) {
     82         case 'identity':
     83             if (strlen($value = trim($value))) {
     84                 if (preg_match('#^xri:/*#i', $value, $m)) {
     85                     $value = substr($value, strlen($m[0]));
     86                 } elseif (!preg_match('/^(?:[=@+\$!\(]|https?:)/i', $value)) {
     87                     $value = "http://$value";
     88                 }
     89                 if (preg_match('#^https?://[^/]+$#i', $value, $m)) {
     90                     $value .= '/';
     91                 }
     92             }
     93             $this->$name = $this->claimed_id = $value;
     94             break;
     95         case 'trustRoot':
     96         case 'realm':
     97             $this->trustRoot = trim($value);
     98         }
     99     }
    100 
    101     function __get($name)
    102     {
    103         switch ($name) {
    104         case 'identity':
    105             # We return claimed_id instead of identity,
    106             # because the developer should see the claimed identifier,
    107             # i.e. what he set as identity, not the op-local identifier (which is what we verify)
    108             return $this->claimed_id;
    109         case 'trustRoot':
    110         case 'realm':
    111             return $this->trustRoot;
    112         }
    113     }
    114 
    115     protected function request($url, $method='GET', $params=array())
    116     {
    117         $params = http_build_query($params, '', '&');
    118         $curl = curl_init($url . ($method == 'GET' && $params ? '?' . $params : ''));
    119         curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
    120         curl_setopt($curl, CURLOPT_HEADER, false);
    121         curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
    122         curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
    123         if ($method == 'POST') {
    124             curl_setopt($curl, CURLOPT_POST, true);
    125             curl_setopt($curl, CURLOPT_POSTFIELDS, $params);
    126         } elseif ($method == 'HEAD') {
    127             curl_setopt($curl, CURLOPT_HEADER, true);
    128             curl_setopt($curl, CURLOPT_NOBODY, true);
    129         } else {
    130             curl_setopt($curl, CURLOPT_HTTPGET, true);
    131         }
    132         $response = curl_exec($curl);
    133 
    134         if (curl_errno($curl)) {
    135             throw new ErrorException(curl_error($curl), curl_errno($curl));
    136         }
    137 
    138         return $response;
    139     }
    140 
    141     protected function build_url($url, $parts)
    142     {
    143         if (isset($url['query'], $parts['query'])) {
    144             $parts['query'] = $url['query'] . '&' . $parts['query'];
    145         }
    146 
    147         $url = $parts + $url;
    148         $url = $url['scheme'] . '://'
    149              . (empty($url['username'])?''
    150                  :(empty($url['password'])? "{$url['username']}@"
    151                  :"{$url['username']}:{$url['password']}@"))
    152              . $url['host']
    153              . (empty($url['port'])?'':":{$url['port']}")
    154              . (empty($url['path'])?'':$url['path'])
    155              . (empty($url['query'])?'':"?{$url['query']}")
    156              . (empty($url['fragment'])?'':":{$url['fragment']}");
    157         return $url;
    158     }
    159 
    160     /**
    161      * Helper function used to scan for <meta>/<link> tags and extract information
    162      * from them
    163      */
    164     protected function htmlTag($content, $tag, $attrName, $attrValue, $valueName)
    165     {
    166         preg_match_all("#<{$tag}[^>]*$attrName=['\"].*?$attrValue.*?['\"][^>]*$valueName=['\"](.+?)['\"][^>]*/?>#i", $content, $matches1);
    167         preg_match_all("#<{$tag}[^>]*$valueName=['\"](.+?)['\"][^>]*$attrName=['\"].*?$attrValue.*?['\"][^>]*/?>#i", $content, $matches2);
    168 
    169         $result = array_merge($matches1[1], $matches2[1]);
    170         return empty($result)?false:$result[0];
    171     }
    172 
    173     /**
    174      * Performs Yadis and HTML discovery. Normally not used.
    175      * @param $url Identity URL.
    176      * @return String OP Endpoint (i.e. OpenID provider address).
    177      * @throws ErrorException
    178      */
    179     function discover($url)
    180     {
    181         if (!$url) throw new ErrorException('No identity supplied.');
    182         # Use xri.net proxy to resolve i-name identities
    183         if (!preg_match('#^https?:#', $url)) {
    184             $url = "https://xri.net/$url";
    185         }
    186 
    187         # We save the original url in case of Yadis discovery failure.
    188         # It can happen when we'll be lead to an XRDS document
    189         # which does not have any OpenID2 services.
    190         $originalUrl = $url;
    191 
    192         # A flag to disable yadis discovery in case of failure in headers.
    193         $yadis = true;
    194 
    195         # We'll jump a maximum of 5 times, to avoid endless redirections.
    196         for ($i = 0; $i < 5; $i ++) {
    197             if ($yadis) {
    198                 $headers = explode("\n",$this->request($url, 'HEAD'));
    199 
    200                 $next = false;
    201                 foreach ($headers as $header) {
    202                     if (preg_match('#X-XRDS-Location\s*:\s*(.*)#', $header, $m)) {
    203                         $url = $this->build_url(parse_url($url), parse_url(trim($m[1])));
    204                         $next = true;
    205                     }
    206 
    207                     if (preg_match('#Content-Type\s*:\s*application/xrds\+xml#i', $header)) {
    208                         # Found an XRDS document, now let's find the server, and optionally delegate.
    209                         $content = $this->request($url, 'GET');
    210 
    211                         # OpenID 2
    212                         # We ignore it for MyOpenID, as it breaks sreg if using OpenID 2.0
    213                         $ns = preg_quote('http://specs.openid.net/auth/2.0/');
    214                         if (preg_match('#<Service.*?>(.*)<Type>\s*'.$ns.'(.*?)\s*</Type>(.*)</Service>#s', $content, $m)) {
    215                             $content = ' ' . $m[1] . $m[3]; # The space is added, so that strpos doesn't return 0.
    216                             if ($m[2] == 'server') $this->identifier_select = true;
    217 
    218                             preg_match('#<URI.*?>(.*)</URI>#', $content, $server);
    219                             preg_match('#<(Local|Canonical)ID>(.*)</\1ID>#', $content, $delegate);
    220                             if (empty($server)) {
    221                                 return false;
    222                             }
    223                             # Does the server advertise support for either AX or SREG?
    224                             $this->ax   = (bool) strpos($content, '<Type>http://openid.net/srv/ax/1.0</Type>');
    225                             $this->sreg = strpos($content, '<Type>http://openid.net/sreg/1.0</Type>')
    226                                        || strpos($content, '<Type>http://openid.net/extensions/sreg/1.1</Type>');
    227 
    228                             $server = $server[1];
    229                             if (isset($delegate[2])) $this->identity = trim($delegate[2]);
    230                             $this->version = 2;
    231 
    232                             $this->server = $server;
    233                             return $server;
    234                         }
    235 
    236                         # OpenID 1.1
    237                         $ns = preg_quote('http://openid.net/signon/1.1');
    238                         if (preg_match('#<Service.*?>(.*)<Type>\s*'.$ns.'\s*</Type>(.*)</Service>#s', $content, $m)) {
    239                             $content = ' ' . $m[1] . $m[2];
    240 
    241                             preg_match('#<URI.*?>(.*)</URI>#', $content, $server);
    242                             preg_match('#<.*?Delegate>(.*)</.*?Delegate>#', $content, $delegate);
    243                             if (empty($server)) {
    244                                 return false;
    245                             }
    246                             # AX can be used only with OpenID 2.0, so checking only SREG
    247                             $this->sreg = strpos($content, '<Type>http://openid.net/sreg/1.0</Type>')
    248                                        || strpos($content, '<Type>http://openid.net/extensions/sreg/1.1</Type>');
    249 
    250                             $server = $server[1];
    251                             if (isset($delegate[1])) $this->identity = $delegate[1];
    252                             $this->version = 1;
    253 
    254                             $this->server = $server;
    255                             return $server;
    256                         }
    257 
    258                         $next = true;
    259                         $yadis = false;
    260                         $url = $originalUrl;
    261                         $content = null;
    262                         break;
    263                     }
    264                 }
    265                 if ($next) continue;
    266 
    267                 # There are no relevant information in headers, so we search the body.
    268                 $content = $this->request($url, 'GET');
    269                 if ($location = $this->htmlTag($content, 'meta', 'http-equiv', 'X-XRDS-Location', 'value')) {
    270                     $url = $this->build_url(parse_url($url), parse_url($location));
    271                     continue;
    272                 }
    273             }
    274 
    275             if (!$content) $content = $this->request($url, 'GET');
    276 
    277             # At this point, the YADIS Discovery has failed, so we'll switch
    278             # to openid2 HTML discovery, then fallback to openid 1.1 discovery.
    279             $server   = $this->htmlTag($content, 'link', 'rel', 'openid2.provider', 'href');
    280             $delegate = $this->htmlTag($content, 'link', 'rel', 'openid2.local_id', 'href');
    281             $this->version = 2;
    282 
    283             if (!$server) {
    284                 # The same with openid 1.1
    285                 $server   = $this->htmlTag($content, 'link', 'rel', 'openid.server', 'href');
    286                 $delegate = $this->htmlTag($content, 'link', 'rel', 'openid.delegate', 'href');
    287                 $this->version = 1;
    288             }
    289 
    290             if ($server) {
    291                 # We found an OpenID2 OP Endpoint
    292                 if ($delegate) {
    293                     # We have also found an OP-Local ID.
    294                     $this->identity = $delegate;
    295                 }
    296                 $this->server = $server;
    297                 return $server;
    298             }
    299 
    300             throw new ErrorException('No servers found!');
    301         }
    302         throw new ErrorException('Endless redirection!');
    303     }
    304 
    305     protected function sregParams()
    306     {
    307         $params = array();
    308         # We always use SREG 1.1, even if the server is advertising only support for 1.0.
    309         # That's because it's fully backwards compatibile with 1.0, and some providers
    310         # advertise 1.0 even if they accept only 1.1. One such provider is myopenid.com
    311         $params['openid.ns.sreg'] = 'http://openid.net/extensions/sreg/1.1';
    312         if ($this->required) {
    313             $params['openid.sreg.required'] = array();
    314             foreach ($this->required as $required) {
    315                 if (!isset(self::$ax_to_sreg[$required])) continue;
    316                 $params['openid.sreg.required'][] = self::$ax_to_sreg[$required];
    317             }
    318             $params['openid.sreg.required'] = implode(',', $params['openid.sreg.required']);
    319         }
    320 
    321         if ($this->optional) {
    322             $params['openid.sreg.optional'] = array();
    323             foreach ($this->optional as $optional) {
    324                 if (!isset(self::$ax_to_sreg[$optional])) continue;
    325                 $params['openid.sreg.optional'][] = self::$ax_to_sreg[$optional];
    326             }
    327             $params['openid.sreg.optional'] = implode(',', $params['openid.sreg.optional']);
    328         }
    329         return $params;
    330     }
    331     protected function axParams()
    332     {
    333         $params = array();
    334         if ($this->required || $this->optional) {
    335             $params['openid.ns.ax'] = 'http://openid.net/srv/ax/1.0';
    336             $params['openid.ax.mode'] = 'fetch_request';
    337             $this->aliases  = array();
    338             $counts   = array();
    339             $required = array();
    340             $optional = array();
    341             foreach (array('required','optional') as $type) {
    342                 foreach ($this->$type as $alias => $field) {
    343                     if (is_int($alias)) $alias = strtr($field, '/', '_');
    344                     $this->aliases[$alias] = 'http://axschema.org/' . $field;
    345                     if (empty($counts[$alias])) $counts[$alias] = 0;
    346                     $counts[$alias] += 1;
    347                     ${$type}[] = $alias;
    348                 }
    349             }
    350             foreach ($this->aliases as $alias => $ns) {
    351                 $params['openid.ax.type.' . $alias] = $ns;
    352             }
    353             foreach ($counts as $alias => $count) {
    354                 if ($count == 1) continue;
    355                 $params['openid.ax.count.' . $alias] = $count;
    356             }
    357 
    358             # Don't send empty ax.requied and ax.if_available.
    359             # Google and possibly other providers refuse to support ax when one of these is empty.
    360             if($required) {
    361                 $params['openid.ax.required'] = implode(',', $required);
    362             }
    363             if($optional) {
    364                 $params['openid.ax.if_available'] = implode(',', $optional);
    365             }
    366         }
    367         return $params;
    368     }
    369 
    370     protected function authUrl_v1()
    371     {
    372 	$returnUrl = $this->returnUrl;
    373         # If we have an openid.delegate that is different from our claimed id,
    374         # we need to somehow preserve the claimed id between requests.
    375         # The simplest way is to just send it along with the return_to url.
    376         if($this->identity != $this->claimed_id) {
    377             $returnUrl .= (strpos($returnUrl, '?') ? '&' : '?') . 'openid.claimed_id=' . $this->claimed_id;
    378         }
    379 
    380         $params = array(
    381             'openid.return_to'  => $returnUrl,
    382             'openid.mode'       => 'checkid_setup',
    383             'openid.identity'   => $this->identity,
    384             'openid.trust_root' => $this->trustRoot,
    385             ) + $this->sregParams();
    386 
    387         return $this->build_url(parse_url($this->server)
    388                                , array('query' => http_build_query($params, '', '&')));
    389     }
    390 
    391     protected function authUrl_v2($identifier_select)
    392     {
    393         $params = array(
    394             'openid.ns'          => 'http://specs.openid.net/auth/2.0',
    395             'openid.mode'        => 'checkid_setup',
    396             'openid.return_to'   => $this->returnUrl,
    397             'openid.realm'       => $this->trustRoot,
    398         );
    399         if ($this->ax) {
    400             $params += $this->axParams();
    401         }
    402         if ($this->sreg) {
    403             $params += $this->sregParams();
    404         }
    405         if (!$this->ax && !$this->sreg) {
    406             # If OP doesn't advertise either SREG, nor AX, let's send them both
    407             # in worst case we don't get anything in return.
    408             $params += $this->axParams() + $this->sregParams();
    409         }
    410 
    411         if ($identifier_select) {
    412             $params['openid.identity'] = $params['openid.claimed_id']
    413                  = 'http://specs.openid.net/auth/2.0/identifier_select';
    414         } else {
    415             $params['openid.identity'] = $this->identity;
    416             $params['openid.claimed_id'] = $this->claimed_id;
    417         }
    418 
    419         return $this->build_url(parse_url($this->server)
    420                                , array('query' => http_build_query($params, '', '&')));
    421     }
    422 
    423     /**
    424      * Returns authentication url. Usually, you want to redirect your user to it.
    425      * @return String The authentication url.
    426      * @param String $select_identifier Whether to request OP to select identity for an user in OpenID 2. Does not affect OpenID 1.
    427      * @throws ErrorException
    428      */
    429     function authUrl($identifier_select = null)
    430     {
    431         if (!$this->server) $this->discover($this->identity);
    432 
    433         if ($this->version == 2) {
    434             if ($identifier_select === null) {
    435                 return $this->authUrl_v2($this->identifier_select);
    436             }
    437             return $this->authUrl_v2($identifier_select);
    438         }
    439         return $this->authUrl_v1();
    440     }
    441 
    442     /**
    443      * Performs OpenID verification with the OP.
    444      * @return Bool Whether the verification was successful.
    445      * @throws ErrorException
    446      */
    447     function validate()
    448     {
    449         $this->claimed_id = isset($this->data['openid_claimed_id'])?$this->data['openid_claimed_id']:$this->data['openid_identity'];
    450         $params = array(
    451             'openid.assoc_handle' => $this->data['openid_assoc_handle'],
    452             'openid.signed'       => $this->data['openid_signed'],
    453             'openid.sig'          => $this->data['openid_sig'],
    454             );
    455 
    456         if (isset($this->data['openid_op_endpoint'])) {
    457             # We're dealing with an OpenID 2.0 server, so let's set an ns
    458             # Even though we should know location of the endpoint,
    459             # we still need to verify it by discovery, so $server is not set here
    460             $params['openid.ns'] = 'http://specs.openid.net/auth/2.0';
    461         }
    462         $server = $this->discover($this->data['openid_identity']);
    463 
    464         foreach (explode(',', $this->data['openid_signed']) as $item) {
    465             # Checking whether magic_quotes_gpc is turned on, because
    466             # the function may fail if it is. For example, when fetching
    467             # AX namePerson, it might containg an apostrophe, which will be escaped.
    468             # In such case, validation would fail, since we'd send different data than OP
    469             # wants to verify. stripslashes() should solve that problem, but we can't
    470             # use it when magic_quotes is off.
    471             $value = $this->data['openid_' . str_replace('.','_',$item)];
    472             $params['openid.' . $item] = get_magic_quotes_gpc() ? stripslashes($value) : $value;
    473         }
    474 
    475         $params['openid.mode'] = 'check_authentication';
    476 
    477         $response = $this->request($server, 'POST', $params);
    478 
    479         return preg_match('/is_valid\s*:\s*true/i', $response);
    480     }
    481     protected function getAxAttributes()
    482     {
    483         $alias = null;
    484         if (isset($this->data['openid_ns_ax'])
    485             && $this->data['openid_ns_ax'] != 'http://openid.net/srv/ax/1.0'
    486         ) { # It's the most likely case, so we'll check it before
    487             $alias = 'ax';
    488         } else {
    489             # 'ax' prefix is either undefined, or points to another extension,
    490             # so we search for another prefix
    491             foreach ($this->data as $key => $val) {
    492                 if (substr($key, 0, strlen('openid_ns_')) == 'openid_ns_'
    493                     && $val == 'http://openid.net/srv/ax/1.0'
    494                 ) {
    495                     $alias = substr($key, strlen('openid_ns_'));
    496                     break;
    497                 }
    498             }
    499         }
    500         if (!$alias) {
    501             # An alias for AX schema has not been found,
    502             # so there is no AX data in the OP's response
    503             return array();
    504         }
    505 
    506         foreach ($this->data as $key => $value) {
    507             $keyMatch = 'openid_' . $alias . '_value_';
    508             if (substr($key, 0, strlen($keyMatch)) != $keyMatch) {
    509                 continue;
    510             }
    511             $key = substr($key, strlen($keyMatch));
    512             if (!isset($this->data['openid_' . $alias . '_type_' . $key])) {
    513                 # OP is breaking the spec by returning a field without
    514                 # associated ns. This shouldn't happen, but it's better
    515                 # to check, than cause an E_NOTICE.
    516                 continue;
    517             }
    518             $key = substr($this->data['openid_' . $alias . '_type_' . $key],
    519                           strlen('http://axschema.org/'));
    520             $attributes[$key] = $value;
    521         }
    522         # Found the AX attributes, so no need to scan for SREG.
    523         return $attributes;
    524     }
    525     protected function getSregAttributes()
    526     {
    527         $attributes = array();
    528         $sreg_to_ax = array_flip(self::$ax_to_sreg);
    529         foreach ($this->data as $key => $value) {
    530             $keyMatch = 'openid_sreg_';
    531             if (substr($key, 0, strlen($keyMatch)) != $keyMatch) {
    532                 continue;
    533             }
    534             $key = substr($key, strlen($keyMatch));
    535             if (!isset($sreg_to_ax[$key])) {
    536                 # The field name isn't part of the SREG spec, so we ignore it.
    537                 continue;
    538             }
    539             $attributes[$sreg_to_ax[$key]] = $value;
    540         }
    541         return $attributes;
    542     }
    543     /**
    544      * Gets AX/SREG attributes provided by OP. should be used only after successful validaton.
    545      * Note that it does not guarantee that any of the required/optional parameters will be present,
    546      * or that there will be no other attributes besides those specified.
    547      * In other words. OP may provide whatever information it wants to.
    548      *     * SREG names will be mapped to AX names.
    549      *     * @return Array Array of attributes with keys being the AX schema names, e.g. 'contact/email'
    550      * @see http://www.axschema.org/types/
    551      */
    552     function getAttributes()
    553     {
    554         $attributes;
    555         if (isset($this->data['openid_ns'])
    556             && $this->data['openid_ns'] == 'http://specs.openid.net/auth/2.0'
    557         ) { # OpenID 2.0
    558             # We search for both AX and SREG attributes, with AX taking precedence.
    559             return $this->getAxAttributes() + $this->getSregAttributes();
    560         }
    561         return $this->getSregAttributes();
    562     }
    563 }
    564