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