diff --git a/core/user.class.php b/core/user.class.php index a9e86b5d..d9b9c45d 100644 --- a/core/user.class.php +++ b/core/user.class.php @@ -1,4 +1,6 @@ get_row($database->scoreql_to_sql("SELECT * FROM users WHERE SCORE_STRNORM(name) = SCORE_STRNORM(:name) AND pass = :hash"), array("name"=>$name, "hash"=>$hash)); - return is_null($row) ? null : new User($row); + assert(is_string($pass)); + $user = User::by_name($name); + if($user) { + if($user->passhash == md5(strtolower($name) . $pass)) { + $user->set_password($pass); + } + if(password_verify($pass, $user->passhash)) { + return $user; + } + } } /** @@ -193,8 +200,8 @@ class User { */ public function set_password(/*string*/ $password) { global $database; - $hash = md5(strtolower($this->name) . $password); - $database->Execute("UPDATE users SET pass=:hash WHERE id=:id", array("hash"=>$hash, "id"=>$this->id)); + $this->passhash = password_hash($password, PASSWORD_BCRYPT); + $database->Execute("UPDATE users SET pass=:hash WHERE id=:id", array("hash"=>$this->passhash, "id"=>$this->id)); log_info("core-user", 'Set password for '.$this->name); } @@ -233,7 +240,7 @@ class User { * Get an auth token to be used in POST forms * * password = secret, avoid storing directly - * passhash = md5(password), so someone who gets to the database can't get passwords + * passhash = bcrypt(password), so someone who gets to the database can't get passwords * sesskey = md5(passhash . IP), so if it gets sniffed it can't be used from another IP, * and it can't be used to get the passhash to generate new sesskeys * authtok = md5(sesskey, salt), presented to the user in web forms, to make sure that diff --git a/ext/danbooru_api/main.php b/ext/danbooru_api/main.php index af254ec7..d1710660 100644 --- a/ext/danbooru_api/main.php +++ b/ext/danbooru_api/main.php @@ -420,8 +420,7 @@ class DanbooruApi extends Extension { // Code borrowed from /ext/user $name = $_REQUEST['login']; $pass = $_REQUEST['password']; - $hash = md5( strtolower($name) . $pass ); - $duser = User::by_name_and_hash($name, $hash); + $duser = User::by_name_and_pass($name, $pass); if(!is_null($duser)) { $user = $duser; } else diff --git a/ext/upgrade/main.php b/ext/upgrade/main.php index 477452cb..7322eb33 100644 --- a/ext/upgrade/main.php +++ b/ext/upgrade/main.php @@ -91,6 +91,22 @@ class Upgrade extends Extension { log_info("upgrade", "Database at version 12"); $config->set_bool("in_upgrade", false); } + + if($config->get_int("db_version") < 13) { + $config->set_bool("in_upgrade", true); + $config->set_int("db_version", 13); + + log_info("upgrade", "Changing password column to VARCHAR(250)"); + if($database->get_driver_name() == 'pgsql') { + $database->execute("ALTER TABLE users ALTER COLUMN pass SET DATA TYPE VARCHAR(250)"); + } + else if($database->get_driver_name() == 'mysql') { + $database->execute("ALTER TABLE users CHANGE pass pass VARCHAR(250)"); + } + + log_info("upgrade", "Database at version 13"); + $config->set_bool("in_upgrade", false); + } } public function get_priority() {return 5;} diff --git a/ext/user/main.php b/ext/user/main.php index e8d425f0..e3d124cd 100644 --- a/ext/user/main.php +++ b/ext/user/main.php @@ -392,18 +392,17 @@ class UserPage extends Extension { * @param Page $page */ private function login(Page $page) { - global $user, $config; + global $config, $user; $name = $_POST['user']; $pass = $_POST['pass']; - $hash = md5(strtolower($name) . $pass); if(empty($name) || empty($pass)) { $this->theme->display_error(400, "Error", "Username or password left blank"); return; } - $duser = User::by_name_and_hash($name, $hash); + $duser = User::by_name_and_pass($name, $pass); if(!is_null($duser)) { $user = $duser; $this->set_login_cookie($duser->name, $pass); @@ -421,7 +420,7 @@ class UserPage extends Extension { } } else { - log_warning("user", "Failed to log in as ".html_escape($name)." [$hash]"); + log_warning("user", "Failed to log in as ".html_escape($name)); $this->theme->display_error(401, "Error", "No user with those details was found"); } } @@ -455,7 +454,6 @@ class UserPage extends Extension { { global $database, $user; - $hash = md5(strtolower($event->username) . $event->password); $email = (!empty($event->email)) ? $event->email : null; // if there are currently no admins, the new user should be one @@ -464,9 +462,10 @@ class UserPage extends Extension { $database->Execute( "INSERT INTO users (name, pass, joindate, email, class) VALUES (:username, :hash, now(), :email, :class)", - array("username"=>$event->username, "hash"=>$hash, "email"=>$email, "class"=>$class)); + array("username"=>$event->username, "hash"=>'', "email"=>$email, "class"=>$class)); $uid = $database->get_last_insert_id('users_id_seq'); $user = User::by_name($event->username); + $user->set_password($event->password); log_info("user", "Created User #$uid ({$event->username})"); } @@ -478,7 +477,7 @@ class UserPage extends Extension { global $config; $addr = get_session_ip($config); - $hash = md5(strtolower($name) . $pass); + $hash = User::by_name($name)->passhash; set_prefixed_cookie("user", $name, time()+60*60*24*365, '/'); diff --git a/install.php b/install.php index 01fcd1e4..168e71c9 100644 --- a/install.php +++ b/install.php @@ -291,7 +291,7 @@ EOD; $db->create_table("users", " id SCORE_AIPK, name VARCHAR(32) UNIQUE NOT NULL, - pass CHAR(32), + pass VARCHAR(250), joindate SCORE_DATETIME NOT NULL DEFAULT SCORE_NOW, class VARCHAR(32) NOT NULL DEFAULT 'user', email VARCHAR(128) diff --git a/lib/password.php b/lib/password.php new file mode 100644 index 00000000..e8ec0288 --- /dev/null +++ b/lib/password.php @@ -0,0 +1,279 @@ + + * @license http://www.opensource.org/licenses/mit-license.html MIT License + * @copyright 2012 The Authors + */ + +namespace { + +if (!defined('PASSWORD_DEFAULT')) { + + define('PASSWORD_BCRYPT', 1); + define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); + + /** + * Hash the password using the specified algorithm + * + * @param string $password The password to hash + * @param int $algo The algorithm to use (Defined by PASSWORD_* constants) + * @param array $options The options for the algorithm to use + * + * @return string|false The hashed password, or false on error. + */ + function password_hash($password, $algo, array $options = array()) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING); + return null; + } + if (!is_string($password)) { + trigger_error("password_hash(): Password must be a string", E_USER_WARNING); + return null; + } + if (!is_int($algo)) { + trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING); + return null; + } + $resultLength = 0; + switch ($algo) { + case PASSWORD_BCRYPT: + // Note that this is a C constant, but not exposed to PHP, so we don't define it here. + $cost = 10; + if (isset($options['cost'])) { + $cost = $options['cost']; + if ($cost < 4 || $cost > 31) { + trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING); + return null; + } + } + // The length of salt to generate + $raw_salt_len = 16; + // The length required in the final serialization + $required_salt_len = 22; + $hash_format = sprintf("$2y$%02d$", $cost); + // The expected length of the final crypt() output + $resultLength = 60; + break; + default: + trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING); + return null; + } + $salt_requires_encoding = false; + if (isset($options['salt'])) { + switch (gettype($options['salt'])) { + case 'NULL': + case 'boolean': + case 'integer': + case 'double': + case 'string': + $salt = (string) $options['salt']; + break; + case 'object': + if (method_exists($options['salt'], '__tostring')) { + $salt = (string) $options['salt']; + break; + } + case 'array': + case 'resource': + default: + trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); + return null; + } + if (PasswordCompat\binary\_strlen($salt) < $required_salt_len) { + trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", PasswordCompat\binary\_strlen($salt), $required_salt_len), E_USER_WARNING); + return null; + } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { + $salt_requires_encoding = true; + } + } else { + $buffer = ''; + $buffer_valid = false; + if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { + $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM); + if ($buffer) { + $buffer_valid = true; + } + } + if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { + $buffer = openssl_random_pseudo_bytes($raw_salt_len); + if ($buffer) { + $buffer_valid = true; + } + } + if (!$buffer_valid && @is_readable('/dev/urandom')) { + $f = fopen('/dev/urandom', 'r'); + $read = PasswordCompat\binary\_strlen($buffer); + while ($read < $raw_salt_len) { + $buffer .= fread($f, $raw_salt_len - $read); + $read = PasswordCompat\binary\_strlen($buffer); + } + fclose($f); + if ($read >= $raw_salt_len) { + $buffer_valid = true; + } + } + if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) { + $bl = PasswordCompat\binary\_strlen($buffer); + for ($i = 0; $i < $raw_salt_len; $i++) { + if ($i < $bl) { + $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); + } else { + $buffer .= chr(mt_rand(0, 255)); + } + } + } + $salt = $buffer; + $salt_requires_encoding = true; + } + if ($salt_requires_encoding) { + // encode string with the Base64 variant used by crypt + $base64_digits = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + $bcrypt64_digits = + './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + $base64_string = base64_encode($salt); + $salt = strtr(rtrim($base64_string, '='), $base64_digits, $bcrypt64_digits); + } + $salt = PasswordCompat\binary\_substr($salt, 0, $required_salt_len); + + $hash = $hash_format . $salt; + + $ret = crypt($password, $hash); + + if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != $resultLength) { + return false; + } + + return $ret; + } + + /** + * Get information about the password hash. Returns an array of the information + * that was used to generate the password hash. + * + * array( + * 'algo' => 1, + * 'algoName' => 'bcrypt', + * 'options' => array( + * 'cost' => 10, + * ), + * ) + * + * @param string $hash The password hash to extract info from + * + * @return array The array of information about the hash. + */ + function password_get_info($hash) { + $return = array( + 'algo' => 0, + 'algoName' => 'unknown', + 'options' => array(), + ); + if (PasswordCompat\binary\_substr($hash, 0, 4) == '$2y$' && PasswordCompat\binary\_strlen($hash) == 60) { + $return['algo'] = PASSWORD_BCRYPT; + $return['algoName'] = 'bcrypt'; + list($cost) = sscanf($hash, "$2y$%d$"); + $return['options']['cost'] = $cost; + } + return $return; + } + + /** + * Determine if the password hash needs to be rehashed according to the options provided + * + * If the answer is true, after validating the password using password_verify, rehash it. + * + * @param string $hash The hash to test + * @param int $algo The algorithm used for new password hashes + * @param array $options The options array passed to password_hash + * + * @return boolean True if the password needs to be rehashed. + */ + function password_needs_rehash($hash, $algo, array $options = array()) { + $info = password_get_info($hash); + if ($info['algo'] != $algo) { + return true; + } + switch ($algo) { + case PASSWORD_BCRYPT: + $cost = isset($options['cost']) ? $options['cost'] : 10; + if ($cost != $info['options']['cost']) { + return true; + } + break; + } + return false; + } + + /** + * Verify a password against a hash using a timing attack resistant approach + * + * @param string $password The password to verify + * @param string $hash The hash to verify against + * + * @return boolean If the password matches the hash + */ + function password_verify($password, $hash) { + if (!function_exists('crypt')) { + trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); + return false; + } + $ret = crypt($password, $hash); + if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != PasswordCompat\binary\_strlen($hash) || PasswordCompat\binary\_strlen($ret) <= 13) { + return false; + } + + $status = 0; + for ($i = 0; $i < PasswordCompat\binary\_strlen($ret); $i++) { + $status |= (ord($ret[$i]) ^ ord($hash[$i])); + } + + return $status === 0; + } +} + +} + +namespace PasswordCompat\binary { + /** + * Count the number of bytes in a string + * + * We cannot simply use strlen() for this, because it might be overwritten by the mbstring extension. + * In this case, strlen() will count the number of *characters* based on the internal encoding. A + * sequence of bytes might be regarded as a single multibyte character. + * + * @param string $binary_string The input string + * + * @internal + * @return int The number of bytes + */ + function _strlen($binary_string) { + if (function_exists('mb_strlen')) { + return mb_strlen($binary_string, '8bit'); + } + return strlen($binary_string); + } + + /** + * Get a substring based on byte limits + * + * @see _strlen() + * + * @param string $binary_string The input string + * @param int $start + * @param int $length + * + * @internal + * @return string The substring + */ + function _substr($binary_string, $start, $length) { + if (function_exists('mb_substr')) { + return mb_substr($binary_string, $start, $length, '8bit'); + } + return substr($binary_string, $start, $length); + } + +}