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

Last change on this file since 535 was 535, checked in by anonymous, 9 years ago

Added nav page ids to service scripts. Logging unauthenticated sessions.

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