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