From 15f391c463f08014a2af7b80920c4832877ad09f Mon Sep 17 00:00:00 2001 From: Jason Woofenden Date: Tue, 23 Jun 2015 10:58:45 -0400 Subject: [PATCH] add nice admin_users --- admin_users.html | 84 ++++++++++ admin_users.php | 189 ++++++++++++++++++++++ admin_users.sql | 10 ++ config.php | 1 + inc/misc.php | 6 + inc/password_funcs_backported.php | 314 +++++++++++++++++++++++++++++++++++++ styl.styl | 21 ++- template.html | 35 +++++ 8 files changed, 655 insertions(+), 5 deletions(-) create mode 100644 admin_users.html create mode 100644 admin_users.php create mode 100644 admin_users.sql create mode 100644 inc/misc.php create mode 100644 inc/password_funcs_backported.php diff --git a/admin_users.html b/admin_users.html new file mode 100644 index 0000000..731ae37 --- /dev/null +++ b/admin_users.html @@ -0,0 +1,84 @@ + + + + + + <!--~$title show {~-->Accounts<!--~}~--> + + + + + + + +

Add a new accountEdit account "~name html~"

+ +
+ +
Role
+
+ +
Name (optional)
+
+ +
Username
+
This is used to log in. It is not case sensitive, and symbols/spaces/etc are ignored.
+
+ +
Password
+
If this is left blank, the user will be unable to log in.
+
Leave this blank to leave teh password unchanged.
+
Password suggestions: ~password_suggestions {~~password_suggestions html~~ sep {~ ~}~~}~
+
+
+ +
+ +
+
+ +
+ +
 
+
Cancel
+ + + +

Accounts Listing

+ +

On this page you can manage who can log into this site, and what sort of things they have permission to do once logged in.

+ + +

[Add a new account]

+ + + + + + + + + + + + + + + + + + + +
RoleNameUsernameLast LoginLast Active 
~role html~(blank)~name html~(blank)~username html~(blank)~last_login html~~last_active html~[delete this account]
+

Download as CSV file

+ + +

No accounts in database.

+ + +

[Add a new account]

+ + + + + diff --git a/admin_users.php b/admin_users.php new file mode 100644 index 0000000..ba3a46c --- /dev/null +++ b/admin_users.php @@ -0,0 +1,189 @@ + 'Name', + 'role' => 'Role', + 'username' => 'Username', + 'password' => 'Password', + 'last_login' => 'Last Login', + 'last_active' => 'Last Active' +); + +function admin_users_get_fields() { + $data = array(); + + $data['role'] = format_options(_REQUEST_cut('role'), 'role'); + $data['name'] = format_oneline(trim(_REQUEST_cut('name'))); + $data['username'] = format_oneline(trim(_REQUEST_cut('username'))); + $data['pass1'] = format_oneline(trim(_REQUEST_cut('pass1'))); + $data['pass2'] = format_oneline(trim(_REQUEST_cut('pass2'))); + + return $data; +} + + +function admin_users_main() { + session_auth_must('admin_users'); + + $id = _REQUEST_cut('edit_id'); + if ($id) { + return admin_users_main_form($id); + } + + $id = _REQUEST_cut('admin_users_delete_id'); + if ($id) { + return admin_users_main_delete($id); + } + + if (_REQUEST_cut('new')) { + return admin_users_main_form(); + } + + if (_REQUEST_cut('list')) { + return admin_users_main_listing(); + } + + if (_REQUEST_cut('download_csv')) { + return admin_users_csv_download(); + } + + if (isset($_POST['name'])) { + return admin_users_main_form(); + } + + # default action: + return admin_users_main_listing(); +} + +function admin_users_main_delete($id) { + db_delete('users', 'where id=%i', $id); + message('Account deleted.'); + return './admin_users'; +} + +function admin_users_csv_download() { + require_once(__DIR__.'/'.'inc/wfpl/csv.php'); + $rows = db_get_rows('users', 'id,'.ADMIN_USERS_DB_FIELDS, 'order by id'); + $fields = explode(',', 'id,'.ADMIN_USERS_DB_FIELDS); + $header = array(); + foreach ($fields as $field) { + if (isset($GLOBALS['admin_users_field_to_caption'][$field])) { + $header[] = $GLOBALS['admin_users_field_to_caption'][$field]; + } else { + $header[] = $field; + } + } + array_unshift($rows, $header); + array2d_to_csv_download($rows, 'admin_users.csv'); +} + +function admin_users_main_listing() { + $data = array(); + $desc = ''; + $sort = _REQUEST_cut('sort'); + if ($sort && substr($sort, 0, 1) === '-') { + $sort = substr($sort, 1); + $desc = ' DESC '; + } else { + $data["sorting-by-$sort"] = '-'; + } + $legal_sorts = explode(',', ADMIN_USERS_DB_FIELDS); + if (!$sort || !in_array($sort, $legal_sorts)) { + $sort = 'role, name'; + } + + $data['rows'] = db_get_assocs('users', 'id,role,name,username,last_login,last_active', "order by $sort $desc limit 1000"); + tem_set('listings', $data); + render_timestamps(); +} + +function admin_users_suggested_password() { + $character_set = "ABCDEFHJKLMNPQRTUWXY34789"; # removed all similar-looking characters + $code = " "; + + # PHP 4.2.0 and up seed the random number generator for you. + # Lets hope that it seeds with something harder to guess than the clock. + for($i = 0; $i < 10; ++$i) { + $code{$i} = $character_set{mt_rand(0, 24)}; # inclusive + } + + return $code; +} + +function admin_users_main_form($id = false) { + if ($id) { + tem_set('id', $id); + } + + pulldown('role', [ + ['admin', 'Site Administrator'], + ['disabled', 'Account Disabled'] + ]); + + if (isset($_POST['name'])) { + $data = admin_users_get_fields(); + + if (strlen($data['username']) < 1) { + message("Oop, Username is required"); + $data['username_bad'] = true; + } elseif ($data['pass1'] !== $data['pass2']) { + message("Oop, passwords didn't match. Please enter your desired password carefully (twice)."); + $data['password_bad'] = true; + } else { + # password hash is slow, so only do it if we're really doing a db write + if (isset($data['pass1']) && strlen($data['pass1']) > 0) { + # hash password for db storage + if (!function_exists('password_hash')) { + require_once(DOCROOT . 'inc/password_funcs_backported.php'); + } + $data['password'] = password_hash($data['pass1'], PASSWORD_DEFAULT); + } + unset($data['pass1']); + unset($data['pass2']); + if ($id) { + db_update_assoc('users', $data, 'where id=%i', $id); + message('Account updated.'); + } else { + db_insert_assoc('users', $data); + message('Account saved.'); + } + return './admin_users'; + } + # else fall through to display the form again. Field values are in $data + } elseif ($id) { + # we've recieved an edit id, but no data. So we grab the values to be edited from the database + $data = db_get_assoc('users', ADMIN_USERS_DB_FIELDS, 'where id=%i', $id); + } else { + # form not submitted, you can set default values like so: + #$data = array('name' => 'Yes'); + $data = array(); + } + + tem_set('password_suggestions', [ + admin_users_suggested_password(), + admin_users_suggested_password(), + admin_users_suggested_password(), + admin_users_suggested_password(), + admin_users_suggested_password() + ]); + tem_set('form', $data); +} diff --git a/admin_users.sql b/admin_users.sql new file mode 100644 index 0000000..08f0071 --- /dev/null +++ b/admin_users.sql @@ -0,0 +1,10 @@ +drop table if exists users; +create table users ( + id int unique auto_increment, + name varchar(200) binary not null default "", + username varchar(200) binary not null default "", + password varchar(255) binary not null default "", + role varchar(200) binary not null default "", + last_active int(11) not null default 0, + last_login int(11) not null default 0 +) CHARSET=utf8; diff --git a/config.php b/config.php index a1506ec..994bfb5 100644 --- a/config.php +++ b/config.php @@ -15,6 +15,7 @@ require_once(DOCROOT . 'inc/wfpl/db.php'); require_once(DOCROOT . 'inc/wfpl/session_messages.php'); require_once(DOCROOT . 'inc/session_auth.php'); require_once(DOCROOT . 'inc/cms.php'); +require_once(DOCROOT . 'inc/misc.php'); # Connect to the database db_connect(WFPL_DB, WFPL_DB_USER, WFPL_DB_PASS); diff --git a/inc/misc.php b/inc/misc.php new file mode 100644 index 0000000..429482d --- /dev/null +++ b/inc/misc.php @@ -0,0 +1,6 @@ +set('$render_timestamps'); +} diff --git a/inc/password_funcs_backported.php b/inc/password_funcs_backported.php new file mode 100644 index 0000000..b68b1c1 --- /dev/null +++ b/inc/password_funcs_backported.php @@ -0,0 +1,314 @@ + + * @license http://www.opensource.org/licenses/mit-license.html MIT License + * @copyright 2012 The Authors + */ + +namespace { + + if (!defined('PASSWORD_BCRYPT')) { + /** + * PHPUnit Process isolation caches constants, but not function declarations. + * So we need to check if the constants are defined separately from + * the functions to enable supporting process isolation in userland + * code. + */ + define('PASSWORD_BCRYPT', 1); + define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); + define('PASSWORD_BCRYPT_DEFAULT_COST', 10); + } + + if (!function_exists('password_hash')) { + + /** + * 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_null($password) || is_int($password)) { + $password = (string) $password; + } + 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: + $cost = PASSWORD_BCRYPT_DEFAULT_COST; + 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_req_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_req_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')) { + $file = fopen('/dev/urandom', 'r'); + $read = PasswordCompat\binary\_strlen($buffer); + while ($read < $raw_salt_len) { + $buffer .= fread($file, $raw_salt_len - $read); + $read = PasswordCompat\binary\_strlen($buffer); + } + fclose($file); + if ($read >= $raw_salt_len) { + $buffer_valid = true; + } + } + if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) { + $buffer_length = PasswordCompat\binary\_strlen($buffer); + for ($i = 0; $i < $raw_salt_len; $i++) { + if ($i < $buffer_length) { + $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); + } else { + $buffer .= chr(mt_rand(0, 255)); + } + } + } + $salt = $buffer; + $salt_req_encoding = true; + } + if ($salt_req_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' => PASSWORD_BCRYPT_DEFAULT_COST, + * ), + * ) + * + * @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'] : PASSWORD_BCRYPT_DEFAULT_COST; + 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 { + + if (!function_exists('PasswordCompat\\binary\\_strlen')) { + + /** + * 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); + } + + /** + * Check if current PHP version is compatible with the library + * + * @return boolean the check result + */ + function check() { + static $pass = NULL; + + if (is_null($pass)) { + if (function_exists('crypt')) { + $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'; + $test = crypt("password", $hash); + $pass = $test == $hash; + } else { + $pass = false; + } + } + return $pass; + } + + } +} diff --git a/styl.styl b/styl.styl index 051415d..54ac463 100644 --- a/styl.styl +++ b/styl.styl @@ -1,4 +1,4 @@ -@require 'inc/wfpl/stylus-helpers.styl' +@require 'inc/wfpl/stylus_helpers.styl' @require '.sha1sums.styl' // dimensions @@ -159,16 +159,16 @@ body nav#site-nav padding-bottom: 20px ul - li-reset() - space-evenly() + li_reset() + space_evenly() footer clear: both padding-top: 40px ul - li-reset() + li_reset() li - li-reset() + li_reset() display: inline-block margin-right: 10px @@ -258,6 +258,9 @@ div > :last-child, margin-bottom: 0px table.evenodd + td, th + padding: 6px 12px + text-align: left > thead, > tbody, & > tr:nth-child(2n+1) > td, > th @@ -271,3 +274,11 @@ table.evenodd &:hover > td, > th background: rgba(0,0,0,0.09) + +.field_error + input + border: 1px solid red + +.password_suggestion + & + & + margin-left: 10px diff --git a/template.html b/template.html index 442685d..89c3cb6 100644 --- a/template.html +++ b/template.html @@ -48,5 +48,40 @@ footer text here + + + -- 1.7.10.4