* Copyright 2001-2012 Strangecode, LLC * * This file is part of The Strangecode Codebase. * * The Strangecode Codebase is free software: you can redistribute it and/or * modify it under the terms of the GNU General Public License as published by the * Free Software Foundation, either version 3 of the License, or (at your option) * any later version. * * The Strangecode Codebase is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along with * The Strangecode Codebase. If not, see . */ /* * The Auth_SQL class provides a SQL implementation for authentication. * * @author Quinn Comendant * @version 2.1 */ require_once dirname(__FILE__) . '/Email.inc.php'; class Auth_SQL { // Available hash types for class Auth_SQL. const ENCRYPT_PLAINTEXT = 1; const ENCRYPT_CRYPT = 2; const ENCRYPT_SHA1 = 3; const ENCRYPT_SHA1_HARDENED = 4; const ENCRYPT_MD5 = 5; const ENCRYPT_MD5_HARDENED = 6; const ENCRYPT_PASSWORD_BCRYPT = 7; const ENCRYPT_PASSWORD_DEFAULT = 8; // Namespace of this auth object. protected $_ns; // Static var for test. protected $_authentication_tested; // Parameters to be configured by setParam. protected $_params = array(); protected $_default_params = array( // Automatically create table and verify columns. Better set to false after site launch. // This value is overwritten by the $app->getParam('db_create_tables') setting if it is available. 'create_table' => true, // The database table containing users to authenticate. 'db_table' => 'user_tbl', // The name of the primary key for the db_table. 'db_primary_key' => 'user_id', // The name of the username key for the db_table. 'db_username_column' => 'username', // If using the db_login_table feature, specify the db_login_table. The primary key must match the primary key for the db_table. 'db_login_table' => 'user_login_tbl', // The type of hash to use for passwords stored in the db_table. Use one of the Auth_SQL::ENCRYPT_* types specified above. // Hardened password hashes rely on the same key/salt being used to compare hashes. // Be aware that when using one of the hardened types the App signing_key or $more_salt below cannot change! 'hash_type' => self::ENCRYPT_MD5, 'encryption_type' => null, // Backwards misnomer compatibility. // Automatically update stored user hashes when the user next authenticates if the hash type changes (requires user_tbl with populated userpass_hashtype column). 'hash_type_autoupdate' => true, // The URL to the login script. 'login_url' => '/', // The maximum amount of time a user is allowed to be logged in. They will be forced to login again if they expire. // In seconds. 21600 seconds = 6 hours. 'login_timeout' => 21600, // 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. // In seconds. 3600 seconds = 1 hour. 'idle_timeout' => 3600, // 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. // Days and hours, like this: 'DD:HH' 'login_abuse_timeframe' => '04:00', // The number of warnings a user will receive (and their password reset each time) before their account is completely blocked. 'login_abuse_warnings' => 3, // The maximum number of IP addresses a user can login with over the timeout period before their account is blocked. 'login_abuse_max_ips' => 5, // The IP address subnet size threshold. Uses a CIDR notation network mask (see CIDR cheat-sheet at bottom). // Any integer between 0 and 32 is permitted. Setting this to '24' permits any address in a // class C network (255.255.255.0) to be considered the same. Setting to '32' compares each IP absolutely. // Setting to '0' ignores all IPs, thus disabling login_abuse checking. 'login_abuse_ip_bitmask' => 32, // Specify usernames to exclude from the account abuse detection system. This is specified as a hardcoded array provided at // class instantiation time, or can be saved in the db_table under the login_abuse_exempt field. 'login_abuse_exempt_usernames' => array(), // 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. 'match_remote_ip_exempt_usernames' => array(), // Match the user's current remote IP against the one they logged in with. 'match_remote_ip' => true, // An array of IP blocks that are bypass the remote_ip comparison check. Useful for dynamic IPs or those behind proxy servers. 'trusted_networks' => array(), // Allow user accounts to be blocked? Requires the user table to have the columns 'blocked' and 'blocked_reason' 'blocking' => false, // Use a db_login_table to detect excessive logins. This requires blocking to be enabled. 'abuse_detection' => false, // Allow users to save login form passwords in their browser? Setting to 'true' may pose a potential security risk. 'login_form_allow_autocomplete' => false, ); /** * Constructs a new authentication object. * * @access public * @param optional array $params A hash containing parameters. */ public function __construct($namespace='') { $app =& App::getInstance(); $this->_ns = $namespace; // Initialize default parameters. $this->setParam($this->_default_params); // Get create tables config from global context. if (!is_null($app->getParam('db_create_tables'))) { $this->setParam(array('create_table' => $app->getParam('db_create_tables'))); } if (!isset($_SESSION['_auth_sql'][$this->_ns])) { $app->logMsg(sprintf('No _auth_sql session found; initializing', null), LOG_DEBUG, __FILE__, __LINE__); $this->clear(); } } /** * Setup the database tables for this class. * * @access public * @author Quinn Comendant * @since 26 Aug 2005 17:09:36 */ public function initDB($recreate_db=false) { $app =& App::getInstance(); $db =& DB::getInstance(); static $_db_tested = false; if ($recreate_db || !$_db_tested && $this->getParam('create_table')) { // User table. if ($recreate_db) { $db->query("DROP TABLE IF EXISTS " . $this->getParam('db_table')); $app->logMsg(sprintf('Dropping and recreating table %s.', $this->getParam('db_table')), LOG_INFO, __FILE__, __LINE__); } // The minimal columns for a table compatible with the Auth_SQL class. $db->query(sprintf( "CREATE TABLE IF NOT EXISTS %1\$s ( %2\$s MEDIUMINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, %3\$s varchar(255) NOT NULL default '', userpass VARCHAR(255) NOT NULL DEFAULT '', userpass_hashtype TINYINT UNSIGNED NOT NULL DEFAULT '0', first_name VARCHAR(50) NOT NULL DEFAULT '', last_name VARCHAR(50) NOT NULL DEFAULT '', email VARCHAR(255) NOT NULL DEFAULT '', login_abuse_exempt ENUM('true') DEFAULT NULL, blocked ENUM('true') DEFAULT NULL, blocked_reason VARCHAR(255) NOT NULL DEFAULT '', abuse_warning_level TINYINT NOT NULL DEFAULT '0', seconds_online INT NOT NULL DEFAULT '0', last_login_datetime DATETIME NOT NULL DEFAULT '%4\$s 00:00:00', last_access_datetime DATETIME NOT NULL DEFAULT '%4\$s 00:00:00', last_login_ip VARCHAR(45) NOT NULL DEFAULT '0.0.0.0', added_by_user_id SMALLINT DEFAULT NULL, modified_by_user_id SMALLINT DEFAULT NULL, added_datetime DATETIME NOT NULL DEFAULT '%4\$s 00:00:00', modified_datetime DATETIME NOT NULL DEFAULT '%4\$s 00:00:00', KEY %5\$s (%5\$s), KEY userpass (userpass), KEY email (email), KEY last_login_datetime (last_login_datetime), KEY last_access_datetime (last_access_datetime) )", $db->escapeString($this->getParam('db_table')), $this->getParam('db_primary_key'), $this->getParam('db_username_column'), $db->getParam('zero_date'), $this->getParam('db_username_column') )); if (!$db->columnExists($this->getParam('db_table'), array( $this->getParam('db_primary_key'), $this->getParam('db_username_column'), 'userpass', 'first_name', 'last_name', 'email', 'login_abuse_exempt', 'blocked', 'blocked_reason', 'abuse_warning_level', 'seconds_online', 'last_login_datetime', 'last_access_datetime', 'last_login_ip', 'added_by_user_id', 'modified_by_user_id', 'added_datetime', 'modified_datetime', ), false, false)) { $app->logMsg(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), LOG_ALERT, __FILE__, __LINE__); trigger_error(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_table')), E_USER_ERROR); } // Login table is used for abuse_detection features. if ($this->getParam('abuse_detection')) { if ($recreate_db) { $db->query("DROP TABLE IF EXISTS " . $this->getParam('db_login_table')); $app->logMsg(sprintf('Dropping and recreating table %s.', $this->getParam('db_login_table')), LOG_INFO, __FILE__, __LINE__); } $db->query(sprintf( "CREATE TABLE IF NOT EXISTS %1\$s ( %2\$s MEDIUMINT UNSIGNED NOT NULL DEFAULT '0', login_datetime DATETIME NOT NULL DEFAULT '%3\$s 00:00:00', remote_ip_binary CHAR(32) NOT NULL DEFAULT '', KEY %4\$s (%4\$s), KEY login_datetime (login_datetime), KEY remote_ip_binary (remote_ip_binary) )", $this->getParam('db_login_table'), $this->getParam('db_primary_key'), $db->getParam('zero_date'), $this->getParam('db_primary_key') )); if (!$db->columnExists($this->getParam('db_login_table'), array( $this->getParam('db_primary_key'), 'login_datetime', 'remote_ip_binary', ), false, false)) { $app->logMsg(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_login_table')), LOG_ALERT, __FILE__, __LINE__); trigger_error(sprintf('Database table %s has invalid columns. Please update this table manually.', $this->getParam('db_login_table')), E_USER_ERROR); } } } $_db_tested = true; } /** * Set the params of an auth object. * * @param array $params Array of parameter keys and value to set. * @return bool true on success, false on failure */ public function setParam($params) { $app =& App::getInstance(); if (isset($params['match_remote_ip_exempt_usernames'])) { $params['match_remote_ip_exempt_usernames'] = array_map('strtolower', $params['match_remote_ip_exempt_usernames']); } if (isset($params['login_abuse_exempt_usernames'])) { $params['login_abuse_exempt_usernames'] = array_map('strtolower', $params['login_abuse_exempt_usernames']); } if (isset($params['encryption_type'])) { // Backwards misnomer compatibility. $params['hash_type'] = $params['encryption_type']; } 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))) { // These hash types require the password_* userland lib in PHP < 5.5.0 $pw_compat_lib = 'vendor/ircmaxell/password-compat/lib/password.php'; if (false !== stream_resolve_include_path($pw_compat_lib)) { include_once $pw_compat_lib; } else { $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__); $params['hash_type'] = self::ENCRYPT_SHA1_HARDENED; } } 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))) { $app->logMsg(sprintf('Invalid hash type %s; falling back to ENCRYPT_SHA1_HARDENED', $params['hash_type']), LOG_ERR, __FILE__, __LINE__); $params['hash_type'] = self::ENCRYPT_SHA1_HARDENED; } if (isset($params) && is_array($params)) { // Merge new parameters with old overriding only those passed. $this->_params = array_merge($this->_params, $params); } } /** * Return the value of a parameter, if it exists. * * @access public * @param string $param Which parameter to return. * @return mixed Configured parameter value. */ public function getParam($param) { $app =& App::getInstance(); if (array_key_exists($param, $this->_params)) { return $this->_params[$param]; } else { $app->logMsg(sprintf('Parameter is not set: %s', $param), LOG_DEBUG, __FILE__, __LINE__); return null; } } /** * Clear any authentication tokens in the current session. A.K.A. logout. * * @access public */ public function clear() { $app =& App::getInstance(); $db =& DB::getInstance(); if ($this->get('user_id', false)) { $this->initDB(); // FIX ME: Should we check if the session is active? $db->query(sprintf( "UPDATE %s SET seconds_online = seconds_online + IFNULL(ABS(UNIX_TIMESTAMP() - UNIX_TIMESTAMP(last_access_datetime)), 0), last_login_datetime = '%s 00:00:00' WHERE %s = '%s' ", $this->_params['db_table'], $db->getParam('zero_date'), $this->_params['db_primary_key'], $this->get('user_id') )); } $_SESSION['_auth_sql'][$this->_ns] = array( 'authenticated' => false, 'user_id' => null, 'username' => null, 'login_datetime' => null, 'last_access_datetime' => null, 'remote_ip' => getRemoteAddr(), 'login_abuse_exempt' => null, 'match_remote_ip_exempt'=> null, 'user_data' => null, ); $app->logMsg(sprintf('Cleared %s auth', $this->_ns), LOG_DEBUG, __FILE__, __LINE__); } /** * Sets a variable into a registered auth session. * * @access public * @param mixed $key Which value to set. * @param mixed $val Value to set variable to. */ public function set($key, $val) { if (!isset($_SESSION['_auth_sql'][$this->_ns]['user_data'])) { $_SESSION['_auth_sql'][$this->_ns]['user_data'] = array(); } if (isset($_SESSION['_auth_sql'][$this->_ns][$key])) { $_SESSION['_auth_sql'][$this->_ns][$key] = $val; } else { $_SESSION['_auth_sql'][$this->_ns]['user_data'][$key] = $val; } } /** * Returns a specified value from a registered auth session. * * @access public * @param mixed $key Which value to return. * @param mixed $default Value to return if key not found in user_data. * @return mixed Value stored in session. */ public function get($key, $default='') { if (isset($_SESSION['_auth_sql'][$this->_ns][$key])) { return $_SESSION['_auth_sql'][$this->_ns][$key]; } else if (isset($_SESSION['_auth_sql'][$this->_ns]['user_data'][$key])) { return $_SESSION['_auth_sql'][$this->_ns]['user_data'][$key]; } else { return $default; } } /** * Retrieve and verify the given username and password against a matching user record in the database. * * @access private * @param string $username The username to check. * @param string $password The password to compare to username. * @return mixed False if credentials not found in DB, or returns DB row matching credentials. */ public function authenticate($username, $password) { $app =& App::getInstance(); $db =& DB::getInstance(); $this->initDB(); // Get user data for specified username. $qid = $db->query(" SELECT *, " . $this->_params['db_primary_key'] . " AS user_id FROM " . $this->_params['db_table'] . " WHERE " . $this->_params['db_username_column'] . " = '" . $db->escapeString($username) . "' "); if (mysql_num_rows($qid) === 0 || !$user_data = mysql_fetch_assoc($qid)) { $app->logMsg(sprintf('Authentication failed; username %s not found', $username), LOG_NOTICE, __FILE__, __LINE__); return false; } if (mysql_num_rows($qid) !== 1) { $app->logMsg(sprintf('Authentication failed; multiple users with username "%s"', $username), LOG_WARNING, __FILE__, __LINE__); return false; } // TODO: log all auth attempts to db_login_table, not just successful ones. Then, rate-limit login attempts. // Check given password against hashed DB password. $old_hash_type = isset($user_data['userpass_hashtype']) && !empty($user_data['userpass_hashtype']) ? $user_data['userpass_hashtype'] : $this->getParam('hash_type'); if ($this->verifyPassword($password, $user_data['userpass'], $old_hash_type)) { $app->logMsg(sprintf('Authentication successful for %s (user_id=%s)', $username, $user_data['user_id']), LOG_INFO, __FILE__, __LINE__); unset($user_data['userpass']); // Avoid revealing the encrypted password in the $user_data. if ($this->getParam('hash_type_autoupdate') && $old_hash_type != $this->getParam('hash_type')) { // Let's update user's password hash to new type (just run setPassword with this authenticated password…). $this->setPassword($user_data['user_id'], $password); $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__); } return $user_data; } $app->logMsg(sprintf('Authentication failed for %s (user_id=%s)', $username, $user_data['user_id']), LOG_NOTICE, __FILE__, __LINE__); return false; } /** * Check username and password, and create new session if authenticated. * * @access private * @param string $username The username to check. * @param string $password The password to compare for username. * @return boolean Whether or not the credentials are valid. */ public function login($username, $password) { $app =& App::getInstance(); $db =& DB::getInstance(); if ($user_data = $this->authenticate($username, $password)) { // The credentials match. Now setup the session. return $this->createSession($user_data); } // No login: failed authentication! return false; } /** * Create new login session for given user. * * @access private * @param string $user_data User data that is normally returned from this->authenticate(). If provided manually: * Required array values: * 'user_id' => '1' * 'username' => 'name' * Optional array values: * 'match_remote_ip_exempt' => true * 'login_abuse_exempt' => true * 'abuse_warning_level' => true * 'blocked' => true * 'blocked_reason' => '' * '…' => '…' (any other values that should be retrievable via this->get()) * @return boolean Whether or not the session was created. It will return true unless abuse detection is enabled and triggered. */ public function createSession($user_data) { $app =& App::getInstance(); $db =& DB::getInstance(); $this->initDB(); $this->clear(); // Convert 'priv' to 'user_type' nomenclature to support older implementations. if (isset($user_data['priv'])) { $user_data['user_type'] = $user_data['priv']; } // Register authenticated session. $_SESSION['_auth_sql'][$this->_ns] = array( 'authenticated' => true, 'user_id' => $user_data['user_id'], 'username' => $user_data['username'], 'login_datetime' => date('Y-m-d H:i:s'), 'last_access_datetime' => date('Y-m-d H:i:s'), 'remote_ip' => getRemoteAddr(), 'login_abuse_exempt' => isset($user_data['login_abuse_exempt']) ? !empty($user_data['login_abuse_exempt']) : in_array(strtolower($user_data['username']), $this->_params['login_abuse_exempt_usernames']), 'match_remote_ip_exempt'=> isset($user_data['match_remote_ip_exempt']) ? !empty($user_data['match_remote_ip_exempt']) : in_array(strtolower($user_data['username']), $this->_params['match_remote_ip_exempt_usernames']), 'user_data' => $user_data ); /** * Check if the account is blocked, respond in context to reason. Cancel the login if blocked. */ if ($this->getParam('blocking')) { if (isset($user_data['blocked']) && !empty($user_data['blocked'])) { switch ($this->get('blocked_reason')) { case 'account abuse' : $app->raiseMsg(sprintf(_("This account has been blocked due to possible account abuse. Please contact an administrator to reactivate."), null), MSG_WARNING, __FILE__, __LINE__); break; default : $app->raiseMsg(sprintf(_("This account is currently not active. %s"), $this->get('blocked_reason')), MSG_WARNING, __FILE__, __LINE__); break; } // No login: user is blocked! $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__); $this->clear(); return false; } } /** * Check the db_login_table for too many logins under this account. * (1) Count the number of unique IP addresses that logged in under this user within the login_abuse_timeframe * (2) If this number exceeds the login_abuse_max_ips, assume multiple people are logging in under the same account. **/ // TODO: make this ipv6 compatible. At the moment, ipv6 addresses are converted into zero for remote_ip_binary. // http://www.highonphp.com/5-tips-for-working-with-ipv6-in-php // https://stackoverflow.com/questions/444966/working-with-ipv6-addresses-in-php if ($this->getParam('abuse_detection') && !$this->get('login_abuse_exempt')) { $qid = $db->query(" SELECT COUNT(DISTINCT LEFT(remote_ip_binary, " . $this->_params['login_abuse_ip_bitmask'] . ")) FROM " . $this->_params['db_login_table'] . " WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "' AND DATE_ADD(login_datetime, INTERVAL '" . $this->_params['login_abuse_timeframe'] . "' DAY_HOUR) > NOW() "); list($distinct_ips) = mysql_fetch_row($qid); if ($distinct_ips > $this->_params['login_abuse_max_ips']) { if ($this->get('abuse_warning_level') < $this->_params['login_abuse_warnings']) { // Warn the user with a password reset. $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 credentials with others. If further account abuse is detected this account will be blocked.")); $app->raiseMsg(_("Your password has been reset as a security precaution. Please check your email for more information."), MSG_NOTICE, __FILE__, __LINE__); $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__); } else { // Block the account with the reason of account abuse. $this->blockAccount(null, 'account abuse'); $app->raiseMsg(_("Your account has been blocked as a security precaution. Please contact us for more information."), MSG_NOTICE, __FILE__, __LINE__); $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__); } // Increment user's warning level. $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') . "'"); // Reset the login counter for this user. $db->query("DELETE FROM " . $this->_params['db_login_table'] . " WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "'"); // No login: reset password because of account abuse! $this->clear(); return false; } // Update the login counter table with this login access. Convert IP to binary. // TODO: this query could benefit from INSERT DELAYED. $db->query(" INSERT INTO " . $this->_params['db_login_table'] . " ( " . $this->_params['db_primary_key'] . ", login_datetime, remote_ip_binary ) VALUES ( '" . $this->get('user_id') . "', '" . $this->get('login_datetime') . "', '" . sprintf('%032b', ip2long($this->get('remote_ip'))) . "' ) "); } // Update user table with this login. $db->query(" UPDATE " . $this->_params['db_table'] . " SET last_login_datetime = '" . $this->get('login_datetime') . "', last_access_datetime = '" . $this->get('login_datetime') . "', last_login_ip = '" . $this->get('remote_ip') . "' WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "' "); // Session created! We're logged-in! $app->logMsg(sprintf('“%s” auth session created for user_id %s (%s): %s=%s', $this->_ns, $this->get('user_id'), $this->get('username'), session_name(), session_id()), LOG_DEBUG, __FILE__, __LINE__); return true; } /** * Test if user has a currently logged-in session. * - authentication flag set to true * - username not empty * - total logged-in time is not greater than login_timeout * - idle time is not greater than idle_timeout * - remote address is the same as the login remote address * * TODO: implement persistent sessions as per https://paragonie.com/blog/2015/04/secure-authentication-php-with-long-term-persistence * * @access public */ public function isLoggedIn($user_id=null) { $app =& App::getInstance(); $db =& DB::getInstance(); $this->initDB(); if (isset($user_id)) { // Check the login status of a specific user. $qid = $db->query(" SELECT TIMESTAMPDIFF(SECOND, last_login_datetime, NOW()) AS seconds_since_last_login, TIMESTAMPDIFF(SECOND, last_access_datetime, NOW()) AS seconds_since_last_access FROM " . $this->_params['db_table'] . " WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "' AND last_login_datetime > DATE_SUB(NOW(), INTERVAL '" . $db->escapeString($this->_params['login_timeout']) . "' SECOND) AND last_access_datetime > DATE_SUB(NOW(), INTERVAL '" . $db->escapeString($this->_params['idle_timeout']) . "' SECOND) "); $result = mysql_fetch_assoc($qid); if (mysql_num_rows($qid) > 0 && isset($result['seconds_since_last_login']) && isset($result['seconds_since_last_access'])) { $seconds_until_login_timeout = max(0, $this->_params['login_timeout'] - $result['seconds_since_last_login']); $seconds_until_idle_timeout = max(0, $this->_params['idle_timeout'] - $result['seconds_since_last_access']); $session_expiry_seconds = min($seconds_until_login_timeout, $seconds_until_idle_timeout); $app->logMsg(sprintf('Returning true login status for user_id %s (session expires in %s seconds)', $user_id, $session_expiry_seconds), LOG_DEBUG, __FILE__, __LINE__); return $session_expiry_seconds; } else { $app->logMsg(sprintf('Returning false login status for user_id %s', $user_id), LOG_DEBUG, __FILE__, __LINE__); return false; } } // User login test need only be run once per script execution. We cache the result in the session. if ($this->_authentication_tested && isset($_SESSION['_auth_sql'][$this->_ns]['authenticated'])) { $app->logMsg(sprintf('Returning cached authentication status: %s', ($_SESSION['_auth_sql'][$this->_ns]['authenticated'] ? 'true' : 'false')), LOG_DEBUG, __FILE__, __LINE__); return $_SESSION['_auth_sql'][$this->_ns]['authenticated']; } // Testing login should occur once. This is the first time. Set flag. $this->_authentication_tested = true; // Some users will access from networks with a changing IP number (i.e. behind a proxy server). // 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. if ($trusted_net = ipInRange(getRemoteAddr(), $this->_params['trusted_networks'])) { $user_in_trusted_network = true; $app->logMsg(sprintf('User_id %s accessing from trusted network %s', ($this->get('user_id') ? $this->get('user_id') . ' (' . $this->get('username') . ')' : 'unknown'), $trusted_net ), LOG_DEBUG, __FILE__, __LINE__); } else { $user_in_trusted_network = false; } // Do we match the user's remote IP at all? Yes, if set in config and not disabled for specific user. if ($this->getParam('match_remote_ip') && !$this->get('match_remote_ip_exempt')) { $remote_ip_is_matched = (isset($_SESSION['_auth_sql'][$this->_ns]['remote_ip']) && $_SESSION['_auth_sql'][$this->_ns]['remote_ip'] == getRemoteAddr()) || $user_in_trusted_network; } else { $app->logMsg(sprintf('User_id %s exempt from remote_ip match (comparing %s == %s)', ($this->get('user_id') ? $this->get('user_id') . ' (' . $this->get('username') . ')' : 'unknown'), $_SESSION['_auth_sql'][$this->_ns]['remote_ip'], getRemoteAddr() ), LOG_DEBUG, __FILE__, __LINE__); $remote_ip_is_matched = true; } // Test login with information stored in session. Skip IP matching for users from trusted networks. if (isset($_SESSION['_auth_sql'][$this->_ns]['authenticated']) && true === $_SESSION['_auth_sql'][$this->_ns]['authenticated'] && isset($_SESSION['_auth_sql'][$this->_ns]['username']) && !empty($_SESSION['_auth_sql'][$this->_ns]['username']) && isset($_SESSION['_auth_sql'][$this->_ns]['login_datetime']) && strtotime($_SESSION['_auth_sql'][$this->_ns]['login_datetime']) > (time() - $this->_params['login_timeout']) && isset($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime']) && strtotime($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime']) > (time() - $this->_params['idle_timeout']) && $remote_ip_is_matched ) { // User is authenticated! // Update the last_access_datetime to now. $this->set('last_access_datetime', date('Y-m-d H:i:s')); // Update the DB with the last_access_datetime and increment the seconds_online. $db->query(" UPDATE " . $this->_params['db_table'] . " SET seconds_online = seconds_online + IFNULL(ABS(UNIX_TIMESTAMP() - UNIX_TIMESTAMP(last_access_datetime)), 0) + 1, last_access_datetime = '" . $this->get('last_access_datetime') . "' WHERE " . $this->_params['db_primary_key'] . " = '" . $this->get('user_id') . "' "); if (mysql_affected_rows($db->getDBH()) > 0) { // 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. $app->logMsg(sprintf('Session authenticated for user_id %s (%s).', $this->get('user_id'), $this->get('username')), LOG_DEBUG, __FILE__, __LINE__); // TODO: This auth check doesn't match parity when calling isLoggedIn($user_id) with a user_id, because the latter checks last_login_datetime in DB, and the former only checks SESSION. These two can be out-of-sync after loading DB via sdbdown. return true; } else { $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__); } } else if (isset($_SESSION['_auth_sql'][$this->_ns]['authenticated']) && true === $_SESSION['_auth_sql'][$this->_ns]['authenticated']) { // User is authenticated, but login has expired. // Log the reason for login expiration. $expire_reasons = array(); $user_notified = false; if (!isset($_SESSION['_auth_sql'][$this->_ns]['username']) || empty($_SESSION['_auth_sql'][$this->_ns]['username'])) { $expire_reasons[] = 'username not found'; } if (!isset($_SESSION['_auth_sql'][$this->_ns]['login_datetime']) || strtotime($_SESSION['_auth_sql'][$this->_ns]['login_datetime']) <= (time() - $this->_params['login_timeout'])) { $expire_reasons[] = sprintf('login_timeout expired (%s older than %s seconds ago)', $_SESSION['_auth_sql'][$this->_ns]['login_datetime'], $this->_params['login_timeout']); } if (!isset($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime']) || strtotime($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime']) <= (time() - $this->_params['idle_timeout'])) { $expire_reasons[] = sprintf('idle_timeout expired (%s older than %s seconds ago)', $_SESSION['_auth_sql'][$this->_ns]['last_access_datetime'], $this->_params['idle_timeout']); if (strtotime($_SESSION['_auth_sql'][$this->_ns]['last_access_datetime']) > (time() - 43200)) { // Only raise message if last session is less than 12 hours old. // Notify user why they were logged out if they haven't yet been given a reason. $user_notified || $app->raiseMsg(sprintf(_("For your security, we logged you out after being idle for %s. Please log in again."), humanTime($this->_params['idle_timeout'], 'hour', '%01.0f')), MSG_NOTICE, __FILE__, __LINE__); $user_notified = true; } } if (!isset($_SESSION['_auth_sql'][$this->_ns]['remote_ip']) || $_SESSION['_auth_sql'][$this->_ns]['remote_ip'] != getRemoteAddr()) { if ($this->getParam('match_remote_ip') && !$this->get('match_remote_ip_exempt') && !$user_in_trusted_network) { // There are three cases when a remote IP match will be the cause of a session termination: // 1. match_remote_ip config is enabled // 2. user is not match_remote_ip_exempt (set in the user_data, or in the match_remote_ip_exempt_usernames list) // 3. the user is connecting from a trusted network (their IP is listed in the trusted_networks) $expire_reasons[] = sprintf('remote_ip not matched (%s != %s)', $_SESSION['_auth_sql'][$this->_ns]['remote_ip'], getRemoteAddr()); // Notify user why they were logged out if they haven't yet been given a reason. $user_notified || $app->raiseMsg(sprintf(_("For your security, we logged you out because your IP address changed. Please log in again."), null), MSG_NOTICE, __FILE__, __LINE__); $user_notified = true; } else { $expire_reasons[] = sprintf('remote_ip not matched but user was exempt from this check (%s != %s)', $_SESSION['_auth_sql'][$this->_ns]['remote_ip'], getRemoteAddr()); } } $app->logMsg(sprintf('User_id %s (%s) session expired: %s', $this->get('user_id'), $this->get('username'), join(', ', $expire_reasons)), LOG_INFO, __FILE__, __LINE__); } else { $app->logMsg('Session is not authenticated', LOG_DEBUG, __FILE__, __LINE__); } // User is not authenticated. $this->clear(); return false; } /** * Redirect user to login page if they are not logged in. * * @param string $message The text description of a message to raise. * @param int $type The type of message: MSG_NOTICE, * MSG_SUCCESS, MSG_WARNING, or MSG_ERR. * @param string $file __FILE__. * @param string $line __LINE__. * @access public */ public function requireLogin($message='', $type=MSG_NOTICE, $file=null, $line=null) { $app =& App::getInstance(); if (!$this->isLoggedIn()) { // Display message for requiring login. (RaiseMsg will ignore empty strings.) if ('' != $message) { $app->raiseMsg($message, $type, $file, $line); } // Login scripts must have the same 'login' tag for boomerangURL verification/manipulation. $app->setBoomerangURL(getenv('REQUEST_URI'), 'login'); $app->dieURL($this->_params['login_url']); } } /** * This sets the 'blocked' field for a user in the db_table, and also * adds an optional reason * * @param string $reason The reason for blocking the account. */ public function blockAccount($user_id=null, $reason='') { $app =& App::getInstance(); $db =& DB::getInstance(); $this->initDB(); if ($this->getParam('blocking')) { if (mb_strlen($db->escapeString($reason)) > 255) { // blocked_reason field is varchar(255). $app->logMsg(sprintf('Blocked reason provided is greater than 255 characters: %s', $reason), LOG_WARNING, __FILE__, __LINE__); } // Get user_id if specified. $user_id = isset($user_id) ? $user_id : $this->get('user_id'); $db->query(" UPDATE " . $this->_params['db_table'] . " SET blocked = 'true', blocked_reason = '" . $db->escapeString($reason) . "' WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "' "); } } /** * Tests if the "blocked" flag is set for a user. * * @param int $user_id User id to look for. * @return boolean True if the user is blocked, false otherwise. */ public function isBlocked($user_id=null) { $db =& DB::getInstance(); $this->initDB(); if ($this->getParam('blocking')) { // Get user_id if specified. $user_id = isset($user_id) ? $user_id : $this->getVal('user_id'); $qid = $db->query(" SELECT 1 FROM " . $this->_params['db_table'] . " WHERE blocked = 'true' AND " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "' "); return mysql_num_rows($qid) === 1; } } /** * Unblocks a user in the db_table, and clears any blocked_reason. */ public function unblockAccount($user_id=null) { $db =& DB::getInstance(); $this->initDB(); if ($this->getParam('blocking')) { // Get user_id if specified. $user_id = isset($user_id) ? $user_id : $this->get('user_id'); $db->query(" UPDATE " . $this->_params['db_table'] . " SET blocked = NULL, blocked_reason = '' WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "' "); } } /** * Returns true if username already exists in database. * * @param string $username Username to look for. * @return bool True if username exists. */ public function usernameExists($username) { $db =& DB::getInstance(); $this->initDB(); $qid = $db->query(" SELECT 1 FROM " . $this->_params['db_table'] . " WHERE " . $this->_params['db_username_column'] . " = '" . $db->escapeString($username) . "' "); return (mysql_num_rows($qid) > 0); } /** * Returns a username for a specified user id. * * @param string $user_id User id to look for. * @return string Username, or false if none found. */ public function getUsername($user_id) { $db =& DB::getInstance(); $this->initDB(); $qid = $db->query(" SELECT " . $this->_params['db_username_column'] . " FROM " . $this->_params['db_table'] . " WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "' "); if (list($username) = mysql_fetch_row($qid)) { return $username; } else { return false; } } /** * Returns a user_id for a specified username. * * @param string $username Username to look for. * @return string User_id, or false if none found. */ public function getUserID($username) { $db =& DB::getInstance(); $this->initDB(); $qid = $db->query(" SELECT " . $this->_params['db_primary_key'] . " FROM " . $this->_params['db_table'] . " WHERE " . $this->_params['db_username_column'] . " = '" . $db->escapeString($username) . "' "); if (list($user_id) = mysql_fetch_row($qid)) { return $user_id; } else { return false; } } /* * Generate a cryptographically secure, random password. * * @access public * @param int $bytes Length of password (in bytes) * @return string Random string of characters. * @author Quinn Comendant * @version 1.0 * @since 15 Nov 2014 20:30:27 */ public function generatePassword($bytes=10) { $app =& App::getInstance(); $bytes = is_numeric($bytes) ? $bytes : 10; $string = strtok(base64_encode(openssl_random_pseudo_bytes($bytes, $strong)), '='); if (!$strong) { $app->logMsg(sprintf('Password generated was not "cryptographically strong"; check your openssl.', null), LOG_NOTICE, __FILE__, __LINE__); } return $string; } /** * */ public function encryptPassword($password, $salt=null, $hash_type=null) { $app =& App::getInstance(); $password = (string)$password; // Existing password hashes rely on the same key/salt being used to compare hashs. // Don't change this (or the value applied to signing_key) unless you know existing hashes or signatures will not be affected! $more_salt = 'B36D18E5-3FE4-4D58-8150-F26642852B81'; $hash_type = isset($hash_type) && !empty($hash_type) ? $hash_type : $this->getParam('hash_type'); switch ($hash_type) { case self::ENCRYPT_PLAINTEXT : $encrypted_password = $password; break; case self::ENCRYPT_CRYPT : // If comparing password with an existing hashed password, provide the hashed password as the salt. $encrypted_password = isset($salt) ? crypt($password, $salt) : crypt($password); break; case self::ENCRYPT_SHA1 : $encrypted_password = sha1($password); break; case self::ENCRYPT_SHA1_HARDENED : $encrypted_password = sha1($app->getParam('signing_key') . $password . $more_salt); for ($i=0; $i < pow(2, 20); $i++) { $encrypted_password = sha1($password . $encrypted_password); } break; case self::ENCRYPT_MD5 : $encrypted_password = md5($password); break; case self::ENCRYPT_MD5_HARDENED : $encrypted_password = md5($app->getParam('signing_key') . $password . $more_salt); for ($i=0; $i < pow(2, 20); $i++) { $encrypted_password = md5($password . $encrypted_password); } break; case self::ENCRYPT_PASSWORD_BCRYPT : $encrypted_password = password_hash($password, PASSWORD_BCRYPT, array('cost' => 12)); break; case self::ENCRYPT_PASSWORD_DEFAULT : $encrypted_password = password_hash($password, PASSWORD_DEFAULT, array('cost' => 12)); break; default : $app->logMsg(sprintf('Unknown hash type: %s', $hash_type), LOG_WARNING, __FILE__, __LINE__); return false; } // In case our hashing function returns 'false' or another empty value, bail out. if ('' == trim((string)$encrypted_password)) { $app->logMsg(sprintf('Invalid password hash returned ("%s") for hash type %s; check yo crypto!', $encrypted_password, $hash_type), LOG_ALERT, __FILE__, __LINE__); return false; } return $encrypted_password; } /* * * * @access public * @param * @return * @author Quinn Comendant * @version 1.0 * @since 15 Nov 2014 21:37:28 */ public function verifyPassword($password, $encrypted_password, $hash_type=null) { $app =& App::getInstance(); $hash_type = isset($hash_type) && !empty($hash_type) ? $hash_type : $this->getParam('hash_type'); switch ($hash_type) { case self::ENCRYPT_CRYPT : return $this->encryptPassword($password, $encrypted_password, $hash_type) == $encrypted_password; case self::ENCRYPT_PLAINTEXT : case self::ENCRYPT_MD5 : case self::ENCRYPT_MD5_HARDENED : case self::ENCRYPT_SHA1 : case self::ENCRYPT_SHA1_HARDENED : return $this->encryptPassword($password, $encrypted_password, $hash_type) == $encrypted_password; case self::ENCRYPT_PASSWORD_BCRYPT : case self::ENCRYPT_PASSWORD_DEFAULT : return password_verify($password, $encrypted_password); default : $app->logMsg(sprintf('Unknown hash type: %s', $hash_type), LOG_WARNING, __FILE__, __LINE__); return false; } } /** * */ public function setPassword($user_id, $password, $hash_type=null) { $app =& App::getInstance(); $db =& DB::getInstance(); $this->initDB(); // Get user_id if specified. $user_id = isset($user_id) ? $user_id : $this->get('user_id'); // New hash type. $hash_type = isset($hash_type) ? $hash_type : $this->getParam('hash_type'); // Save the hash method used if a table exists for it. $userpass_hashtype_clause = ''; if ($db->columnExists($this->_params['db_table'], 'userpass_hashtype', false)) { $userpass_hashtype_clause = ", userpass_hashtype = '" . $db->escapeString($hash_type) . "'"; } // Issue the password change query. $db->query(" UPDATE " . $this->_params['db_table'] . " SET userpass = '" . $db->escapeString($this->encryptPassword($password, null, $hash_type)) . "', modified_datetime = NOW(), modified_by_user_id = '" . $db->escapeString($user_id) . "' $userpass_hashtype_clause WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "' "); if (mysql_affected_rows($db->getDBH()) != 1) { $app->logMsg(sprintf('Failed to update password for user_id %s (no affected rows)', $user_id), LOG_WARNING, __FILE__, __LINE__); return false; } $app->logMsg(sprintf('Password change successful for user_id %s', $user_id), LOG_INFO, __FILE__, __LINE__); return true; } /** * Resets the password for the user with the specified id. * * @param string $user_id The id of the user to reset. * @param string $reason Additional message to add to the reset email. * @return string The user's new password. */ public function resetPassword($user_id=null, $reason='') { $app =& App::getInstance(); $db =& DB::getInstance(); $this->initDB(); // Get user_id if specified. $user_id = isset($user_id) ? $user_id : $this->get('user_id'); // Reset password of a specific user. $qid = $db->query(" SELECT * FROM " . $this->_params['db_table'] . " WHERE " . $this->_params['db_primary_key'] . " = '" . $db->escapeString($user_id) . "' "); if (!$user_data = mysql_fetch_assoc($qid)) { $app->logMsg(sprintf('Reset password failed. User_id %s not found.', $user_id), LOG_NOTICE, __FILE__, __LINE__); return false; } // Get new password. $password = $this->generatePassword(); // Update password query. $this->setPassword($user_id, $password); // Make sure user has an email on record before continuing. if (!isset($user_data['email']) || '' == trim($user_data['email'])) { $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__); } else { // Send the new password in an email. $email = new Email(array( 'to' => $user_data['email'], 'from' => sprintf('"%s" <%s>', addcslashes($app->getParam('site_name'), '"'), $app->getParam('site_email')), 'subject' => sprintf('%s password change', $app->getParam('site_name')) )); $email->setTemplate('codebase/services/templates/email_reset_password.txt'); $email->replace(array( 'SITE_NAME' => $app->getParam('site_name'), 'SITE_URL' => $app->getParam('site_url'), 'SITE_EMAIL' => $app->getParam('site_email'), 'NAME' => ('' != $user_data['first_name'] . $user_data['last_name'] ? $user_data['first_name'] . ' ' . $user_data['last_name'] : $user_data[$this->_params['db_username_column']]), 'USERNAME' => $user_data[$this->_params['db_username_column']], 'PASSWORD' => $password, 'REASON' => ('' == trim($reason) ? '' : trim($reason) . ' '), // Add a space after the reason if it exists for better formatting. )); $email->send(); } return array( 'username' => $user_data[$this->_params['db_username_column']], 'userpass' => $password ); } } // end class