final move of files
[web.mtrack] / Auth / OpenID / SQLStore.php
1 <?php
2
3 /**
4  * SQL-backed OpenID stores.
5  *
6  * PHP versions 4 and 5
7  *
8  * LICENSE: See the COPYING file included in this distribution.
9  *
10  * @package OpenID
11  * @author JanRain, Inc. <openid@janrain.com>
12  * @copyright 2005-2008 Janrain, Inc.
13  * @license http://www.apache.org/licenses/LICENSE-2.0 Apache
14  */
15
16 /**
17  * Require the PEAR DB module because we'll need it for the SQL-based
18  * stores implemented here.  We silence any errors from the inclusion
19  * because it might not be present, and a user of the SQL stores may
20  * supply an Auth_OpenID_DatabaseConnection instance that implements
21  * its own storage.
22  */
23 global $__Auth_OpenID_PEAR_AVAILABLE;
24 $__Auth_OpenID_PEAR_AVAILABLE = @include_once 'DB.php';
25
26 /**
27  * @access private
28  */
29 require_once 'Auth/OpenID/Interface.php';
30 require_once 'Auth/OpenID/Nonce.php';
31
32 /**
33  * @access private
34  */
35 require_once 'Auth/OpenID.php';
36
37 /**
38  * @access private
39  */
40 require_once 'Auth/OpenID/Nonce.php';
41
42 /**
43  * This is the parent class for the SQL stores, which contains the
44  * logic common to all of the SQL stores.
45  *
46  * The table names used are determined by the class variables
47  * associations_table_name and nonces_table_name.  To change the name
48  * of the tables used, pass new table names into the constructor.
49  *
50  * To create the tables with the proper schema, see the createTables
51  * method.
52  *
53  * This class shouldn't be used directly.  Use one of its subclasses
54  * instead, as those contain the code necessary to use a specific
55  * database.  If you're an OpenID integrator and you'd like to create
56  * an SQL-driven store that wraps an application's database
57  * abstraction, be sure to create a subclass of
58  * {@link Auth_OpenID_DatabaseConnection} that calls the application's
59  * database abstraction calls.  Then, pass an instance of your new
60  * database connection class to your SQLStore subclass constructor.
61  *
62  * All methods other than the constructor and createTables should be
63  * considered implementation details.
64  *
65  * @package OpenID
66  */
67 class Auth_OpenID_SQLStore extends Auth_OpenID_OpenIDStore {
68
69     /**
70      * This creates a new SQLStore instance.  It requires an
71      * established database connection be given to it, and it allows
72      * overriding the default table names.
73      *
74      * @param connection $connection This must be an established
75      * connection to a database of the correct type for the SQLStore
76      * subclass you're using.  This must either be an PEAR DB
77      * connection handle or an instance of a subclass of
78      * Auth_OpenID_DatabaseConnection.
79      *
80      * @param associations_table: This is an optional parameter to
81      * specify the name of the table used for storing associations.
82      * The default value is 'oid_associations'.
83      *
84      * @param nonces_table: This is an optional parameter to specify
85      * the name of the table used for storing nonces.  The default
86      * value is 'oid_nonces'.
87      */
88     function Auth_OpenID_SQLStore($connection,
89                                   $associations_table = null,
90                                   $nonces_table = null)
91     {
92         global $__Auth_OpenID_PEAR_AVAILABLE;
93
94         $this->associations_table_name = "oid_associations";
95         $this->nonces_table_name = "oid_nonces";
96
97         // Check the connection object type to be sure it's a PEAR
98         // database connection.
99         if (!(is_object($connection) &&
100               (is_subclass_of($connection, 'db_common') ||
101                is_subclass_of($connection,
102                               'auth_openid_databaseconnection')))) {
103             trigger_error("Auth_OpenID_SQLStore expected PEAR connection " .
104                           "object (got ".get_class($connection).")",
105                           E_USER_ERROR);
106             return;
107         }
108
109         $this->connection = $connection;
110
111         // Be sure to set the fetch mode so the results are keyed on
112         // column name instead of column index.  This is a PEAR
113         // constant, so only try to use it if PEAR is present.  Note
114         // that Auth_Openid_Databaseconnection instances need not
115         // implement ::setFetchMode for this reason.
116         if ($__Auth_OpenID_PEAR_AVAILABLE) {
117             $this->connection->setFetchMode(DB_FETCHMODE_ASSOC);
118         }
119
120         if ($associations_table) {
121             $this->associations_table_name = $associations_table;
122         }
123
124         if ($nonces_table) {
125             $this->nonces_table_name = $nonces_table;
126         }
127
128         $this->max_nonce_age = 6 * 60 * 60;
129
130         // Be sure to run the database queries with auto-commit mode
131         // turned OFF, because we want every function to run in a
132         // transaction, implicitly.  As a rule, methods named with a
133         // leading underscore will NOT control transaction behavior.
134         // Callers of these methods will worry about transactions.
135         $this->connection->autoCommit(false);
136
137         // Create an empty SQL strings array.
138         $this->sql = array();
139
140         // Call this method (which should be overridden by subclasses)
141         // to populate the $this->sql array with SQL strings.
142         $this->setSQL();
143
144         // Verify that all required SQL statements have been set, and
145         // raise an error if any expected SQL strings were either
146         // absent or empty.
147         list($missing, $empty) = $this->_verifySQL();
148
149         if ($missing) {
150             trigger_error("Expected keys in SQL query list: " .
151                           implode(", ", $missing),
152                           E_USER_ERROR);
153             return;
154         }
155
156         if ($empty) {
157             trigger_error("SQL list keys have no SQL strings: " .
158                           implode(", ", $empty),
159                           E_USER_ERROR);
160             return;
161         }
162
163         // Add table names to queries.
164         $this->_fixSQL();
165     }
166
167     function tableExists($table_name)
168     {
169         return !$this->isError(
170                       $this->connection->query(
171                           sprintf("SELECT * FROM %s LIMIT 0",
172                                   $table_name)));
173     }
174
175     /**
176      * Returns true if $value constitutes a database error; returns
177      * false otherwise.
178      */
179     function isError($value)
180     {
181         return PEAR::isError($value);
182     }
183
184     /**
185      * Converts a query result to a boolean.  If the result is a
186      * database error according to $this->isError(), this returns
187      * false; otherwise, this returns true.
188      */
189     function resultToBool($obj)
190     {
191         if ($this->isError($obj)) {
192             return false;
193         } else {
194             return true;
195         }
196     }
197
198     /**
199      * This method should be overridden by subclasses.  This method is
200      * called by the constructor to set values in $this->sql, which is
201      * an array keyed on sql name.
202      */
203     function setSQL()
204     {
205     }
206
207     /**
208      * Resets the store by removing all records from the store's
209      * tables.
210      */
211     function reset()
212     {
213         $this->connection->query(sprintf("DELETE FROM %s",
214                                          $this->associations_table_name));
215
216         $this->connection->query(sprintf("DELETE FROM %s",
217                                          $this->nonces_table_name));
218     }
219
220     /**
221      * @access private
222      */
223     function _verifySQL()
224     {
225         $missing = array();
226         $empty = array();
227
228         $required_sql_keys = array(
229                                    'nonce_table',
230                                    'assoc_table',
231                                    'set_assoc',
232                                    'get_assoc',
233                                    'get_assocs',
234                                    'remove_assoc'
235                                    );
236
237         foreach ($required_sql_keys as $key) {
238             if (!array_key_exists($key, $this->sql)) {
239                 $missing[] = $key;
240             } else if (!$this->sql[$key]) {
241                 $empty[] = $key;
242             }
243         }
244
245         return array($missing, $empty);
246     }
247
248     /**
249      * @access private
250      */
251     function _fixSQL()
252     {
253         $replacements = array(
254                               array(
255                                     'value' => $this->nonces_table_name,
256                                     'keys' => array('nonce_table',
257                                                     'add_nonce',
258                                                     'clean_nonce')
259                                     ),
260                               array(
261                                     'value' => $this->associations_table_name,
262                                     'keys' => array('assoc_table',
263                                                     'set_assoc',
264                                                     'get_assoc',
265                                                     'get_assocs',
266                                                     'remove_assoc',
267                                                     'clean_assoc')
268                                     )
269                               );
270
271         foreach ($replacements as $item) {
272             $value = $item['value'];
273             $keys = $item['keys'];
274
275             foreach ($keys as $k) {
276                 if (is_array($this->sql[$k])) {
277                     foreach ($this->sql[$k] as $part_key => $part_value) {
278                         $this->sql[$k][$part_key] = sprintf($part_value,
279                                                             $value);
280                     }
281                 } else {
282                     $this->sql[$k] = sprintf($this->sql[$k], $value);
283                 }
284             }
285         }
286     }
287
288     function blobDecode($blob)
289     {
290         return $blob;
291     }
292
293     function blobEncode($str)
294     {
295         return $str;
296     }
297
298     function createTables()
299     {
300         $this->connection->autoCommit(true);
301         $n = $this->create_nonce_table();
302         $a = $this->create_assoc_table();
303         $this->connection->autoCommit(false);
304
305         if ($n && $a) {
306             return true;
307         } else {
308             return false;
309         }
310     }
311
312     function create_nonce_table()
313     {
314         if (!$this->tableExists($this->nonces_table_name)) {
315             $r = $this->connection->query($this->sql['nonce_table']);
316             return $this->resultToBool($r);
317         }
318         return true;
319     }
320
321     function create_assoc_table()
322     {
323         if (!$this->tableExists($this->associations_table_name)) {
324             $r = $this->connection->query($this->sql['assoc_table']);
325             return $this->resultToBool($r);
326         }
327         return true;
328     }
329
330     /**
331      * @access private
332      */
333     function _set_assoc($server_url, $handle, $secret, $issued,
334                         $lifetime, $assoc_type)
335     {
336         return $this->connection->query($this->sql['set_assoc'],
337                                         array(
338                                               $server_url,
339                                               $handle,
340                                               $secret,
341                                               $issued,
342                                               $lifetime,
343                                               $assoc_type));
344     }
345
346     function storeAssociation($server_url, $association)
347     {
348         if ($this->resultToBool($this->_set_assoc(
349                                             $server_url,
350                                             $association->handle,
351                                             $this->blobEncode(
352                                                   $association->secret),
353                                             $association->issued,
354                                             $association->lifetime,
355                                             $association->assoc_type
356                                             ))) {
357             $this->connection->commit();
358         } else {
359             $this->connection->rollback();
360         }
361     }
362
363     /**
364      * @access private
365      */
366     function _get_assoc($server_url, $handle)
367     {
368         $result = $this->connection->getRow($this->sql['get_assoc'],
369                                             array($server_url, $handle));
370         if ($this->isError($result)) {
371             return null;
372         } else {
373             return $result;
374         }
375     }
376
377     /**
378      * @access private
379      */
380     function _get_assocs($server_url)
381     {
382         $result = $this->connection->getAll($this->sql['get_assocs'],
383                                             array($server_url));
384
385         if ($this->isError($result)) {
386             return array();
387         } else {
388             return $result;
389         }
390     }
391
392     function removeAssociation($server_url, $handle)
393     {
394         if ($this->_get_assoc($server_url, $handle) == null) {
395             return false;
396         }
397
398         if ($this->resultToBool($this->connection->query(
399                               $this->sql['remove_assoc'],
400                               array($server_url, $handle)))) {
401             $this->connection->commit();
402         } else {
403             $this->connection->rollback();
404         }
405
406         return true;
407     }
408
409     function getAssociation($server_url, $handle = null)
410     {
411         if ($handle !== null) {
412             $assoc = $this->_get_assoc($server_url, $handle);
413
414             $assocs = array();
415             if ($assoc) {
416                 $assocs[] = $assoc;
417             }
418         } else {
419             $assocs = $this->_get_assocs($server_url);
420         }
421
422         if (!$assocs || (count($assocs) == 0)) {
423             return null;
424         } else {
425             $associations = array();
426
427             foreach ($assocs as $assoc_row) {
428                 $assoc = new Auth_OpenID_Association($assoc_row['handle'],
429                                                      $assoc_row['secret'],
430                                                      $assoc_row['issued'],
431                                                      $assoc_row['lifetime'],
432                                                      $assoc_row['assoc_type']);
433
434                 $assoc->secret = $this->blobDecode($assoc->secret);
435
436                 if ($assoc->getExpiresIn() == 0) {
437                     $this->removeAssociation($server_url, $assoc->handle);
438                 } else {
439                     $associations[] = array($assoc->issued, $assoc);
440                 }
441             }
442
443             if ($associations) {
444                 $issued = array();
445                 $assocs = array();
446                 foreach ($associations as $key => $assoc) {
447                     $issued[$key] = $assoc[0];
448                     $assocs[$key] = $assoc[1];
449                 }
450
451                 array_multisort($issued, SORT_DESC, $assocs, SORT_DESC,
452                                 $associations);
453
454                 // return the most recently issued one.
455                 list($issued, $assoc) = $associations[0];
456                 return $assoc;
457             } else {
458                 return null;
459             }
460         }
461     }
462
463     /**
464      * @access private
465      */
466     function _add_nonce($server_url, $timestamp, $salt)
467     {
468         $sql = $this->sql['add_nonce'];
469         $result = $this->connection->query($sql, array($server_url,
470                                                        $timestamp,
471                                                        $salt));
472         if ($this->isError($result)) {
473             $this->connection->rollback();
474         } else {
475             $this->connection->commit();
476         }
477         return $this->resultToBool($result);
478     }
479
480     function useNonce($server_url, $timestamp, $salt)
481     {
482         global $Auth_OpenID_SKEW;
483
484         if ( abs($timestamp - time()) > $Auth_OpenID_SKEW ) {
485             return False;
486         }
487
488         return $this->_add_nonce($server_url, $timestamp, $salt);
489     }
490
491     /**
492      * "Octifies" a binary string by returning a string with escaped
493      * octal bytes.  This is used for preparing binary data for
494      * PostgreSQL BYTEA fields.
495      *
496      * @access private
497      */
498     function _octify($str)
499     {
500         $result = "";
501         for ($i = 0; $i < Auth_OpenID::bytes($str); $i++) {
502             $ch = substr($str, $i, 1);
503             if ($ch == "\\") {
504                 $result .= "\\\\\\\\";
505             } else if (ord($ch) == 0) {
506                 $result .= "\\\\000";
507             } else {
508                 $result .= "\\" . strval(decoct(ord($ch)));
509             }
510         }
511         return $result;
512     }
513
514     /**
515      * "Unoctifies" octal-escaped data from PostgreSQL and returns the
516      * resulting ASCII (possibly binary) string.
517      *
518      * @access private
519      */
520     function _unoctify($str)
521     {
522         $result = "";
523         $i = 0;
524         while ($i < strlen($str)) {
525             $char = $str[$i];
526             if ($char == "\\") {
527                 // Look to see if the next char is a backslash and
528                 // append it.
529                 if ($str[$i + 1] != "\\") {
530                     $octal_digits = substr($str, $i + 1, 3);
531                     $dec = octdec($octal_digits);
532                     $char = chr($dec);
533                     $i += 4;
534                 } else {
535                     $char = "\\";
536                     $i += 2;
537                 }
538             } else {
539                 $i += 1;
540             }
541
542             $result .= $char;
543         }
544
545         return $result;
546     }
547
548     function cleanupNonces()
549     {
550         global $Auth_OpenID_SKEW;
551         $v = time() - $Auth_OpenID_SKEW;
552
553         $this->connection->query($this->sql['clean_nonce'], array($v));
554         $num = $this->connection->affectedRows();
555         $this->connection->commit();
556         return $num;
557     }
558
559     function cleanupAssociations()
560     {
561         $this->connection->query($this->sql['clean_assoc'],
562                                  array(time()));
563         $num = $this->connection->affectedRows();
564         $this->connection->commit();
565         return $num;
566     }
567 }
568
569 ?>