CVE-2015-8033: Weak Password Requirements (CWE-521)
value: In Textpattern 4.5.7, the password-reset feature does not securely tether a hash to a user account.
CWE description: The product does not require that users should have strong passwords, which makes it easier for attackers to compromise user accounts.
CVSS v3 base score: 5.3
Published: 2020-08-14T19:15Z
Repository: https://github.com/textpattern/textpattern
Language: PHP
Files changed: textpattern/include/txp_admin.php, textpattern/include/txp_auth.php, textpattern/lib/constants.php, textpattern/lib/txplib_admin.php, textpattern/textpattern.js, textpattern/update/_to_4.6.0.php
Commit date: 2015-11-05 21:56:00 +0000
Commit message: Security improvements part 1. See Issue #565

* No more emailing plaintext passwords out: password changes and reset requests are dealt with immediately on the login/admin panels.
* Stronger cryptographic hashes for confirmation links that don't leak user ID information.
* Hashes are securely tethered to the user account and auto-expire when logging in or generating a new request. Timeout window of 20 minutes by default for answering reset requests.
* Password input boxes are unmaskable. Saves having a separate confirmation box, simplifies code logic and is better UX.
* Dedicated txp_token table introduced for handling token-based storage and retrieval. Better for plugin authors than abusing txp_discuss_nonce.

More on the way including strength meter and improvements to setup passwords...

Diff: diff --git a/textpattern/include/txp_admin.php b/textpattern/include/txp_admin.php
index 49f985d52..bd04d512e 100644
--- a/textpattern/include/txp_admin.php
+++ b/textpattern/include/txp_admin.php
@@ -130,33 +130,23 @@ function author_save()
 
 function change_pass()
 {
     global $txp_user;
 
-    extract(psa(array('new_pass', 'mail_password')));
+    extract(psa(array('new_pass')));
 
     if (empty($new_pass)) {
         author_list(array(gTxt('password_required'), E_ERROR));
 
         return;
     }
 
     $rs = change_user_password($txp_user, $new_pass);
 
     if ($rs) {
-        $message = gTxt('password_changed');
-
-        if ($mail_password) {
-            $email = fetch('email', 'txp_users', 'name', $txp_user);
-
-            send_new_password($new_pass, $email, $txp_user);
-
-            $message .= sp.gTxt('and_mailed_to').sp.$email;
-        }
-
-        $message .= '.';
-
+        // Todo: Move full stop to Textpack.
+        $message = gTxt('password_changed') . '.';
         author_list($message);
     }
 }
 
 /**
@@ -239,16 +229,16 @@ function new_pass_form()
 
     echo form(
         hed(gTxt('change_password'), 2).
         inputLabel(
             'new_pass',
-            fInput('password', 'new_pass', '', '', '', '', INPUT_REGULAR, '', 'new_pass'),
+            fInput('password', 'new_pass', '', 'txp-maskable', '', '', INPUT_REGULAR, '', 'new_pass'),
             'new_password', '', array('class' => 'txp-form-field edit-admin-new-password')
         ).
         graf(
-            checkbox('mail_password', '1', true, '', 'mail_password').
-            n.tag(gTxt('mail_it'), 'label', array('for' => 'mail_password')), array('class' => 'edit-admin-mail-password')).
+            checkbox('unmask', 1, false, 0, 'show_password').
+            n.tag(gTxt('show_password'), 'label', array('for' => 'show_password')), array('class' => 'edit-admin-show-password')).
         graf(fInput('submit', 'change_pass', gTxt('submit'), 'publish')).
         eInput('admin').
         sInput('change_pass'),
     '', '', 'post', 'txp-edit', '', 'change_password');
 }
@@ -712,24 +702,15 @@ function admin_multi_edit()
             break;
 
         case 'resetpassword':
 
             foreach ($names as $name) {
-                $passwd = generate_password(PASSWORD_LENGTH);
-
-                if (change_user_password($name, $passwd)) {
-                    $email = safe_field("email", 'txp_users', "name = '".doSlash($name)."'");
-
-                    if (send_new_password($passwd, $email, $name)) {
-                        $changed[] = $name;
-                        $msg = 'author_updated';
-                    } else {
-                        return author_list(array(gTxt('could_not_mail').' '.txpspecialchars($name), E_ERROR));
-                    }
-                }
+                send_reset_confirmation_request($name);
+                $changed[] = $name;
             }
 
+            $msg = 'password_reset_confirmation_request_sent';
             break;
     }
 
     if ($changed) {
         return author_list(gTxt($msg, array('{name}' => txpspecialchars(join(', ', $changed)))));
diff --git a/textpattern/include/txp_auth.php b/textpattern/include/txp_auth.php
index f446bc324..ef3019ea3 100644
--- a/textpattern/include/txp_auth.php
+++ b/textpattern/include/txp_auth.php
@@ -69,38 +69,63 @@ function doAuth()
 function doLoginForm($message)
 {
     global $textarray_script, $event, $step;
 
     include txpath.'/lib/txplib_head.php';
+
     $event = 'login';
 
+    $stay = (cs('txp_login') && !gps('logout') ? 1 : 0);
+    $reset = gps('reset');
+    $confirm = gps('confirm');
+
     if (gps('logout')) {
         $step = 'logout';
-    } elseif (gps('reset')) {
+    } elseif ($reset) {
         $step = 'reset';
+    } elseif ($confirm) {
+        $step = 'confirm';
     }
 
-    pagetop(gTxt('login'), $message);
-
-    $stay = (cs('txp_login') and !gps('logout') ? 1 : 0);
-    $reset = gps('reset');
-
     $name = join(',', array_slice(explode(',', cs('txp_login')), 0, -1));
     $out = array();
 
     if ($reset) {
+        $pageTitle = gTxt('password_reset');
         $out[] = hed(gTxt('password_reset'), 1, array('id' => 'txp-login-heading')).
             n.tag(
                 n.tag(gTxt('name'), 'label', array('for' => 'login_name')).
                 fInput('text', 'p_userid', $name, '', '', '', INPUT_REGULAR, '', 'login_name'),
                 'div', array('class' => 'txp-form-field login-name')).
             graf(
                 fInput('submit', '', gTxt('password_reset_button'), 'publish')).
             graf(
                 href(gTxt('back_to_login'), 'index.php'), array('class' => 'login-return')).
             hInput('p_reset', 1);
+    } elseif ($confirm) {
+        $pageTitle = gTxt('change_password');
+        $out[] = hed(gTxt('change_password'), 1, array('id' => 'txp-change-password-heading')).
+            n.tag(
+                n.tag(gTxt('new_password'), 'label', array(
+                    'class' => 'txp-form-field-label',
+                    'for' => 'change_password',
+                )).
+                fInput('password', 'p_password', '', 'txp-form-field-input txp-maskable', '', '', INPUT_REGULAR, '', 'change_password', false, true),
+                'div', array('class' => 'txp-form-field change-password')).
+            graf(
+                checkbox('unmask', 1, false, 0, 'show_password').n.
+                tag(gTxt('show_password'), 'label', array('for' => 'show_password'))
+                , array('class' => 'show-password')).
+            graf(
+                fInput('submit', '', gTxt('password_confirm_button'), 'publish').n
+            ).
+            graf(
+                href(gTxt('back_to_login'), 'index.php'), array('class' => 'login-return'));
+        $out[] = hInput('hash', gps('confirm'));
+        $out[] = hInput('p_alter', 1);
     } else {
+        $pageTitle = gTxt('login');
         $out[] = hed(gTxt('login_to_textpattern'), 1, array('id' => 'txp-login-heading')).
             n.tag(
                 n.tag(gTxt('name'), 'label', array('for' => 'login_name')).
                 fInput('text', 'p_userid', $name, '', '', '', INPUT_REGULAR, '', 'login_name'),
                 'div', array('class' => 'txp-form-field login-name')).
@@ -122,10 +147,12 @@ function doLoginForm($message)
         if (gps('event')) {
             $out[] = eInput(gps('event'));
         }
     }
 
+    pagetop($pageTitle, $message);
+
     echo form(
         join('', $out), '', '', 'post', 'txp-login', '', 'login_form').
 
     script_js('textpattern.textarray = '.json_encode($textarray_script)).
     n.'</main><!-- /txp-body -->'.n.'</body>'.n.'</html>';
@@ -134,26 +161,39 @@ function doLoginForm($message)
 }
 
 /**
  * Validates the sent login form and creates a session.
  *
+ * During the reset request procedure, it is conceivable to verify the
+ * token as soon as it is presented in the URL, but that would require:
+ *  a) very similar code in both p_confirm and p_alter branches (unless refactored)
+ *  b) some way (other than via the message) to signal back to doLoginForm() that
+ *     the token is bogus so the 'change your password' form is not displayed.
+ *     Perhaps raise an exception?
+ *
+ * @todo  Investigate validating confirm token as soon as it's presented in URL (better UX).
+ * @todo  Could this be done via a Validator()?
+ *
  * @return string A localised feedback message
  * @see    doLoginForm()
  */
 
 function doTxpValidate()
 {
     global $logout, $txp_user;
+
     $p_userid   = ps('p_userid');
     $p_password = ps('p_password');
     $p_reset    = ps('p_reset');
+    $p_alter    = ps('p_alter');
     $stay       = ps('stay');
+    $p_confirm  = gps('confirm');
     $logout     = gps('logout');
     $message    = '';
     $pub_path   = preg_replace('|//$|', '/', rhu.'/');
 
-    if (cs('txp_login') and strpos(cs('txp_login'), ',')) {
+    if (cs('txp_login') && strpos(cs('txp_login'), ',')) {
         $txp_login = explode(',', cs('txp_login'));
         $c_hash = end($txp_login);
         $c_userid = join(',', array_slice($txp_login, 0, -1));
     } else {
         $c_hash   = '';
@@ -163,11 +203,15 @@ function doTxpValidate()
     if ($logout) {
         setcookie('txp_login', '', time() - 3600);
         setcookie('txp_login_public', '', time() - 3600, $pub_path);
     }
 
-    if ($c_userid and strlen($c_hash) == 32) { // Cookie exists.
+    if ($c_userid && strlen($c_hash) === 32) {
+        // Cookie exists.
+        // @todo Improve security by using a better nonce/salt mechanism. md5 and uniqid are bad.
+        // @todo Flag cookie-based logins and force confirmation of old password when
+        // changing it from Admin->Users panel.
         $r = safe_row(
             "name, nonce",
             'txp_users',
             "name = '".doSlash($c_userid)."' AND last_access > DATE_SUB(NOW(), INTERVAL 30 DAY)"
         );
@@ -191,11 +235,12 @@ function doTxpValidate()
             txp_status_header('401 Your session has expired');
             setcookie('txp_login', $c_userid, time() + 3600 * 24 * 365);
             setcookie('txp_login_public', '', time() - 3600, $pub_path);
             $message = array(gTxt('bad_cookie'), E_ERROR);
         }
-    } elseif ($p_userid and $p_password) { // Incoming login vars.
+    } elseif ($p_userid && $p_password) {
+        // Incoming login vars.
         $name = txp_validate($p_userid, $p_password);
 
         if ($name !== false) {
             $c_hash = md5(uniqid(mt_rand(), true));
             $nonce  = md5($name.pack('H*', $c_hash));
@@ -216,11 +261,11 @@ function doTxpValidate()
                 LOGIN_COOKIE_HTTP_ONLY
             );
 
             setcookie(
                 'txp_login_public',
-                substr(md5($nonce), - 10).$name,
+                substr(md5($nonce), -10).$name,
                 ($stay ? time() + 3600 * 24 * 30 : 0),
                 $pub_path
             );
 
             // Login is good, create $txp_user.
@@ -230,29 +275,54 @@ function doTxpValidate()
         } else {
             sleep(3);
             txp_status_header('401 Could not log in with that username/password');
             $message = array(gTxt('could_not_log_in'), E_ERROR);
         }
-    } elseif ($p_reset) { // Reset request.
+    } elseif ($p_reset) {
+        // Reset request.
         sleep(3);
 
         include_once txpath.'/lib/txplib_admin.php';
 
         $message = ($p_userid) ? send_reset_confirmation_request($p_userid) : '';
-    } elseif (gps('reset')) {
-        $message = '';
-    } elseif (gps('confirm')) {
+    } elseif ($p_alter) {
+        // Password change confirmation.
         sleep(3);
+        global $sitename;
 
-        $confirm = pack('H*', gps('confirm'));
-        $name    = substr($confirm, 5);
-        $nonce   = safe_field("nonce", 'txp_users', "name = '".doSlash($name)."'");
+        $pass = ps('p_password');
 
-        if ($nonce and $confirm === pack('H*', substr(md5($nonce), 0, 10)).$name) {
-            include_once txpath.'/lib/txplib_admin.php';
-
-            $message = reset_author_pass($name);
+        if (trim($pass) === '') {
+            $message = array(gTxt('password_required'), E_ERROR);
+        } else {
+            $hash = gps('hash');
+            $selector = substr($hash, SALT_LENGTH);
+            $tokenInfo = safe_row("reference_id, token, expires", 'txp_token', "selector = '".doSlash($selector)."' AND type='password_reset'");
+
+            if ($tokenInfo) {
+                if (strtotime($tokenInfo['expires']) <= time()) {
+                    $message = array(gTxt('token_expired'), E_ERROR);
+                } else {
+                    $uid = assert_int($tokenInfo['reference_id']);
+                    $row = safe_row("name, email, nonce, pass AS old_pass", 'txp_users', "user_id = $uid");
+
+                    if ($row['nonce'] && ($hash === bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $row['nonce'].$selector.$row['old_pass']), 0, SALT_LENGTH))).$selector)) {
+                        if (change_user_password($row['name'], $pass)) {
+                            $body = gTxt('greeting').' '.$row['name'].','.n.n.gTxt('password_change_confirmation');
+                            txpMail($row['email'], "[$sitename] ".gTxt('password_changed'), $body);
+                            $message = gTxt('password_changed');
+
+                            // Invalidate all reset requests in the wild for this user.
+                            safe_delete("txp_token", "reference_id = $uid AND type = 'password_reset'");
+                        }
+                    } else {
+                        $message = array(gTxt('invalid_token'), E_ERROR);
+                    }
+                }
+            } else {
+                $message = array(gTxt('invalid_token'), E_ERROR);
+            }
         }
     }
 
     $txp_user = '';
 
diff --git a/textpattern/lib/constants.php b/textpattern/lib/constants.php
index ab557bc50..930fa130c 100644
--- a/textpattern/lib/constants.php
+++ b/textpattern/lib/constants.php
@@ -400,10 +400,57 @@ if (!defined('PASSWORD_SYMBOLS')) {
      */
 
     define('PASSWORD_SYMBOLS', '23456789abcdefghijkmnopqrstuvwxyz');
 }
 
+if (!defined('HASHING_ALGORITHM')) {
+    /**
+     * Algorithm to use for hashing passwords/reset requests.
+     *
+     * This constant can be overridden from the config.php.
+     *
+     * @package User
+     * @since   4.6.0
+     * @see     PHP's hash_algos() function
+     * @example
+     * define('HASHING_ALGORITHM', 'whirlpool');
+     */
+
+    define('HASHING_ALGORITHM', 'ripemd256');
+}
+
+if (!defined('SALT_LENGTH')) {
+    /**
+     * Length of salt/selector hashes.
+     *
+     * This constant can be overridden from the config.php.
+     *
+     * @package User
+     * @since   4.6.0
+     * @example
+     * define('SALT_LENGTH', '80');
+     */
+
+    define('SALT_LENGTH', '64');
+}
+
+if (!defined('RESET_EXPIRY_MINUTES')) {
+    /**
+     * Length of time (in minutes) that a password reset request remains valid.
+     *
+     * This constant can be overridden from the config.php.
+     * Values under 60 may fall foul of DST changeover times, but meh.
+     *
+     * @package User
+     * @since   4.6.0
+     * @example
+     * define('RESET_EXPIRY_MINUTES', '90');
+     */
+
+    define('RESET_EXPIRY_MINUTES', '20');
+}
+
 if (!defined('LOGIN_COOKIE_HTTP_ONLY')) {
     /**
      * If TRUE, login cookie is set just for HTTP.
      *
      * This constant can be overridden from the config.php.
diff --git a/textpattern/lib/txplib_admin.php b/textpattern/lib/txplib_admin.php
index 29ef44af0..1ea6e5937 100644
--- a/textpattern/lib/txplib_admin.php
+++ b/textpattern/lib/txplib_admin.php
@@ -31,16 +31,20 @@
  * Emails a new user with login details.
  *
  * This function can be only executed when the currently authenticated user
  * trying to send the email was granted 'admin.edit' privileges.
  *
- * @param  string $RealName The real name
- * @param  string $name     The login name
- * @param  string $email    The email address
- * @param  string $password The password
- * @return bool FALSE on error.
- * @see    send_new_password()
+ * Should NEVER be used as sending plaintext passwords is wrong.
+ * Will be removed in future, in lieu of sending reset request tokens.
+ *
+ * @param      string $RealName The real name
+ * @param      string $name     The login name
+ * @param      string $email    The email address
+ * @param      string $password The password
+ * @return     bool FALSE on error.
+ * @deprecated in 4.6.0
+ * @see        send_new_password(), send_reset_confirmation_request
  * @example
  * if (send_password('John Doe', 'login', 'example@example.tld', 'password'))
  * {
  *     echo "Login details sent.";
  * }
@@ -68,16 +72,20 @@ function send_password($RealName, $name, $email, $password)
  * Sends a new password to an existing user.
  *
  * If the $name is FALSE, the password is sent to the currently
  * authenticated user.
  *
- * @param  string $password The new password
- * @param  string $email    The email address
- * @param  string $name     The login name
- * @return bool FALSE on error.
- * @see    send_password()
- * @see    reset_author_pass()
+ * Should NEVER be used as sending plaintext passwords is wrong.
+ * Will be removed in future, in lieu of sending reset request tokens.
+ *
+ * @param      string $password The new password
+ * @param      string $email    The email address
+ * @param      string $name     The login name
+ * @return     bool FALSE on error.
+ * @deprecated in 4.6.0
+ * @see        send_reset_confirmation_request
+ * @see        reset_author_pass()
  * @example
  * $pass = generate_password();
  * if (send_new_password($pass, 'example@example.tld', 'user'))
  * {
  *     echo "Password was sent to 'user'.";
@@ -103,13 +111,13 @@ function send_new_password($password, $email, $name)
 
 /**
  * Sends a password reset link to a user's email address.
  *
  * This function will return a success message even when the specified user
- * doesn't exist. Though an error message could be thrown when user isn't found,
- * this is done due to security. This prevents the function from leaking
- * existing account names.
+ * doesn't exist. Though an error message could be thrown when a user isn't
+ * found, this is done due to security, which prevents the function from
+ * leaking existing account names.
  *
  * @param  string $name The login name
  * @return string A localized message string
  * @see    send_new_password()
  * @see    reset_author_pass()
@@ -119,44 +127,73 @@ function send_new_password($password, $email, $name)
 
 function send_reset_confirmation_request($name)
 {
     global $sitename;
 
-    $rs = safe_row("email, nonce", 'txp_users', "name = '".doSlash($name)."'");
+    $rs = safe_row("user_id, email, nonce, pass", 'txp_users', "name = '".doSlash($name)."'");
 
     if ($rs) {
         extract($rs);
 
-        $confirm = bin2hex(pack('H*', substr(md5($nonce), 0, 10)).$name);
+        $uid = assert_int($user_id);
+
+        // The selector becomes an indirect reference to the txp_users row,
+        // which does not leak information.
+        $selector = Txp::get('\Textpattern\Password\Random')->generate(12);
+        $expiry = strftime('%Y-%m-%d %H:%M:%S', time() + (60 * RESET_EXPIRY_MINUTES));
+
+        // Use a hash of the nonce, selector and password.
+        // This ensures that confirmation requests expire automatically when:
+        //  a) The person next logs in, or
+        //  b) They successfully change their password (usually as a result of this reset request)
+        // Using the selector in the hash just injects randomness, otherwise two requests
+        // back-to-back would generate the same confirmation code.
+        // Old requests for the same user id are purged every time a new request is made.
+        $token = bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $nonce . $selector . $pass), 0, SALT_LENGTH)));
+        $confirm = $token.$selector;
+
+        // Remove any previous reset tokens and insert the new one.
+        safe_delete("txp_token", "reference_id = $uid AND type = 'password_reset'");
+        safe_insert("txp_token",
+                "reference_id = $uid,
+                type = 'password_reset',
+                selector = '".doSlash($selector)."',
+                token = '".doSlash($token)."',
+                expires = '".doSlash($expiry)."'
+            ");
 
         $message = gTxt('greeting').' '.$name.','.
-
-            n.n.gTxt('password_reset_confirmation').': '.
+            n.n.gTxt('password_reset_confirmation').
             n.hu.'textpattern/index.php?confirm='.$confirm;
-
         if (txpMail($email, "[$sitename] ".gTxt('password_reset_confirmation_request'), $message)) {
             return gTxt('password_reset_confirmation_request_sent');
         } else {
             return array(gTxt('could_not_mail'), E_ERROR);
         }
     } else {
         // Though 'unknown_author' could be thrown, send generic 'request_sent'
         // message instead so that (non-)existence of account names are not leaked.
+        // There's a possibility of a timing attack revealing the existence of
+        // an account, which we could defend against to some degree.
         return gTxt('password_reset_confirmation_request_sent');
     }
 }
 
 /**
  * Generates a password.
  *
  * Generates a random password of given length using the symbols set in
  * PASSWORD_SYMBOLS constant.
  *
- * @param  int $length The length of the password
- * @return string Random plain-text password
- * @see    PASSWORD_SYMBOLS
- * @see    PASSWORD_LENGTH
+ * Should NEVER be used as it is not cryptographically secure.
+ * Will be removed in future, in lieu of sending reset request tokens.
+ *
+ * @param      int $length The length of the password
+ * @return     string Random plain-text password
+ * @deprecated in 4.6.0
+ * @see        \Textpattern\Password\Generate
+ * @see        \Textpattern\Password\Random
  * @example
  * echo generate_password(128);
  */
 
 function generate_password($length = 10)
@@ -185,14 +222,18 @@ function generate_password($length = 10)
 }
 
 /**
  * Resets the given user's password and emails it.
  *
- * The old password replaced with a new random-generated one.
+ * The old password is replaced with a new random-generated one.
+ *
+ * Should NEVER be used as sending plaintext passwords is wrong.
+ * Will be removed in future, in lieu of sending reset request tokens.
  *
  * @param  string $name The login name
  * @return string A localized message string
+ * @deprecated in 4.6.0
  * @see    PASSWORD_LENGTH
  * @see    generate_password()
  * @example
  * echo reset_author_pass('username');
  */
diff --git a/textpattern/textpattern.js b/textpattern/textpattern.js
index 8c1afcf27..5a70c89ef 100644
--- a/textpattern/textpattern.js
+++ b/textpattern/textpattern.js
@@ -1238,10 +1238,83 @@ jQuery.fn.txpSortable = function (options)
         update   : methods.update,
         items    : options.items
     });
 };
 
+/**
+ * Mask/unmask password input field.
+ *
+ * @since  4.6.0
+ */
+
+textpattern.passwordMask = function()
+{
+    $('body').on('click', '#show_password', function() {
+        var inputBox = $(this).closest('form').find('input.txp-maskable');
+        var newType = (inputBox.attr('type') === 'password') ? 'text' : 'password';
+        textpattern.changeType(inputBox, newType);
+    });
+}
+
+/**
+ * Change the type of an input element.
+ *
+ * @param  {object} elem The <input/> element
+ * @param  {string} type The desired type
+ *
+ * @see    https://gist.github.com/3559343 for original
+ * @since  4.6.0
+ */
+
+textpattern.changeType = function(elem, type)
+{
+    if (elem.prop('type') === type) {
+        // Already the correct type.
+        return elem;
+    }
+
+    try {
+        // May fail if browser prevents it.
+        return elem.prop('type', type);
+    } catch(e) {
+        // Create the element by hand.
+        // Clone it via a div (jQuery has no html() method for an element).
+        var html = $("<div>").append(elem.clone()).html();
+
+        // Match existing attributes of type=text or type="text".
+        var regex = /type=(\")?([^\"\s]+)(\")?/;
+
+        // If no match, add the type attribute to the end; otherwise, replace it.
+        var tmp = $(html.match(regex) == null ?
+            html.replace(">", ' type="' + type + '">') :
+            html.replace(regex, 'type="' + type + '"'));
+
+        // Copy data from old element.
+        tmp.data('type', elem.data('type'));
+        var events = elem.data('events');
+        var cb = function(events) {
+            return function() {
+                // Re-bind all prior events.
+                for(var idx in events) {
+                    var ydx = events[idx];
+
+                    for(var jdx in ydx) {
+                        tmp.bind(idx, ydx[jdx].handler);
+                    }
+                }
+            }
+        }(events);
+
+        elem.replaceWith(tmp);
+
+        // Wait a smidge before firing callback.
+        setTimeout(cb, 10);
+
+        return tmp;
+    }
+}
+
 /**
  * Encodes a string for a use in HTML.
  *
  * @param  {string} string The string
  * @return {string} Encoded string
@@ -1445,19 +1518,18 @@ textpattern.Route.add('login', function ()
         cookieEnabled = false;
         $('main').prepend($('<p class="alert-block warning" />').text(textpattern.gTxt('cookies_must_be_enabled')));
     }
 
     // Focus on either username or password when empty.
-    var has_name = $('#login_name').val().length;
-    var password_box = $('#login_password').val();
-    var has_password = (password_box) ? password_box.length : 0;
-
-    if (!has_name) {
-        $('#login_name').focus();
-    } else if (!has_password) {
-        $('#login_password').focus();
-    }
+    $('#login_form input').each(function() {
+        if (this.value === '') {
+            this.focus();
+            return false;
+        }
+    });
+
+    textpattern.passwordMask();
 });
 
 // Write panel.
 
 textpattern.Route.add('article', function ()
@@ -1535,10 +1607,17 @@ textpattern.Route.add('form', function ()
         'row'         : '.switcher-list li, .form-list-name',
         'highlighted' : '.switcher-list li'
     });
 });
 
+// Admin panel
+
+textpattern.Route.add('admin', function ()
+{
+    textpattern.passwordMask();
+});
+
 // Preferences panel.
 
 textpattern.Route.add('prefs', function ()
 {
     var prefsGroup = $('#all_preferences div[role=group]');
diff --git a/textpattern/update/_to_4.6.0.php b/textpattern/update/_to_4.6.0.php
index b1cc58d92..55a9f426f 100644
--- a/textpattern/update/_to_4.6.0.php
+++ b/textpattern/update/_to_4.6.0.php
@@ -194,5 +194,17 @@ safe_drop_index('txp_page', "name");
 safe_drop_index('txp_plugin', "name_2");
 safe_drop_index('txp_section', "name");
 
 // The txp_priv table was created for version 1.0, but never used nor created in later versions.
 safe_drop('txp_priv');
+
+// Add generic token table.
+safe_create('txp_token',"
+id           INT          NOT NULL AUTO_INCREMENT,
+reference_id INT          DEFAULT 0,
+type         VARCHAR(255) DEFAULT '',
+selector     CHAR(12)     DEFAULT '',
+token        VARCHAR(255) DEFAULT '',
+expires      DATETIME     DEFAULT '0000-00-00 00:00:00',
+PRIMARY KEY (id)
+"
+);
\ No newline at end of file

Vulnerable code:     extract(psa(array('new_pass', 'mail_password')));
        $message = gTxt('password_changed');

        if ($mail_password) {
            $email = fetch('email', 'txp_users', 'name', $txp_user);

            send_new_password($new_pass, $email, $txp_user);

            $message .= sp.gTxt('and_mailed_to').sp.$email;
        }

        $message .= '.';

            fInput('password', 'new_pass', '', '', '', '', INPUT_REGULAR, '', 'new_pass'),
            checkbox('mail_password', '1', true, '', 'mail_password').
            n.tag(gTxt('mail_it'), 'label', array('for' => 'mail_password')), array('class' => 'edit-admin-mail-password')).
                $passwd = generate_password(PASSWORD_LENGTH);

                if (change_user_password($name, $passwd)) {
                    $email = safe_field("email", 'txp_users', "name = '".doSlash($name)."'");

                    if (send_new_password($passwd, $email, $name)) {
                        $changed[] = $name;
                        $msg = 'author_updated';
                    } else {
                        return author_list(array(gTxt('could_not_mail').' '.txpspecialchars($name), E_ERROR));
                    }
                }
    } elseif (gps('reset')) {
    pagetop(gTxt('login'), $message);

    $stay = (cs('txp_login') and !gps('logout') ? 1 : 0);
    $reset = gps('reset');

    if (cs('txp_login') and strpos(cs('txp_login'), ',')) {
    if ($c_userid and strlen($c_hash) == 32) { // Cookie exists.
    } elseif ($p_userid and $p_password) { // Incoming login vars.
                substr(md5($nonce), - 10).$name,
    } elseif ($p_reset) { // Reset request.
    } elseif (gps('reset')) {
        $message = '';
    } elseif (gps('confirm')) {
        $confirm = pack('H*', gps('confirm'));
        $name    = substr($confirm, 5);
        $nonce   = safe_field("nonce", 'txp_users', "name = '".doSlash($name)."'");
        if ($nonce and $confirm === pack('H*', substr(md5($nonce), 0, 10)).$name) {
            include_once txpath.'/lib/txplib_admin.php';

            $message = reset_author_pass($name);
 * @param  string $RealName The real name
 * @param  string $name     The login name
 * @param  string $email    The email address
 * @param  string $password The password
 * @return bool FALSE on error.
 * @see    send_new_password()
 * @param  string $password The new password
 * @param  string $email    The email address
 * @param  string $name     The login name
 * @return bool FALSE on error.
 * @see    send_password()
 * @see    reset_author_pass()
 * doesn't exist. Though an error message could be thrown when user isn't found,
 * this is done due to security. This prevents the function from leaking
 * existing account names.
    $rs = safe_row("email, nonce", 'txp_users', "name = '".doSlash($name)."'");
        $confirm = bin2hex(pack('H*', substr(md5($nonce), 0, 10)).$name);

            n.n.gTxt('password_reset_confirmation').': '.

 * @param  int $length The length of the password
 * @return string Random plain-text password
 * @see    PASSWORD_SYMBOLS
 * @see    PASSWORD_LENGTH
 * The old password replaced with a new random-generated one.
    var has_name = $('#login_name').val().length;
    var password_box = $('#login_password').val();
    var has_password = (password_box) ? password_box.length : 0;

    if (!has_name) {
        $('#login_name').focus();
    } else if (!has_password) {
        $('#login_password').focus();
    }
Fixed code:     extract(psa(array('new_pass')));
        // Todo: Move full stop to Textpack.
        $message = gTxt('password_changed') . '.';
            fInput('password', 'new_pass', '', 'txp-maskable', '', '', INPUT_REGULAR, '', 'new_pass'),
            checkbox('unmask', 1, false, 0, 'show_password').
            n.tag(gTxt('show_password'), 'label', array('for' => 'show_password')), array('class' => 'edit-admin-show-password')).
                send_reset_confirmation_request($name);
                $changed[] = $name;
            $msg = 'password_reset_confirmation_request_sent';

    $stay = (cs('txp_login') && !gps('logout') ? 1 : 0);
    $reset = gps('reset');
    $confirm = gps('confirm');

    } elseif ($reset) {
    } elseif ($confirm) {
        $step = 'confirm';
        $pageTitle = gTxt('password_reset');
    } elseif ($confirm) {
        $pageTitle = gTxt('change_password');
        $out[] = hed(gTxt('change_password'), 1, array('id' => 'txp-change-password-heading')).
            n.tag(
                n.tag(gTxt('new_password'), 'label', array(
                    'class' => 'txp-form-field-label',
                    'for' => 'change_password',
                )).
                fInput('password', 'p_password', '', 'txp-form-field-input txp-maskable', '', '', INPUT_REGULAR, '', 'change_password', false, true),
                'div', array('class' => 'txp-form-field change-password')).
            graf(
                checkbox('unmask', 1, false, 0, 'show_password').n.
                tag(gTxt('show_password'), 'label', array('for' => 'show_password'))
                , array('class' => 'show-password')).
            graf(
                fInput('submit', '', gTxt('password_confirm_button'), 'publish').n
            ).
            graf(
                href(gTxt('back_to_login'), 'index.php'), array('class' => 'login-return'));
        $out[] = hInput('hash', gps('confirm'));
        $out[] = hInput('p_alter', 1);
        $pageTitle = gTxt('login');
    pagetop($pageTitle, $message);

 * During the reset request procedure, it is conceivable to verify the
 * token as soon as it is presented in the URL, but that would require:
 *  a) very similar code in both p_confirm and p_alter branches (unless refactored)
 *  b) some way (other than via the message) to signal back to doLoginForm() that
 *     the token is bogus so the 'change your password' form is not displayed.
 *     Perhaps raise an exception?
 *
 * @todo  Investigate validating confirm token as soon as it's presented in URL (better UX).
 * @todo  Could this be done via a Validator()?
 *

    $p_alter    = ps('p_alter');
    $p_confirm  = gps('confirm');
    if (cs('txp_login') && strpos(cs('txp_login'), ',')) {
    if ($c_userid && strlen($c_hash) === 32) {
        // Cookie exists.
        // @todo Improve security by using a better nonce/salt mechanism. md5 and uniqid are bad.
        // @todo Flag cookie-based logins and force confirmation of old password when
        // changing it from Admin->Users panel.
    } elseif ($p_userid && $p_password) {
        // Incoming login vars.
                substr(md5($nonce), -10).$name,
    } elseif ($p_reset) {
        // Reset request.
    } elseif ($p_alter) {
        // Password change confirmation.
        global $sitename;
        $pass = ps('p_password');
        if (trim($pass) === '') {
            $message = array(gTxt('password_required'), E_ERROR);
        } else {
            $hash = gps('hash');
            $selector = substr($hash, SALT_LENGTH);
            $tokenInfo = safe_row("reference_id, token, expires", 'txp_token', "selector = '".doSlash($selector)."' AND type='password_reset'");

            if ($tokenInfo) {
                if (strtotime($tokenInfo['expires']) <= time()) {
                    $message = array(gTxt('token_expired'), E_ERROR);
                } else {
                    $uid = assert_int($tokenInfo['reference_id']);
                    $row = safe_row("name, email, nonce, pass AS old_pass", 'txp_users', "user_id = $uid");

                    if ($row['nonce'] && ($hash === bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $row['nonce'].$selector.$row['old_pass']), 0, SALT_LENGTH))).$selector)) {
                        if (change_user_password($row['name'], $pass)) {
                            $body = gTxt('greeting').' '.$row['name'].','.n.n.gTxt('password_change_confirmation');
                            txpMail($row['email'], "[$sitename] ".gTxt('password_changed'), $body);
                            $message = gTxt('password_changed');

                            // Invalidate all reset requests in the wild for this user.
                            safe_delete("txp_token", "reference_id = $uid AND type = 'password_reset'");
                        }
                    } else {
                        $message = array(gTxt('invalid_token'), E_ERROR);
                    }
                }
            } else {
                $message = array(gTxt('invalid_token'), E_ERROR);
            }
if (!defined('HASHING_ALGORITHM')) {
    /**
     * Algorithm to use for hashing passwords/reset requests.
     *
     * This constant can be overridden from the config.php.
     *
     * @package User
     * @since   4.6.0
     * @see     PHP's hash_algos() function
     * @example
     * define('HASHING_ALGORITHM', 'whirlpool');
     */

    define('HASHING_ALGORITHM', 'ripemd256');
}

if (!defined('SALT_LENGTH')) {
    /**
     * Length of salt/selector hashes.
     *
     * This constant can be overridden from the config.php.
     *
     * @package User
     * @since   4.6.0
     * @example
     * define('SALT_LENGTH', '80');
     */

    define('SALT_LENGTH', '64');
}

if (!defined('RESET_EXPIRY_MINUTES')) {
    /**
     * Length of time (in minutes) that a password reset request remains valid.
     *
     * This constant can be overridden from the config.php.
     * Values under 60 may fall foul of DST changeover times, but meh.
     *
     * @package User
     * @since   4.6.0
     * @example
     * define('RESET_EXPIRY_MINUTES', '90');
     */

    define('RESET_EXPIRY_MINUTES', '20');
}

 * Should NEVER be used as sending plaintext passwords is wrong.
 * Will be removed in future, in lieu of sending reset request tokens.
 *
 * @param      string $RealName The real name
 * @param      string $name     The login name
 * @param      string $email    The email address
 * @param      string $password The password
 * @return     bool FALSE on error.
 * @deprecated in 4.6.0
 * @see        send_new_password(), send_reset_confirmation_request
 * Should NEVER be used as sending plaintext passwords is wrong.
 * Will be removed in future, in lieu of sending reset request tokens.
 *
 * @param      string $password The new password
 * @param      string $email    The email address
 * @param      string $name     The login name
 * @return     bool FALSE on error.
 * @deprecated in 4.6.0
 * @see        send_reset_confirmation_request
 * @see        reset_author_pass()
 * doesn't exist. Though an error message could be thrown when a user isn't
 * found, this is done due to security, which prevents the function from
 * leaking existing account names.
    $rs = safe_row("user_id, email, nonce, pass", 'txp_users', "name = '".doSlash($name)."'");
        $uid = assert_int($user_id);

        // The selector becomes an indirect reference to the txp_users row,
        // which does not leak information.
        $selector = Txp::get('\Textpattern\Password\Random')->generate(12);
        $expiry = strftime('%Y-%m-%d %H:%M:%S', time() + (60 * RESET_EXPIRY_MINUTES));

        // Use a hash of the nonce, selector and password.
        // This ensures that confirmation requests expire automatically when:
        //  a) The person next logs in, or
        //  b) They successfully change their password (usually as a result of this reset request)
        // Using the selector in the hash just injects randomness, otherwise two requests
        // back-to-back would generate the same confirmation code.
        // Old requests for the same user id are purged every time a new request is made.
        $token = bin2hex(pack('H*', substr(hash(HASHING_ALGORITHM, $nonce . $selector . $pass), 0, SALT_LENGTH)));
        $confirm = $token.$selector;

        // Remove any previous reset tokens and insert the new one.
        safe_delete("txp_token", "reference_id = $uid AND type = 'password_reset'");
        safe_insert("txp_token",
                "reference_id = $uid,
                type = 'password_reset',
                selector = '".doSlash($selector)."',
                token = '".doSlash($token)."',
                expires = '".doSlash($expiry)."'
            ");
            n.n.gTxt('password_reset_confirmation').
        // There's a possibility of a timing attack revealing the existence of
        // an account, which we could defend against to some degree.
 * Should NEVER be used as it is not cryptographically secure.
 * Will be removed in future, in lieu of sending reset request tokens.
 *
 * @param      int $length The length of the password
 * @return     string Random plain-text password
 * @deprecated in 4.6.0
 * @see        \Textpattern\Password\Generate
 * @see        \Textpattern\Password\Random
 * The old password is replaced with a new random-generated one.
 *
 * Should NEVER be used as sending plaintext passwords is wrong.
 * Will be removed in future, in lieu of sending reset request tokens.
 * @deprecated in 4.6.0
/**
 * Mask/unmask password input field.
 *
 * @since  4.6.0
 */

textpattern.passwordMask = function()
{
    $('body').on('click', '#show_password', function() {
        var inputBox = $(this).closest('form').find('input.txp-maskable');
        var newType = (inputBox.attr('type') === 'password') ? 'text' : 'password';
        textpattern.changeType(inputBox, newType);
    });
}

/**
 * Change the type of an input element.
 *
 * @param  {object} elem The <input/> element
 * @param  {string} type The desired type
 *
 * @see    https://gist.github.com/3559343 for original
 * @since  4.6.0
 */

textpattern.changeType = function(elem, type)
{
    if (elem.prop('type') === type) {
        // Already the correct type.
        return elem;
    }

    try {
        // May fail if browser prevents it.
        return elem.prop('type', type);
    } catch(e) {
        // Create the element by hand.
        // Clone it via a div (jQuery has no html() method for an element).
        var html = $("<div>").append(elem.clone()).html();

        // Match existing attributes of type=text or type="text".
        var regex = /type=(\")?([^\"\s]+)(\")?/;

        // If no match, add the type attribute to the end; otherwise, replace it.
        var tmp = $(html.match(regex) == null ?
            html.replace(">", ' type="' + type + '">') :
            html.replace(regex, 'type="' + type + '"'));

        // Copy data from old element.
        tmp.data('type', elem.data('type'));
        var events = elem.data('events');
        var cb = function(events) {
            return function() {
                // Re-bind all prior events.
                for(var idx in events) {
                    var ydx = events[idx];

                    for(var jdx in ydx) {
                        tmp.bind(idx, ydx[jdx].handler);
                    }
                }
            }
        }(events);

        elem.replaceWith(tmp);

        // Wait a smidge before firing callback.
        setTimeout(cb, 10);

        return tmp;
    }
}

    $('#login_form input').each(function() {
        if (this.value === '') {
            this.focus();
            return false;
        }
    });

    textpattern.passwordMask();
// Admin panel

textpattern.Route.add('admin', function ()
{
    textpattern.passwordMask();
});


// Add generic token table.
safe_create('txp_token',"
id           INT          NOT NULL AUTO_INCREMENT,
reference_id INT          DEFAULT 0,
type         VARCHAR(255) DEFAULT '',
selector     CHAR(12)     DEFAULT '',
token        VARCHAR(255) DEFAULT '',
expires      DATETIME     DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id)
"
);