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

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

Many auth and crypto changes; various other bugfixes while working on pulso.

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