1<?php 2 3/** 4 * This file supplies a Memcached store backend for OpenID servers and 5 * consumers. 6 * 7 * PHP versions 4 and 5 8 * 9 * LICENSE: See the COPYING file included in this distribution. 10 * 11 * @package OpenID 12 * @author JanRain, Inc. <openid@janrain.com> 13 * @copyright 2005-2008 Janrain, Inc. 14 * @license http://www.apache.org/licenses/LICENSE-2.0 Apache 15 */ 16 17/** 18 * Require base class for creating a new interface. 19 */ 20require_once 'Auth/OpenID.php'; 21require_once 'Auth/OpenID/Interface.php'; 22require_once 'Auth/OpenID/HMAC.php'; 23require_once 'Auth/OpenID/Nonce.php'; 24 25/** 26 * This is a filesystem-based store for OpenID associations and 27 * nonces. This store should be safe for use in concurrent systems on 28 * both windows and unix (excluding NFS filesystems). There are a 29 * couple race conditions in the system, but those failure cases have 30 * been set up in such a way that the worst-case behavior is someone 31 * having to try to log in a second time. 32 * 33 * Most of the methods of this class are implementation details. 34 * People wishing to just use this store need only pay attention to 35 * the constructor. 36 * 37 * @package OpenID 38 */ 39class Auth_OpenID_FileStore extends Auth_OpenID_OpenIDStore { 40 41 protected $directory = ''; 42 protected $active = false; 43 protected $nonce_dir = ''; 44 protected $association_dir = ''; 45 protected $temp_dir = ''; 46 protected $max_nonce_age = 0; 47 48 /** 49 * Initializes a new {@link Auth_OpenID_FileStore}. This 50 * initializes the nonce and association directories, which are 51 * subdirectories of the directory passed in. 52 * 53 * @param string $directory This is the directory to put the store 54 * directories in. 55 */ 56 function __construct($directory) 57 { 58 if (!Auth_OpenID::ensureDir($directory)) { 59 trigger_error('Not a directory and failed to create: ' 60 . $directory, E_USER_ERROR); 61 } 62 $directory = realpath($directory); 63 64 $this->directory = $directory; 65 $this->active = true; 66 67 $this->nonce_dir = $directory . DIRECTORY_SEPARATOR . 'nonces'; 68 69 $this->association_dir = $directory . DIRECTORY_SEPARATOR . 70 'associations'; 71 72 // Temp dir must be on the same filesystem as the assciations 73 // $directory. 74 $this->temp_dir = $directory . DIRECTORY_SEPARATOR . 'temp'; 75 76 $this->max_nonce_age = 6 * 60 * 60; // Six hours, in seconds 77 78 if (!$this->_setup()) { 79 trigger_error('Failed to initialize OpenID file store in ' . 80 $directory, E_USER_ERROR); 81 } 82 } 83 84 function destroy() 85 { 86 Auth_OpenID_FileStore::_rmtree($this->directory); 87 $this->active = false; 88 } 89 90 /** 91 * Make sure that the directories in which we store our data 92 * exist. 93 * 94 * @access private 95 */ 96 function _setup() 97 { 98 return (Auth_OpenID::ensureDir($this->nonce_dir) && 99 Auth_OpenID::ensureDir($this->association_dir) && 100 Auth_OpenID::ensureDir($this->temp_dir)); 101 } 102 103 /** 104 * Create a temporary file on the same filesystem as 105 * $this->association_dir. 106 * 107 * The temporary directory should not be cleaned if there are any 108 * processes using the store. If there is no active process using 109 * the store, it is safe to remove all of the files in the 110 * temporary directory. 111 * 112 * @return array ($fd, $filename) 113 * @access private 114 */ 115 function _mktemp() 116 { 117 $name = Auth_OpenID_FileStore::_mkstemp($dir = $this->temp_dir); 118 $file_obj = @fopen($name, 'wb'); 119 if ($file_obj !== false) { 120 return [$file_obj, $name]; 121 } else { 122 Auth_OpenID_FileStore::_removeIfPresent($name); 123 } 124 return []; 125 } 126 127 function cleanupNonces() 128 { 129 global $Auth_OpenID_SKEW; 130 131 $nonces = Auth_OpenID_FileStore::_listdir($this->nonce_dir); 132 $now = time(); 133 134 $removed = 0; 135 // Check all nonces for expiry 136 foreach ($nonces as $nonce_fname) { 137 $base = basename($nonce_fname); 138 $parts = explode('-', $base, 2); 139 $timestamp = $parts[0]; 140 $timestamp = intval($timestamp, 16); 141 if (abs($timestamp - $now) > $Auth_OpenID_SKEW) { 142 Auth_OpenID_FileStore::_removeIfPresent($nonce_fname); 143 $removed += 1; 144 } 145 } 146 return $removed; 147 } 148 149 /** 150 * Create a unique filename for a given server url and 151 * handle. This implementation does not assume anything about the 152 * format of the handle. The filename that is returned will 153 * contain the domain name from the server URL for ease of human 154 * inspection of the data directory. 155 * 156 * @param string $server_url 157 * @param string $handle 158 * @return string $filename 159 */ 160 function getAssociationFilename($server_url, $handle) 161 { 162 if (!$this->active) { 163 trigger_error("FileStore no longer active", E_USER_ERROR); 164 return null; 165 } 166 167 if (strpos($server_url, '://') === false) { 168 trigger_error(sprintf("Bad server URL: %s", $server_url), 169 E_USER_WARNING); 170 return null; 171 } 172 173 list($proto, $rest) = explode('://', $server_url, 2); 174 $parts = explode('/', $rest); 175 $domain = Auth_OpenID_FileStore::_filenameEscape($parts[0]); 176 $url_hash = Auth_OpenID_FileStore::_safe64($server_url); 177 if ($handle) { 178 $handle_hash = Auth_OpenID_FileStore::_safe64($handle); 179 } else { 180 $handle_hash = ''; 181 } 182 183 $filename = sprintf('%s-%s-%s-%s', $proto, $domain, $url_hash, 184 $handle_hash); 185 186 return $this->association_dir. DIRECTORY_SEPARATOR . $filename; 187 } 188 189 /** 190 * Store an association in the association directory. 191 * 192 * @param string $server_url 193 * @param Auth_OpenID_Association $association 194 * @return bool 195 */ 196 function storeAssociation($server_url, $association) 197 { 198 if (!$this->active) { 199 trigger_error("FileStore no longer active", E_USER_ERROR); 200 return false; 201 } 202 203 $association_s = $association->serialize(); 204 $filename = $this->getAssociationFilename($server_url, 205 $association->handle); 206 list($tmp_file, $tmp) = $this->_mktemp(); 207 208 if (!$tmp_file) { 209 trigger_error("_mktemp didn't return a valid file descriptor", 210 E_USER_WARNING); 211 return false; 212 } 213 214 fwrite($tmp_file, $association_s); 215 216 fflush($tmp_file); 217 218 fclose($tmp_file); 219 220 if (@rename($tmp, $filename)) { 221 return true; 222 } else { 223 // In case we are running on Windows, try unlinking the 224 // file in case it exists. 225 @unlink($filename); 226 227 // Now the target should not exist. Try renaming again, 228 // giving up if it fails. 229 if (@rename($tmp, $filename)) { 230 return true; 231 } 232 } 233 234 // If there was an error, don't leave the temporary file 235 // around. 236 Auth_OpenID_FileStore::_removeIfPresent($tmp); 237 return false; 238 } 239 240 /** 241 * Retrieve an association. If no handle is specified, return the 242 * association with the most recent issue time. 243 * 244 * @param string $server_url 245 * @param string|null $handle 246 * @return Auth_OpenID_Association|mixed|null 247 */ 248 function getAssociation($server_url, $handle = null) 249 { 250 if (!$this->active) { 251 trigger_error("FileStore no longer active", E_USER_ERROR); 252 return null; 253 } 254 255 if ($handle === null) { 256 $handle = ''; 257 } 258 259 // The filename with the empty handle is a prefix of all other 260 // associations for the given server URL. 261 $filename = $this->getAssociationFilename($server_url, $handle); 262 263 if ($handle) { 264 return $this->_getAssociation($filename); 265 } else { 266 $association_files = 267 Auth_OpenID_FileStore::_listdir($this->association_dir); 268 $matching_files = []; 269 270 // strip off the path to do the comparison 271 $name = basename($filename); 272 foreach ($association_files as $association_file) { 273 $base = basename($association_file); 274 if (strpos($base, $name) === 0) { 275 $matching_files[] = $association_file; 276 } 277 } 278 279 $matching_associations = []; 280 // read the matching files and sort by time issued 281 foreach ($matching_files as $full_name) { 282 $association = $this->_getAssociation($full_name); 283 if ($association !== null) { 284 $matching_associations[] = [ 285 $association->issued, 286 $association 287 ]; 288 } 289 } 290 291 $issued = []; 292 $assocs = []; 293 foreach ($matching_associations as $key => $assoc) { 294 $issued[$key] = $assoc[0]; 295 $assocs[$key] = $assoc[1]; 296 } 297 298 array_multisort($issued, SORT_DESC, $assocs, SORT_DESC, 299 $matching_associations); 300 301 // return the most recently issued one. 302 if ($matching_associations) { 303 list(, $assoc) = $matching_associations[0]; 304 return $assoc; 305 } else { 306 return null; 307 } 308 } 309 } 310 311 /** 312 * @access private 313 * @param string $filename 314 * @return Auth_OpenID_Association|null 315 */ 316 function _getAssociation($filename) 317 { 318 if (!$this->active) { 319 trigger_error("FileStore no longer active", E_USER_ERROR); 320 return null; 321 } 322 323 if (file_exists($filename) !== true) { 324 return null; 325 } 326 327 $assoc_file = @fopen($filename, 'rb'); 328 329 if ($assoc_file === false) { 330 return null; 331 } 332 333 $filesize = filesize($filename); 334 if ($filesize === false || $filesize <= 0) { 335 return null; 336 } 337 338 $assoc_s = fread($assoc_file, $filesize); 339 fclose($assoc_file); 340 341 if (!$assoc_s) { 342 return null; 343 } 344 345 $association = 346 Auth_OpenID_Association::deserialize('Auth_OpenID_Association', 347 $assoc_s); 348 349 if (!$association) { 350 Auth_OpenID_FileStore::_removeIfPresent($filename); 351 return null; 352 } 353 354 if ($association->getExpiresIn() == 0) { 355 Auth_OpenID_FileStore::_removeIfPresent($filename); 356 return null; 357 } else { 358 return $association; 359 } 360 } 361 362 /** 363 * Remove an association if it exists. Do nothing if it does not. 364 * 365 * @param string $server_url 366 * @param string $handle 367 * @return bool $success 368 */ 369 function removeAssociation($server_url, $handle) 370 { 371 if (!$this->active) { 372 trigger_error("FileStore no longer active", E_USER_ERROR); 373 return null; 374 } 375 376 $assoc = $this->getAssociation($server_url, $handle); 377 if ($assoc === null) { 378 return false; 379 } else { 380 $filename = $this->getAssociationFilename($server_url, $handle); 381 return Auth_OpenID_FileStore::_removeIfPresent($filename); 382 } 383 } 384 385 /** 386 * Return whether this nonce is present. As a side effect, mark it 387 * as no longer present. 388 * 389 * @param string $server_url 390 * @param int $timestamp 391 * @param string $salt 392 * @return bool $present 393 */ 394 function useNonce($server_url, $timestamp, $salt) 395 { 396 global $Auth_OpenID_SKEW; 397 398 if (!$this->active) { 399 trigger_error("FileStore no longer active", E_USER_ERROR); 400 return null; 401 } 402 403 if ( abs($timestamp - time()) > $Auth_OpenID_SKEW ) { 404 return false; 405 } 406 407 if ($server_url) { 408 list($proto, $rest) = explode('://', $server_url, 2); 409 } else { 410 $proto = ''; 411 $rest = ''; 412 } 413 414 $parts = explode('/', $rest, 2); 415 $domain = $this->_filenameEscape($parts[0]); 416 $url_hash = $this->_safe64($server_url); 417 $salt_hash = $this->_safe64($salt); 418 419 $filename = sprintf('%08x-%s-%s-%s-%s', $timestamp, $proto, 420 $domain, $url_hash, $salt_hash); 421 $filename = $this->nonce_dir . DIRECTORY_SEPARATOR . $filename; 422 423 $result = @fopen($filename, 'x'); 424 425 if ($result === false) { 426 return false; 427 } else { 428 fclose($result); 429 return true; 430 } 431 } 432 433 /** 434 * Remove expired entries from the database. This is potentially 435 * expensive, so only run when it is acceptable to take time. 436 * 437 * @access private 438 */ 439 function _allAssocs() 440 { 441 $all_associations = []; 442 443 $association_filenames = 444 Auth_OpenID_FileStore::_listdir($this->association_dir); 445 446 foreach ($association_filenames as $association_filename) { 447 $association_file = fopen($association_filename, 'rb'); 448 449 if ($association_file !== false) { 450 $assoc_s = fread($association_file, 451 filesize($association_filename)); 452 fclose($association_file); 453 454 // Remove expired or corrupted associations 455 $association = 456 Auth_OpenID_Association::deserialize( 457 'Auth_OpenID_Association', $assoc_s); 458 459 if ($association === null) { 460 Auth_OpenID_FileStore::_removeIfPresent( 461 $association_filename); 462 } else { 463 if ($association->getExpiresIn() == 0) { 464 $all_associations[] = [ 465 $association_filename, 466 $association, 467 ]; 468 } 469 } 470 } 471 } 472 473 return $all_associations; 474 } 475 476 function clean() 477 { 478 if (!$this->active) { 479 trigger_error("FileStore no longer active", E_USER_ERROR); 480 return null; 481 } 482 483 $nonces = Auth_OpenID_FileStore::_listdir($this->nonce_dir); 484 $now = time(); 485 486 // Check all nonces for expiry 487 foreach ($nonces as $nonce) { 488 if (!Auth_OpenID_checkTimestamp($nonce, $now)) { 489 $filename = $this->nonce_dir . DIRECTORY_SEPARATOR . $nonce; 490 Auth_OpenID_FileStore::_removeIfPresent($filename); 491 } 492 } 493 494 foreach ($this->_allAssocs() as $pair) { 495 list($assoc_filename, $assoc) = $pair; 496 /** @var Auth_OpenID_Association $assoc */ 497 if ($assoc->getExpiresIn() == 0) { 498 Auth_OpenID_FileStore::_removeIfPresent($assoc_filename); 499 } 500 } 501 } 502 503 /** 504 * @access private 505 * @param string $dir 506 * @return bool 507 */ 508 function _rmtree($dir) 509 { 510 if ($dir[strlen($dir) - 1] != DIRECTORY_SEPARATOR) { 511 $dir .= DIRECTORY_SEPARATOR; 512 } 513 514 if ($handle = opendir($dir)) { 515 while (false !== ($item = readdir($handle))) { 516 if (!in_array($item, ['.', '..'])) { 517 if (is_dir($dir . $item)) { 518 519 if (!Auth_OpenID_FileStore::_rmtree($dir . $item)) { 520 return false; 521 } 522 } else if (is_file($dir . $item)) { 523 if (!unlink($dir . $item)) { 524 return false; 525 } 526 } 527 } 528 } 529 530 closedir($handle); 531 532 if (!@rmdir($dir)) { 533 return false; 534 } 535 536 return true; 537 } else { 538 // Couldn't open directory. 539 return false; 540 } 541 } 542 543 /** 544 * @access private 545 * @param string $dir 546 * @return bool|string 547 */ 548 function _mkstemp($dir) 549 { 550 foreach (range(0, 4) as $i) { 551 $name = tempnam($dir, "php_openid_filestore_"); 552 553 if ($name !== false) { 554 return $name; 555 } 556 } 557 return false; 558 } 559 560 /** 561 * @access private 562 * @param string $dir 563 * @return bool|string 564 */ 565 static function _mkdtemp($dir) 566 { 567 foreach (range(0, 4) as $i) { 568 $name = $dir . strval(DIRECTORY_SEPARATOR) . strval(getmypid()) . 569 "-" . strval(rand(1, time())); 570 if (!mkdir($name, 0700)) { 571 return false; 572 } else { 573 return $name; 574 } 575 } 576 return false; 577 } 578 579 /** 580 * @access private 581 * @param string $dir 582 * @return array 583 */ 584 function _listdir($dir) 585 { 586 $handle = opendir($dir); 587 $files = []; 588 while (false !== ($filename = readdir($handle))) { 589 if (!in_array($filename, ['.', '..'])) { 590 $files[] = $dir . DIRECTORY_SEPARATOR . $filename; 591 } 592 } 593 return $files; 594 } 595 596 /** 597 * @access private 598 * @param string $char 599 * @return bool 600 */ 601 function _isFilenameSafe($char) 602 { 603 $_Auth_OpenID_filename_allowed = Auth_OpenID_letters . 604 Auth_OpenID_digits . "."; 605 return (strpos($_Auth_OpenID_filename_allowed, $char) !== false); 606 } 607 608 /** 609 * @access private 610 * @param string $str 611 * @return mixed|string 612 */ 613 function _safe64($str) 614 { 615 $h64 = base64_encode(Auth_OpenID_SHA1($str)); 616 $h64 = str_replace('+', '_', $h64); 617 $h64 = str_replace('/', '.', $h64); 618 $h64 = str_replace('=', '', $h64); 619 return $h64; 620 } 621 622 /** 623 * @access private 624 * @param string $str 625 * @return string 626 */ 627 function _filenameEscape($str) 628 { 629 $filename = ""; 630 $b = Auth_OpenID::toBytes($str); 631 632 for ($i = 0; $i < count($b); $i++) { 633 $c = $b[$i]; 634 if (Auth_OpenID_FileStore::_isFilenameSafe($c)) { 635 $filename .= $c; 636 } else { 637 $filename .= sprintf("_%02X", ord($c)); 638 } 639 } 640 return $filename; 641 } 642 643 /** 644 * Attempt to remove a file, returning whether the file existed at 645 * the time of the call. 646 * 647 * @access private 648 * @param string $filename 649 * @return bool $result True if the file was present, false if not. 650 */ 651 function _removeIfPresent($filename) 652 { 653 return @unlink($filename); 654 } 655 656 function cleanupAssociations() 657 { 658 $removed = 0; 659 foreach ($this->_allAssocs() as $pair) { 660 list($assoc_filename, $assoc) = $pair; 661 /** @var Auth_OpenID_Association $assoc */ 662 if ($assoc->getExpiresIn() == 0) { 663 $this->_removeIfPresent($assoc_filename); 664 $removed += 1; 665 } 666 } 667 return $removed; 668 } 669} 670 671 672