source: trunk/lib/Auth_SQL.inc.php @ 501

Last change on this file since 501 was 501, checked in by anonymous, 10 years ago

Optimizing auth and csrf token.

File size: 48.3 KB
Line 
1<?php
2/**
3 * The Strangecode Codebase - a general application development framework for PHP
4 * For details visit the project site: <http://trac.strangecode.com/codebase/>
5 * Copyright 2001-2012 Strangecode, LLC
6 *
7 * This file is part of The Strangecode Codebase.
8 *
9 * The Strangecode Codebase is free software: you can redistribute it and/or
10 * modify it under the terms of the GNU General Public License as published by the
11 * Free Software Foundation, either version 3 of the License, or (at your option)
12 * any later version.
13 *
14 * The Strangecode Codebase is distributed in the hope that it will be useful, but
15 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17 * details.
18 *
19 * You should have received a copy of the GNU General Public License along with
20 * The Strangecode Codebase. If not, see <http://www.gnu.org/licenses/>.
21 */
22
23/*
24* The Auth_SQL class provides a SQL implementation for authentication.
25*
26* @author  Quinn Comendant <quinn@strangecode.com>
27* @version 2.1
28*/
29
30require_once dirname(__FILE__) . '/Email.inc.php';
31
32class Auth_SQL {
33
34    // Available hash types for class Auth_SQL.
35    const ENCRYPT_PLAINTEXT = 1;
36    const ENCRYPT_CRYPT = 2;
37    const ENCRYPT_SHA1 = 3;
38    const ENCRYPT_SHA1_HARDENED = 4;
39    const ENCRYPT_MD5 = 5;
40    const ENCRYPT_MD5_HARDENED = 6;
41    const ENCRYPT_PASSWORD_BCRYPT = 7;
42    const ENCRYPT_PASSWORD_DEFAULT = 8;
43
44    // Namespace of this auth object.
45    protected $_ns;
46
47    // Static var for test.
48    protected $_authentication_tested;
49
50    // Parameters to be configured by setParam.
51    protected $_params = array();
52    protected $_default_params = array(
53
54        // Automatically create table and verify columns. Better set to false after site launch.
55        // This value is overwritten by the $app->getParam('db_create_tables') setting if it is available.
56        'create_table' => true,
57
58        // The database table containing users to authenticate.
59        'db_table' => 'user_tbl',
60
61        // The name of the primary key for the db_table.
62        'db_primary_key' => 'user_id',
63
64        // The name of the username key for the db_table.
65        'db_username_column' => 'username',
66
67        // If using the db_login_table feature, specify the db_login_table. The primary key must match the primary key for the db_table.
68        'db_login_table' => 'user_login_tbl',
69
70        // The type of hash to use for passwords stored in the db_table. Use one of the Auth_SQL::ENCRYPT_* types specified above.
71        // Hardened password hashes rely on the same key/salt being used to compare hashs.
72        // Be aware that when using one of the hardened types the App signing_key or $more_salt below cannot change!
73        'hash_type' => self::ENCRYPT_MD5,
74        'encryption_type' => null, // Backwards misnomer compatibility.
75
76        // Automatically update stored user hashes when the user next authenticates if the hash type changes (requires user_tbl with populated userpass_hashtype column).
77        'hash_type_autoupdate' => true,
78
79        // The URL to the login script.
80        'login_url' => '/',
81
82        // The maximum amount of time a user is allowed to be logged in. They will be forced to login again if they expire.
83        // In seconds. 21600 seconds = 6 hours.
84        'login_timeout' => 21600,
85
86        // The maximum amount of time a user is allowed to be idle before their session expires. They will be forced to login again if they expire.
87        // In seconds. 3600 seconds = 1 hour.
88        'idle_timeout' => 3600,
89
90        // The period of time to compare login abuse attempts. If a threshold of logins is reached in this amount of time the account is blocked.
91        // Days and hours, like this: 'DD:HH'
92        'login_abuse_timeframe' => '04:00',
93
94        // The number of warnings a user will receive (and their password reset each time) before their account is completely blocked.
95        'login_abuse_warnings' => 3,
96
97        // The maximum number of IP addresses a user can login with over the timeout period before their account is blocked.
98        'login_abuse_max_ips' => 5,
99
100        // The IP address subnet size threshold. Uses a CIDR notation network mask (see CIDR cheat-sheet at bottom).
101        // Any integer between 0 and 32 is permitted. Setting this to '24' permits any address in a
102        // class C network (255.255.255.0) to be considered the same. Setting to '32' compares each IP absolutely.
103        // Setting to '0' ignores all IPs, thus disabling login_abuse checking.
104        'login_abuse_ip_bitmask' => 32,
105
106        // Specify usernames to exclude from the account abuse detection system. This is specified as a hardcoded array provided at
107        // class instantiation time, or can be saved in the db_table under the login_abuse_exempt field.
108        'login_abuse_exempt_usernames' => array(),
109
110        // Specify usernames to exclude from remote_ip matching. Users behind proxy servers should be appended to this array so their shifting remote IP will not log them out.
111        'match_remote_ip_exempt_usernames' => array(),
112
113        // Match the user's current remote IP against the one they logged in with.
114        'match_remote_ip' => true,
115
116        // An array of IP blocks that are bypass the remote_ip comparison check. Useful for dynamic IPs or those behind proxy servers.
117        'trusted_networks' => array(),
118
119        // Allow user accounts to be blocked? Requires the user table to have the columns 'blocked' and 'blocked_reason'
120        'blocking' => false,
121
122        // Use a db_login_table to detect excessive logins. This requires blocking to be enabled.
123        'abuse_detection' => false,
124
125        // Allow users to save login form passwords in their browser? Setting to 'true' may pose a potential security risk.
126        'login_form_allow_autocomplete' => false,
127    );
128
129    /**
130     * Constructs a new authentication object.
131     *
132     * @access public
133     * @param optional array $params  A hash containing parameters.
134     */
135    public function __construct($namespace='')
136    {
137        $app =& App::getInstance();
138
139        $this->_ns = $namespace;
140
141        // Initialize default parameters.
142        $this->setParam($this->_default_params);
143
144        // Get create tables config from global context.
145        if (!is_null($app->getParam('db_create_tables'))) {
146            $this->setParam(array('create_table' => $app->getParam('db_create_tables')));
147        }
148
149        if (!isset($_SESSION['_auth_sql'][$this->_ns])) {
150            $this->clear();
151        }
152    }
153
154    /**
155     * Setup the database tables for this class.
156     *
157     * @access  public
158     * @author  Quinn Comendant <quinn@strangecode.com>
159     * @since   26 Aug 2005 17:09:36
160     */
161    public function initDB($recreate_db=false)
162    {
163        $app =& App::getInstance();
164        $db =& DB::getInstance();
165
166
167        static $_db_tested = false;
168
169        if ($recreate_db || !$_db_tested && $this->getParam('create_table')) {
170
171            // User table.
172            if ($recreate_db) {
173                $db->query("DROP TABLE IF EXISTS " . $this->getParam('db_table'));
174                $app->logMsg(sprintf('Dropping and recreating table %s.', $this->getParam('db_table')), LOG_INFO, __FILE__, __LINE__);
175            }
176
177            // The minimal columns for a table compatable with the Auth_SQL class.
178            $db->query("CREATE TABLE IF NOT EXISTS " . $db->escapeString($this->getParam('db_table')) . " (
179                " . $this->getParam('db_primary_key') . " MEDIUMINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
180                " . $this->getParam('db_username_column') . " varchar(255) NOT NULL default '',
181                userpass VARCHAR(255) NOT NULL DEFAULT '',
182                userpass_hashtype TINYINT UNSIGNED NOT NULL DEFAULT '0',
183                first_name VARCHAR(255) NOT NULL DEFAULT '',
184                last_name VARCHAR(255) NOT NULL DEFAULT '',
185                email VARCHAR(255) NOT NULL DEFAULT '',
186                login_abuse_exempt ENUM('TRUE') DEFAULT NULL,
187                blocked ENUM('TRUE') DEFAULT NULL,
188                blocked_reason VARCHAR(255) NOT NULL DEFAULT '',
189                abuse_warning_level TINYINT(4) NOT NULL DEFAULT '0',
190                seconds_online INT(11) NOT NULL DEFAULT '0',
191                last_login_datetime DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
192                last_access_datetime DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
193                last_login_ip VARCHAR(255) NOT NULL DEFAULT '0.0.0.0',
194                added_by_user_id SMALLINT(11) DEFAULT NULL,
195                modified_by_user_id SMALLINT(11) DEFAULT NULL,
196                added_datetime DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
197                modified_datetime DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
198                KEY " . $this->getParam('db_username_column') . " (" . $this->getParam('db_username_column') . "),
199                KEY userpass (userpass),
200                KEY email (email)
201            )");
202
203            if (!$db->columnExists($this->getParam('db_table'), array(
204                $this->getParam('db_primary_key'),
205                $this->getParam('db_username_column'),
206                'userpass',
207                'first_name',
208                'last_name',
209                'email',
210                'login_abuse_exempt',
211                'blocked',
212                'blocked_reason',
213                'abuse_warning_level',
214                'seconds_online',
215                'last_login_datetime',
216                'last_access_datetime',
217                'last_login_ip',
218                'added_by_user_id',
219                'modified_by_user_id',
220                'added_datetime',
221                'modified_datetime',
222            ), false, false)) {
223                $app->logMsg(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), LOG_ALERT, __FILE__, __LINE__);
224                trigger_error(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), E_USER_ERROR);
225            }
226
227            // Login table is used for abuse_detection features.
228            if ($this->getParam('abuse_detection')) {
229                if ($recreate_db) {
230                    $db->query("DROP TABLE IF EXISTS " . $this->getParam('db_login_table'));
231                    $app->logMsg(sprintf('Dropping and recreating table %s.', $this->getParam('db_login_table')), LOG_INFO, __FILE__, __LINE__);
232                }
233                $db->query("CREATE TABLE IF NOT EXISTS " . $this->getParam('db_login_table') . " (
234                    " . $this->getParam('db_primary_key') . " MEDIUMINT UNSIGNED NOT NULL DEFAULT '0',
235                    login_datetime DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
236                    remote_ip_binary CHAR(32) NOT NULL DEFAULT '',
237                    KEY " . $this->getParam('db_primary_key') . " (" . $this->getParam('db_primary_key') . "),
238                    KEY login_datetime (login_datetime),
239                    KEY remote_ip_binary (remote_ip_binary)
240                )");
241
242                if (!$db->columnExists($this->getParam('db_login_table'), array(
243                    $this->getParam('db_primary_key'),
244                    'login_datetime',
245                    'remote_ip_binary',
246                ), false, false)) {
247                    $app->logMsg(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_login_table')), LOG_ALERT, __FILE__, __LINE__);
248                    trigger_error(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_login_table')), E_USER_ERROR);
249                }
250            }
251        }
252        $_db_tested = true;
253    }
254
255    /**
256     * Set the params of an auth object.
257     *
258     * @param  array $params   Array of parameter keys and value to set.
259     * @return bool true on success, false on failure
260     */
261    public function setParam($params)
262    {
263        $app =& App::getInstance();
264
265        if (isset($params['match_remote_ip_exempt_usernames'])) {
266            $params['match_remote_ip_exempt_usernames'] = array_map('strtolower', $params['match_remote_ip_exempt_usernames']);
267        }
268        if (isset($params['login_abuse_exempt_usernames'])) {
269            $params['login_abuse_exempt_usernames'] = array_map('strtolower', $params['login_abuse_exempt_usernames']);
270        }
271        if (isset($params['encryption_type'])) {
272            // Backwards misnomer compatibility.
273            $params['hash_type'] = $params['encryption_type'];
274        }
275        if (isset($params['hash_type']) && version_compare(PHP_VERSION, '5.5.0', '<') && in_array($params['hash_type'], array(self::ENCRYPT_PASSWORD_BCRYPT, self::ENCRYPT_PASSWORD_DEFAULT))) {
276            // These hash types require the password_* userland lib in PHP < 5.5.0
277            $pw_compat_lib = 'vendor/ircmaxell/password-compat/lib/password.php';
278            if (false !== stream_resolve_include_path($pw_compat_lib)) {
279                include_once $pw_compat_lib;
280            } else {
281                $app->logMsg(sprintf('Hash type %s requires password-compat lib in PHP < 5.5.0; falling back to ENCRYPT_SHA1_HARDENED', $params['hash_type']), LOG_ERR, __FILE__, __LINE__);
282                $params['hash_type'] = self::ENCRYPT_SHA1_HARDENED;
283            }
284        }
285        if (isset($params['hash_type']) && !in_array($params['hash_type'], array(self::ENCRYPT_PLAINTEXT, self::ENCRYPT_CRYPT, self::ENCRYPT_SHA1, self::ENCRYPT_SHA1_HARDENED, self::ENCRYPT_MD5, self::ENCRYPT_MD5_HARDENED, self::ENCRYPT_PASSWORD_BCRYPT, self::ENCRYPT_PASSWORD_DEFAULT))) {
286            $app->logMsg(sprintf('Invalid hash type %s; falling back to ENCRYPT_SHA1_HARDENED', $params['hash_type']), LOG_ERR, __FILE__, __LINE__);
287            $params['hash_type'] = self::ENCRYPT_SHA1_HARDENED;
288        }
289        if (isset($params) && is_array($params)) {
290            // Merge new parameters with old overriding only those passed.
291            $this->_params = array_merge($this->_params, $params);
292        }
293    }
294
295    /**
296     * Return the value of a parameter, if it exists.
297     *
298     * @access public
299     * @param string $param        Which parameter to return.
300     * @return mixed               Configured parameter value.
301     */
302    public function getParam($param)
303    {
304        $app =& App::getInstance();
305
306        if (array_key_exists($param, $this->_params)) {
307            return $this->_params[$param];
308        } else {
309            $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__);
310            return null;
311        }
312    }
313
314    /**
315     * Clear any authentication tokens in the current session. A.K.A. logout.
316     *
317     * @access public
318     */
319    public function clear()
320    {
321        $db =& DB::getInstance();
322
323        $this->initDB();
324
325        if ($this->get('user_id', false)) {
326            // FIX ME: Should we check if the session is active?
327            $db->query("
328                UPDATE " . $this->_params['db_table'] . " SET
329                seconds_online = seconds_online + (UNIX_TIMESTAMP() - UNIX_TIMESTAMP(last_access_datetime)),
330                last_login_datetime = '0000-00-00 00:00:00'
331                WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "'
332            ");
333        }
334        $_SESSION['_auth_sql'][$this->_ns] = array(
335            'authenticated'         => false,
336            'user_id'               => null,
337            'username'              => null,
338            'login_datetime'        => null,
339            'last_access_datetime'  => null,
340            'remote_ip'             => getRemoteAddr(),
341            'login_abuse_exempt'    => null,
342            'match_remote_ip_exempt'=> null,
343            'user_data'             => null,
344        );
345    }
346
347    /**
348     * Sets a variable into a registered auth session.
349     *
350     * @access public
351     * @param mixed $key      Which value to set.
352     * @param mixed $val      Value to set variable to.
353     */
354    public function set($key, $val)
355    {
356        if (!isset($_SESSION['_auth_sql'][$this->_ns]['user_data'])) {
357            $_SESSION['_auth_sql'][$this->_ns]['user_data'] = array();
358        }
359        $_SESSION['_auth_sql'][$this->_ns]['user_data'][$key] = $val;
360    }
361
362    /**
363     * Returns a specified value from a registered auth session.
364     *
365     * @access public
366     * @param mixed $key      Which value to return.
367     * @param mixed $default  Value to return if key not found in user_data.
368     * @return mixed          Value stored in session.
369     */
370    public function get($key, $default='')
371    {
372        if (isset($_SESSION['_auth_sql'][$this->_ns][$key])) {
373            return $_SESSION['_auth_sql'][$this->_ns][$key];
374        } else if (isset($_SESSION['_auth_sql'][$this->_ns]['user_data'][$key])) {
375            return $_SESSION['_auth_sql'][$this->_ns]['user_data'][$key];
376        } else {
377            return $default;
378        }
379    }
380
381    /**
382     * Retrieve and verify the given username and password against a matching user record in the database.
383     *
384     * @access private
385     * @param string $username      The username to check.
386     * @param string $password      The password to compare to username.
387     * @return mixed  False if credentials not found in DB, or returns DB row matching credentials.
388     */
389    public function authenticate($username, $password)
390    {
391        $app =& App::getInstance();
392        $db =& DB::getInstance();
393
394        $this->initDB();
395
396        // Get user data for specified username.
397        // Query DB for user matching credentials. Compare cyphertext with salted-encrypted password.
398        $qid = $db->query("
399            SELECT *, " . $this->_params['db_primary_key'] . " AS user_id
400            FROM " . $this->_params['db_table'] . "
401            WHERE " . $this->_params['db_username_column'] . " = '" . $db->escapeString($username) . "'
402        ");
403        if (!$user_data = mysql_fetch_assoc($qid)) {
404            $app->logMsg(sprintf('Username %s not found for authentication', $username), LOG_NOTICE, __FILE__, __LINE__);
405            return false;
406        }
407
408        $old_hash_type = isset($user_data['userpass_hashtype']) && !empty($user_data['userpass_hashtype']) ? $user_data['userpass_hashtype'] : $this->getParam('hash_type');
409        if ($this->verifyPassword($password, $user_data['userpass'], $old_hash_type)) {
410            $app->logMsg(sprintf('Authentication successful for %s (user_id=%s)', $username, $user_data['user_id']), LOG_INFO, __FILE__, __LINE__);
411            unset($user_data['userpass']); // Avoid revealing the encrypted password in the $user_data.
412            if ($this->getParam('hash_type_autoupdate') && $old_hash_type != $this->getParam('hash_type')) {
413                // Let's update user's password hash to new type (just run setPassword with this authenticated password
).
414                $this->setPassword($user_data['user_id'], $password);
415                $app->logMsg(sprintf('User %s password hash type updated from %s to %s', $username, $old_hash_type, $this->getParam('hash_type')), LOG_INFO, __FILE__, __LINE__);
416            }
417            return $user_data;
418        }
419
420        $app->logMsg(sprintf('Authentication failed for %s (user_id=%s)', $username, $user_data['user_id']), LOG_NOTICE, __FILE__, __LINE__);
421        return false;
422    }
423
424    /**
425     * If user authenticated, register login into session.
426     *
427     * @access private
428     * @param string $username     The username to check.
429     * @param string $password     The password to compare to username.
430     * @return boolean  Whether or not the credentials are valid.
431     */
432    public function login($username, $password)
433    {
434        $app =& App::getInstance();
435        $db =& DB::getInstance();
436
437        $this->initDB();
438
439        $this->clear();
440
441        if (!($user_data = $this->authenticate($username, $password))) {
442            // No login: failed authentication!
443            return false;
444        }
445
446        // Convert 'priv' to 'user_type' nomenclature to support older implementations.
447        if (isset($user_data['priv'])) {
448            $user_data['user_type'] = $user_data['priv'];
449        }
450
451        // Register authenticated session.
452        $_SESSION['_auth_sql'][$this->_ns] = array(
453            'authenticated'         => true,
454            'user_id'               => $user_data['user_id'],
455            'username'              => $username,
456            'login_datetime'        => date('Y-m-d H:i:s'),
457            'last_access_datetime'  => date('Y-m-d H:i:s'),
458            'remote_ip'             => getRemoteAddr(),
459            'login_abuse_exempt'    => isset($user_data['login_abuse_exempt']) ? !empty($user_data['login_abuse_exempt']) : in_array(strtolower($username), $this->_params['login_abuse_exempt_usernames']),
460            'match_remote_ip_exempt'=> isset($user_data['match_remote_ip_exempt']) ? !empty($user_data['match_remote_ip_exempt']) : in_array(strtolower($username), $this->_params['match_remote_ip_exempt_usernames']),
461            'user_data'             => $user_data
462        );
463
464        /**
465         * Check if the account is blocked, respond in context to reason. Cancel the login if blocked.
466         */
467        if ($this->getParam('blocking')) {
468            if (!empty($user_data['blocked'])) {
469
470                $app->logMsg(sprintf('User_id %s (%s) login failed due to blocked account: %s', $this->get('user_id'), $this->get('username'), $this->get('blocked_reason')), LOG_NOTICE, __FILE__, __LINE__);
471
472                switch ($user_data['blocked_reason']) {
473                    case 'account abuse' :
474                        $app->raiseMsg(sprintf(_("This account has been blocked due to possible account abuse. Please contact us to reactivate."), null), MSG_WARNING, __FILE__, __LINE__);
475                        break;
476                    default :
477                        $app->raiseMsg(sprintf(_("This account is currently not active. %s"), $user_data['blocked_reason']), MSG_WARNING, __FILE__, __LINE__);
478                        break;
479                }
480
481                // No login: user is blocked!
482                $this->clear();
483                return false;
484            }
485        }
486
487        /**
488         * Check the db_login_table for too many logins under this account.
489         * (1) Count the number of unique IP addresses that logged in under this user within the login_abuse_timeframe
490         * (2) If this number exceeds the login_abuse_max_ips, assume multiple people are logging in under the same account.
491        **/
492        // TODO: make this ipv6 compatible. At the moment, ipv6 addresses are converted into zero for remote_ip_binary.
493        // http://www.highonphp.com/5-tips-for-working-with-ipv6-in-php
494        // https://stackoverflow.com/questions/444966/working-with-ipv6-addresses-in-php
495        if ($this->getParam('abuse_detection') && !$this->get('login_abuse_exempt')) {
496            $qid = $db->query("
497                SELECT COUNT(DISTINCT LEFT(remote_ip_binary, " . $this->_params['login_abuse_ip_bitmask'] . "))
498                FROM " . $this->_params['db_login_table'] . "
499                WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "'
500                AND DATE_ADD(login_datetime, INTERVAL '" . $this->_params['login_abuse_timeframe'] . "' DAY_HOUR) > NOW()
501            ");
502            list($distinct_ips) = mysql_fetch_row($qid);
503            if ($distinct_ips > $this->_params['login_abuse_max_ips']) {
504                if ($this->get('abuse_warning_level') < $this->_params['login_abuse_warnings']) {
505                    // Warn the user with a password reset.
506                    $this->resetPassword(null, _("This is a security precaution. We have detected this account has been accessed from multiple computers simultaneously. It is against policy to share login information with others. If further account abuse is detected this account will be blocked."));
507                    $app->raiseMsg(_("Your password has been reset as a security precaution. Please check your email for more information."), MSG_NOTICE, __FILE__, __LINE__);
508                    $app->logMsg(sprintf('Account abuse detected for user_id %s (%s) from IP %s', $this->get('user_id'), $this->get('username'), $this->get('remote_ip')), LOG_WARNING, __FILE__, __LINE__);
509                } else {
510                    // Block the account with the reason of account abuse.
511                    $this->blockAccount(null, 'account abuse');
512                    $app->raiseMsg(_("Your account has been blocked as a security precaution. Please contact us for more information."), MSG_NOTICE, __FILE__, __LINE__);
513                    $app->logMsg(sprintf('Account blocked for user_id %s (%s) from IP %s', $this->get('user_id'), $this->get('username'), $this->get('remote_ip')), LOG_ALERT, __FILE__, __LINE__);
514                }
515                // Increment user's warning level.
516                $db->query("UPDATE " . $this->_params['db_table'] . " SET abuse_warning_level = abuse_warning_level + 1 WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "'");
517                // Reset the login counter for this user.
518                $db->query("DELETE FROM " . $this->_params['db_login_table'] . " WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "'");
519                // No login: reset password because of account abuse!
520                $this->clear();
521                return false;
522            }
523
524            // Update the login counter table with this login access. Convert IP to binary.
525            // TODO: after MySQL 5.0.23 is released this query could benefit from INSERT DELAYED.
526            $db->query("
527                INSERT INTO " . $this->_params['db_login_table'] . " (
528                    " . $this->_params['db_primary_key'] . ",
529                    login_datetime,
530                    remote_ip_binary
531                ) VALUES (
532                    '" . $this->get('user_id') . "',
533                    '" . $this->get('login_datetime') . "',
534                    '" . sprintf('%032b', ip2long($this->get('remote_ip'))) . "'
535                )
536            ");
537        }
538
539        // Update user table with this login.
540        $db->query("
541            UPDATE " . $this->_params['db_table'] . " SET
542                last_login_datetime = '" . $this->get('login_datetime') . "',
543                last_access_datetime = '" . $this->get('login_datetime') . "',
544                last_login_ip = '" . $this->get('remote_ip') . "'
545            WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "'
546        ");
547
548        // We're logged-in!
549        return true;
550    }
551
552    /**
553     * Test if user has a currently logged-in session.
554     *  - authentication flag set to true
555     *  - username not empty
556     *  - total logged-in time is not greater than login_timeout
557     *  - idle time is not greater than idle_timeout
558     *  - remote address is the same as the login remote address (aol users excluded).
559     *
560     * @access public
561     */
562    public function isLoggedIn($user_id=null)
563    {
564        $app =& App::getInstance();
565        $db =& DB::getInstance();
566
567        $this->initDB();
568
569        if (isset($user_id)) {
570            // Check the login status of a specific user.
571            $qid = $db->query("
572                SELECT 1 FROM " . $this->_params['db_table'] . "
573                WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "'
574                AND last_login_datetime > DATE_SUB(NOW(), INTERVAL '" . $this->_params['login_timeout'] . "' SECOND)
575                AND last_access_datetime > DATE_SUB(NOW(), INTERVAL '" . $this->_params['idle_timeout'] . "' SECOND)
576            ");
577            $login_status = (mysql_num_rows($qid) > 0);
578            $app->logMsg(sprintf('Returning %s login status for user_id %s', ($login_status ? 'true' : 'false'), $user_id), LOG_DEBUG, __FILE__, __LINE__);
579            return $login_status;
580        }
581
582        // User login test need only be run once per script execution. We cache the result in the session.
583        if ($this->_authentication_tested && isset($_SESSION['_auth_sql'][$this->_ns]['authenticated'])) {
584            $app->logMsg(sprintf('Returning cached authentication status: %s', ($_SESSION['_auth_sql'][$this->_ns]['authenticated'] ? 'true' : 'false')), LOG_DEBUG, __FILE__, __LINE__);
585            return $_SESSION['_auth_sql'][$this->_ns]['authenticated'];
586        }
587
588        // Tesing login should occur once. This is the first time. Set flag.
589        $this->_authentication_tested = true;
590
591        // Some users will access from networks with a changing IP number (i.e. behind a proxy server).
592        // These users must be allowed entry by adding their IP to the list of trusted_networks, or their usernames to the list of match_remote_ip_exempt_usernames.
593        if ($trusted_net = ipInRange(getRemoteAddr(), $this->_params['trusted_networks'])) {
594            $user_in_trusted_network = true;
595            $app->logMsg(sprintf('User_id %s accessing from trusted network %s',
596                ($this->get('user_id') ? $this->get('user_id') . ' (' .  $this->get('username') . ')' : 'unknown'),
597                $trusted_net
598            ), LOG_DEBUG, __FILE__, __LINE__);
599        } else if (preg_match('/proxy.aol.com$/i', getRemoteAddr(true))) {
600            $user_in_trusted_network = true;
601            $app->logMsg(sprintf('User_id %s accessing from trusted network proxy.aol.com',
602                ($this->get('user_id') ? $this->get('user_id') . ' (' .  $this->get('username') . ')' : 'unknown')
603            ), LOG_DEBUG, __FILE__, __LINE__);
604        } else {
605            $user_in_trusted_network = false;
606        }
607
608        // Do we match the user's remote IP at all? Yes, if set in config and not disabled for specific user.
609        if ($this->getParam('match_remote_ip') && !$this->get('match_remote_ip_exempt')) {
610            $remote_ip_is_matched = (isset($_SESSION['_auth_sql'][$this->_ns]['remote_ip']) && $_SESSION['_auth_sql'][$this->_ns]['remote_ip'] == getRemoteAddr()) || $user_in_trusted_network;
611        } else {
612            $app->logMsg(sprintf('User_id %s exempt from remote_ip match (comparing %s == %s)',
613                ($this->get('user_id') ? $this->get('user_id') . ' (' .  $this->get('username') . ')' : 'unknown'),
614                $_SESSION['_auth_sql'][$this->_ns]['remote_ip'],
615                getRemoteAddr()
616            ), LOG_DEBUG, __FILE__, __LINE__);
617            $remote_ip_is_matched = true;
618        }
619
620        // Test login with information stored in session. Skip IP matching for users from trusted networks.
621        if (isset($_SESSION['_auth_sql'][$this->_ns]['authenticated'])
622            && true === $_SESSION['_auth_sql'][$this->_ns]['authenticated']
623            && isset($_SESSION['_auth_sql'][$this->_ns]['username'])
624            && !empty($_SESSION['_auth_sql'][$this->_ns]['username'])
625            && isset($_SESSION['_auth_sql'][$this->_ns]['login_datetime'])
626            && strtotime($_SESSION['_auth_sql'][$this->_ns]['login_datetime']) > time() - $this->_params['login_timeout']
627            && isset($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime'])
628            && strtotime($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime']) > time() - $this->_params['idle_timeout']
629            && $remote_ip_is_matched
630        ) {
631            // User is authenticated!
632            $_SESSION['_auth_sql'][$this->_ns]['last_access_datetime'] = date('Y-m-d H:i:s');
633
634            // Update the DB with the last_access_datetime and increment the seconds_online.
635            $db->query("
636                UPDATE " . $this->_params['db_table'] . " SET
637                seconds_online = seconds_online + (UNIX_TIMESTAMP() - UNIX_TIMESTAMP(last_access_datetime)) + 1,
638                last_access_datetime = '" . $this->get('last_access_datetime') . "'
639                WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "'
640            ");
641            if (mysql_affected_rows($db->getDBH()) > 0) {
642                // User record still exists in DB. Do this to ensure user was not delete from DB between accesses. Notice "+ 1" in SQL above to ensure record is modified.
643                return true;
644            } else {
645                $app->logMsg(sprintf('Session update failed; record not found for user_id %s (%s).', $this->get('user_id'), $this->get('username')), LOG_NOTICE, __FILE__, __LINE__);
646            }
647        } else if (isset($_SESSION['_auth_sql'][$this->_ns]['authenticated']) && true === $_SESSION['_auth_sql'][$this->_ns]['authenticated']) {
648            // User is authenticated, but login has expired.
649            if (strtotime($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime']) > time() - 43200) {
650                // Only raise message if last session is less than 12 hours old.
651                $app->raiseMsg(_("Your session has expired. You need to log-in again."), MSG_NOTICE, __FILE__, __LINE__);
652            }
653
654            // Log the reason for login expiration.
655            $expire_reasons = array();
656            if (empty($_SESSION['_auth_sql'][$this->_ns]['username'])) {
657                $expire_reasons[] = 'username not found';
658            }
659            if (strtotime($_SESSION['_auth_sql'][$this->_ns]['login_datetime']) <= time() - $this->_params['login_timeout']) {
660                $expire_reasons[] = sprintf('login_timeout expired (%s older than %s seconds ago)', $_SESSION['_auth_sql'][$this->_ns]['login_datetime'], $this->_params['login_timeout']);
661            }
662            if (strtotime($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime']) <= time() - $this->_params['idle_timeout']) {
663                $expire_reasons[] = sprintf('idle_timeout expired (%s older than %s seconds ago)', $_SESSION['_auth_sql'][$this->_ns]['last_access_datetime'], $this->_params['idle_timeout']);
664            }
665            if ($_SESSION['_auth_sql'][$this->_ns]['remote_ip'] != getRemoteAddr()) {
666                if ($this->getParam('match_remote_ip') && !$this->get('match_remote_ip_exempt') && !$user_in_trusted_network) {
667                    // There are three cases when a remote IP match will be the cause of a session termination:
668                    //   1. match_remote_ip config is enabled
669                    //   2. user is not match_remote_ip_exempt (set in the user_data, or in the match_remote_ip_exempt_usernames list)
670                    //   3. the user is connecting from a trusted network (their IP is listed in the trusted_networks or from *.proxy.aol.com)
671                    $expire_reasons[] = sprintf('remote_ip not matched (%s != %s)', $_SESSION['_auth_sql'][$this->_ns]['remote_ip'], getRemoteAddr());
672                } else {
673                    $expire_reasons[] = sprintf('remote_ip not matched but user was exempt from this check (%s != %s)', $_SESSION['_auth_sql'][$this->_ns]['remote_ip'], getRemoteAddr());
674                }
675            }
676            $app->logMsg(sprintf('User_id %s (%s) session expired: %s', $this->get('user_id'), $this->get('username'), join(', ', $expire_reasons)), LOG_INFO, __FILE__, __LINE__);
677        }
678
679        // User is not authenticated.
680        $this->clear();
681        return false;
682    }
683
684    /**
685     * Redirect user to login page if they are not logged in.
686     *
687     * @param string $message The text description of a message to raise.
688     * @param int    $type    The type of message: MSG_NOTICE,
689     *                        MSG_SUCCESS, MSG_WARNING, or MSG_ERR.
690     * @param string $file    __FILE__.
691     * @param string $line    __LINE__.
692     * @access public
693     */
694    public function requireLogin($message='', $type=MSG_NOTICE, $file=null, $line=null)
695    {
696        $app =& App::getInstance();
697
698        if (!$this->isLoggedIn()) {
699            // Display message for requiring login. (RaiseMsg will ignore empty strings.)
700            if ('' != $message) {
701                $app->raiseMsg($message, $type, $file, $line);
702            }
703
704            // Login scripts must have the same 'login' tag for boomerangURL verification/manipulation.
705            $app->setBoomerangURL(absoluteMe(), 'login');
706            $app->dieURL($this->_params['login_url']);
707        }
708    }
709
710    /**
711     * This sets the 'blocked' field for a user in the db_table, and also
712     * adds an optional reason
713     *
714     * @param  string   $reason      The reason for blocking the account.
715     */
716    public function blockAccount($user_id=null, $reason='')
717    {
718        $app =& App::getInstance();
719        $db =& DB::getInstance();
720
721        $this->initDB();
722
723        if ($this->getParam('blocking')) {
724            if (mb_strlen($db->escapeString($reason)) > 255) {
725                // blocked_reason field is varchar(255).
726                $app->logMsg(sprintf('Blocked reason provided is greater than 255 characters: %s', $reason), LOG_WARNING, __FILE__, __LINE__);
727            }
728
729            // Get user_id if specified.
730            $user_id = isset($user_id) ? $user_id : $this->get('user_id');
731            $db->query("
732                UPDATE " . $this->_params['db_table'] . " SET
733                blocked = 'true',
734                blocked_reason = '" . $db->escapeString($reason) . "'
735                WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "'
736            ");
737        }
738    }
739
740    /**
741     * Tests if the "blocked" flag is set for a user.
742     *
743     * @param  int      $user_id    User id to look for.
744     * @return boolean              True if the user is blocked, false otherwise.
745     */
746    public function isBlocked($user_id=null)
747    {
748        $db =& DB::getInstance();
749
750        $this->initDB();
751
752        if ($this->getParam('blocking')) {
753            // Get user_id if specified.
754            $user_id = isset($user_id) ? $user_id : $this->getVal('user_id');
755            $qid = $db->query("
756                SELECT 1
757                FROM " . $this->_params['db_table'] . "
758                WHERE blocked = 'true'
759                AND " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "'
760            ");
761            return mysql_num_rows($qid) === 1;
762        }
763    }
764
765    /**
766     * Unblocks a user in the db_table, and clears any blocked_reason.
767     */
768    public function unblockAccount($user_id=null)
769    {
770        $db =& DB::getInstance();
771
772        $this->initDB();
773
774        if ($this->getParam('blocking')) {
775            // Get user_id if specified.
776            $user_id = isset($user_id) ? $user_id : $this->get('user_id');
777            $db->query("
778                UPDATE " . $this->_params['db_table'] . " SET
779                blocked = '',
780                blocked_reason = ''
781                WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "'
782            ");
783        }
784    }
785
786    /**
787     * Returns true if username already exists in database.
788     *
789     * @param  string  $username    Username to look for.
790     * @return bool                 True if username exists.
791     */
792    public function usernameExists($username)
793    {
794        $db =& DB::getInstance();
795
796        $this->initDB();
797
798        $qid = $db->query("
799            SELECT 1
800            FROM " . $this->_params['db_table'] . "
801            WHERE " . $this->_params['db_username_column'] . " = '" . $db->escapeString($username) . "'
802        ");
803        return (mysql_num_rows($qid) > 0);
804    }
805
806    /**
807     * Returns a username for a specified user id.
808     *
809     * @param  string  $user_id     User id to look for.
810     * @return string               Username, or false if none found.
811     */
812    public function getUsername($user_id)
813    {
814        $db =& DB::getInstance();
815
816        $this->initDB();
817
818        $qid = $db->query("
819            SELECT " . $this->_params['db_username_column'] . "
820            FROM " . $this->_params['db_table'] . "
821            WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "'
822        ");
823        if (list($username) = mysql_fetch_row($qid)) {
824            return $username;
825        } else {
826            return false;
827        }
828    }
829
830    /*
831    * Generate a cryptographically secure, random password.
832    *
833    * @access   public
834    * @param    int  $bytes     Length of password (in bytes)
835    * @return   string          Random string of characters.
836    * @author   Quinn Comendant <quinn@strangecode.com>
837    * @version  1.0
838    * @since    15 Nov 2014 20:30:27
839    */
840    public function generatePassword($bytes=10)
841    {
842        $app =& App::getInstance();
843
844        $bytes = is_numeric($bytes) ? $bytes : 10;
845        $string = strtok(base64_encode(openssl_random_pseudo_bytes($bytes, $strong)), '=');
846        if (!$strong) {
847            $app->logMsg(sprintf('Password generated was not "cryptographically strong"; check your openssl.', null), LOG_NOTICE, __FILE__, __LINE__);
848        }
849
850        return $string;
851    }
852
853    /**
854     *
855     */
856    public function encryptPassword($password, $salt=null, $hash_type=null)
857    {
858        $app =& App::getInstance();
859
860        $password = (string)$password;
861
862        // Existing password hashes rely on the same key/salt being used to compare hashs.
863        // Don't change this (or the value applied to signing_key) unless you know existing hashes or signatures will not be affected!
864        $more_salt = 'B36D18E5-3FE4-4D58-8150-F26642852B81';
865
866        $hash_type = isset($hash_type) && !empty($hash_type) ? $hash_type : $this->getParam('hash_type');
867
868        switch ($hash_type) {
869        case self::ENCRYPT_PLAINTEXT :
870            $encrypted_password = $password;
871            break;
872
873        case self::ENCRYPT_CRYPT :
874            // If comparing password with an existing hashed password, provide the hashed password as the salt.
875            $encrypted_password = isset($salt) ? crypt($password, $salt) : crypt($password);
876            break;
877
878        case self::ENCRYPT_SHA1 :
879            $encrypted_password = sha1($password);
880            break;
881
882        case self::ENCRYPT_SHA1_HARDENED :
883            $encrypted_password = sha1($app->getParam('signing_key') . $password . $more_salt);
884            for ($i=0; $i < pow(2, 20); $i++) {
885                $encrypted_password = sha1($password . $encrypted_password);
886            }
887            break;
888
889        case self::ENCRYPT_MD5 :
890            $encrypted_password = md5($password);
891            break;
892
893        case self::ENCRYPT_MD5_HARDENED :
894            $encrypted_password = md5($app->getParam('signing_key') . $password . $more_salt);
895            for ($i=0; $i < pow(2, 20); $i++) {
896                $encrypted_password = md5($password . $encrypted_password);
897            }
898            break;
899
900        case self::ENCRYPT_PASSWORD_BCRYPT :
901            $encrypted_password = password_hash($password, PASSWORD_BCRYPT, array('cost' => 12));
902            break;
903
904        case self::ENCRYPT_PASSWORD_DEFAULT :
905            $encrypted_password = password_hash($password, PASSWORD_DEFAULT, array('cost' => 12));
906            break;
907
908        default :
909            $app->logMsg(sprintf('Unknown hash type: %s', $hash_type), LOG_WARNING, __FILE__, __LINE__);
910            return false;
911        }
912
913        // In case our hashing function returns 'false' or another empty value, bail out.
914        if ('' == trim((string)$encrypted_password)) {
915            $app->logMsg(sprintf('Invalid password hash returned ("%s") for hash type %s; check yo crypto!', $encrypted_password, $hash_type), LOG_ALERT, __FILE__, __LINE__);
916            return false;
917        }
918
919        return $encrypted_password;
920    }
921
922    /*
923    *
924    *
925    * @access   public
926    * @param
927    * @return
928    * @author   Quinn Comendant <quinn@strangecode.com>
929    * @version  1.0
930    * @since    15 Nov 2014 21:37:28
931    */
932    public function verifyPassword($password, $encrypted_password, $hash_type=null)
933    {
934        $app =& App::getInstance();
935
936        $hash_type = isset($hash_type) && !empty($hash_type) ? $hash_type : $this->getParam('hash_type');
937
938        switch ($hash_type) {
939        case self::ENCRYPT_CRYPT :
940            return $this->encryptPassword($password, $encrypted_password) == $encrypted_password;
941
942        case self::ENCRYPT_PLAINTEXT :
943        case self::ENCRYPT_MD5 :
944        case self::ENCRYPT_MD5_HARDENED :
945        case self::ENCRYPT_SHA1 :
946        case self::ENCRYPT_SHA1_HARDENED :
947        default :
948            return $this->encryptPassword($password) == $encrypted_password;
949
950        case self::ENCRYPT_PASSWORD_BCRYPT :
951        case self::ENCRYPT_PASSWORD_DEFAULT :
952            return password_verify($password, $encrypted_password);
953        }
954
955        $app->logMsg(sprintf('Unknown hash type: %s', $hash_type), LOG_WARNING, __FILE__, __LINE__);
956        return false;
957    }
958
959    /**
960     *
961     */
962    public function setPassword($user_id=null, $password, $hash_type=null)
963    {
964        $app =& App::getInstance();
965        $db =& DB::getInstance();
966
967        $this->initDB();
968
969        // Get user_id if specified.
970        $user_id = isset($user_id) ? $user_id : $this->get('user_id');
971
972        // New hash type.
973        $hash_type = isset($hash_type) ? $hash_type : $this->getParam('hash_type');
974
975        // Save the hash method used if a table exists for it.
976        $userpass_hashtype_clause = '';
977        if ($db->columnExists($this->_params['db_table'], 'userpass_hashtype', false)) {
978            $userpass_hashtype_clause = ", userpass_hashtype = '" . $db->escapeString($hash_type) . "'";
979        }
980
981        // Issue the password change query.
982        $db->query("
983            UPDATE " . $this->_params['db_table'] . "
984            SET userpass = '" . $db->escapeString($this->encryptPassword($password, null, $hash_type)) . "'
985            $userpass_hashtype_clause
986            WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "'
987        ");
988
989        if (mysql_affected_rows($db->getDBH()) != 1) {
990            $app->logMsg(sprintf('Failed to update password for user_id %s (no affected rows)', $user_id), LOG_WARNING, __FILE__, __LINE__);
991            return false;
992        }
993
994        $app->logMsg(sprintf('Password change successful for user_id %s', $user_id), LOG_INFO, __FILE__, __LINE__);
995        return true;
996    }
997
998    /**
999     * Resets the password for the user with the specified id.
1000     *
1001     * @param  string $user_id   The id of the user to reset.
1002     * @param  string $reason    Additional message to add to the reset email.
1003     * @return string            The user's new password.
1004     */
1005    public function resetPassword($user_id=null, $reason='')
1006    {
1007        $app =& App::getInstance();
1008        $db =& DB::getInstance();
1009
1010        $this->initDB();
1011
1012        // Get user_id if specified.
1013        $user_id = isset($user_id) ? $user_id : $this->get('user_id');
1014
1015        // Reset password of a specific user.
1016        $qid = $db->query("
1017            SELECT * FROM " . $this->_params['db_table'] . "
1018            WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "'
1019        ");
1020        if (!$user_data = mysql_fetch_assoc($qid)) {
1021            $app->logMsg(sprintf('Reset password failed. User_id %s not found.', $user_id), LOG_NOTICE, __FILE__, __LINE__);
1022            return false;
1023        }
1024
1025        // Get new password.
1026        $password = $this->generatePassword();
1027
1028        // Update password query.
1029        $this->setPassword($user_id, $password);
1030
1031        // Make sure user has an email on record before continuing.
1032        if (!isset($user_data['email']) || '' == trim($user_data['email'])) {
1033            $app->logMsg(sprintf('Password reset but notification failed, no email address for user_id %s (%s).', $user_data[$this->_params['db_primary_key']], $user_data[$this->_params['db_username_column']]), LOG_NOTICE, __FILE__, __LINE__);
1034        } else {
1035            // Send the new password in an email.
1036            $email = new Email(array(
1037                'to' => $user_data['email'],
1038                'from' => sprintf('%s <%s>', $app->getParam('site_name'), $app->getParam('site_email')),
1039                'subject' => sprintf('%s password change', $app->getParam('site_name'))
1040            ));
1041            $email->setTemplate('codebase/services/templates/email_reset_password.txt');
1042            $email->replace(array(
1043                'SITE_NAME' => $app->getParam('site_name'),
1044                'SITE_URL' => $app->getParam('site_url'),
1045                'SITE_EMAIL' => $app->getParam('site_email'),
1046                'NAME' => ('' != $user_data['first_name'] . $user_data['last_name'] ? $user_data['first_name'] . ' ' . $user_data['last_name'] : $user_data[$this->_params['db_username_column']]),
1047                'USERNAME' => $user_data[$this->_params['db_username_column']],
1048                'PASSWORD' => $password,
1049                'REASON' => ('' == trim($reason) ? '' : trim($reason) . ' '), // Add a space after the reason if it exists for better formatting.
1050            ));
1051            $email->send();
1052        }
1053
1054        return array(
1055            'username' => $user_data[$this->_params['db_username_column']],
1056            'userpass' => $password
1057        );
1058    }
1059
1060} // end class
Note: See TracBrowser for help on using the repository browser.