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