• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 */
47class 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