View Issue Details

IDProjectCategoryView StatusLast Update
4708Composrcore_cnspublic2022-11-20 03:06
ReporterChris Graham Assigned ToGuest  
PrioritynormalSeverityfeature 
Status newResolutionopen 
Summary4708: Overhaul to lost password support
DescriptionThe lost password support is rather over-complex. Provide better messaging. Clean up the code with better comments.

Remove the temporary password option, and the option to have a new password e-mailed, and instead always just assign a randomised password, create a logged in session for them, and take them to edit their account to set a new password. There's no need to make this stuff configurable, just to have a sensible flow that covers all the bases and is maximally secure and user-friendly (there's no trade off necessary in this case).

Allow configuration for whether admins may reset their own passwords. Currently it is hard-coded that they cannot.
Allow configuration for where to redirect the user after they successfully reset their password.
Allow configuration of expiry of a password reset token. For security, if someone's email account is harvested at a much later date.

Remove the "ultra security" mode, as the above changes mean it isn't really adding anything anymore.

Members may not complete the lost password process. Add a notification to the staff for when it is not completed, so they can manually follow up if desired.
TagsHas Patch, Roadmap: Over the horizon, Type: Security
Attach Tags
Attached Files
lost_password_changes.diff (60,971 bytes)   
diff --git a/sources/hooks/systems/notifications/cns_lost_password_non_concluded.php b/sources/hooks/systems/notifications/cns_lost_password_non_concluded.php
new file mode 100644
index 0000000..7d24d45
--- /dev/null
+++ b/sources/hooks/systems/notifications/cns_lost_password_non_concluded.php
@@ -0,0 +1,50 @@
+<?php /*
+
+ Composr
+ Copyright (c) ocProducts, 2004-2016
+
+ See text/EN/licence.txt for full licencing information.
+
+
+ NOTE TO PROGRAMMERS:
+   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
+   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****
+
+*/
+
+/**
+ * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
+ * @copyright  ocProducts Ltd
+ * @package    core_cns
+ */
+
+/**
+ * Hook class.
+ */
+class Hook_notification_cns_lost_password_non_concluded extends Hook_notification__Staff
+{
+    /**
+     * Find the initial setting that members have for a notification code (only applies to the member_could_potentially_enable members).
+     *
+     * @param  ID_TEXT $notification_code Notification code
+     * @param  ?SHORT_TEXT $category The category within the notification code (null: none)
+     * @return integer Initial setting
+     */
+    public function get_initial_setting($notification_code, $category = null)
+    {
+        return A_NA;
+    }
+
+    /**
+     * Get a list of all the notification codes this hook can handle.
+     * (Addons can define hooks that handle whole sets of codes, so hooks are written so they can take wide authority)
+     *
+     * @return array List of codes (mapping between code names, and a pair: section and labelling for those codes)
+     */
+    public function list_handled_codes()
+    {
+        $list = array();
+        $list['cns_lost_password_non_concluded'] = array(do_lang('MEMBERS'), do_lang('cns_lost_password:NOTIFICATION_TYPE_cns_lost_password_non_concluded'));
+        return $list;
+    }
+}
diff --git a/lang/EN/cns_lost_password.ini b/lang/EN/cns_lost_password.ini
new file mode 100644
index 0000000..80b710e
--- /dev/null
+++ b/lang/EN/cns_lost_password.ini
@@ -0,0 +1,49 @@
+[strings]
+# Step 1
+_LOST_PASSWORD=Lost-password?
+LOST_PASSWORD=Login recovery
+LOST_PASSWORD_TEXT=Don't worry, we can help you log back in (&dagger;). You must first enter your account's e-mail address <em>or</em> your username.<br /><br />An e-mail will be sent to you. Upon clicking a link in the e-mail you'll be immediately logged in, your password will be randomised and you'll be encouraged to change it.<br /><br /><span class="associated_details">&dagger; Some accounts may not be permitted to go through this process, for security reasons.</span>
+LOST_PASSWORD_REQUEST_LOGIN_LINK=Request login link
+
+# Step 2
+LOGIN_LINK_MAILED=A login link has been e-mailed to the specified member's e-mail address.<br />E-mails usually arrive in a few minutes; if it doesn't, make sure to check&nbsp;your&nbsp;junk/spam&nbsp;folder.
+LOST_PASSWORD_INITIATE_MAIL_SUBJECT=Login recovery for {2}
+LOST_PASSWORD_INITIATE_MAIL=Someone has tried to generate a login link to your {1} account (username: {2}).\n\nIf this was not you, then ignore this e-mail (they may have just used the wrong username).\n\nIf this was you, click the following link to log in:\n[url="{3}"]{3}[/url]\n\nIf an error is returned when clicking that link you may try manually entering the details using the link below:\n[url="{4}"]{4}[/url]\nUsername: {2}\nLogin code: {6}\n\nThe login link/code expires on {7}.\nThe reset was initiated by the IP address {8}.
+LOST_PASSWORD_ERROR_NO_INPUT=You must enter either an e-mail address or a username.
+LOST_PASSWORD_ERROR_NO_MATCH=There is no such member on the system.
+LOST_PASSWORD_ERROR_NO_EMAIL=This member has no configured e-mail address to send a login link to, so unfortunately we cannot proceed.
+LOST_PASSWORD_ERROR_DENIED=This member is in a usergroup which is protected from the login recovery process, for a higher level of security. If you have lost access to an admin account, see the software's &ldquo;Disaster recovery&rdquo; tutorial for lower-level ways to get back in.
+LOST_PASSWORD_ERROR_HTTPAUTH=This is a single-sign-on account, you must log in using browser HTTP-authentication.
+LOST_PASSWORD_ERROR_EXTAUTH=This username is tied into an external architecture, and the password cannot be changed.
+
+# Step 3
+LOST_PASSWORD_MISSING_CONFIRM_CODE=Type your Username and login code here manually to access your account. You will find the login code in the e-mail you received.
+LOST_PASSWORD_CONFIRM=Your login code is valid. Click &ldquo;Proceed&rdquo; below to log in as {1}.
+LOST_PASSWORD_LOGGED_IN_HOME=You have been logged in as {1}. Your password has been changed to something random, so we recommend <a href="{2}">editing your account settings</a> to pick a password of your own choice.
+LOST_PASSWORD_LOGGED_IN_CHANGE_PASSWORD=You have been logged in as {1}. Your password has been changed to something random so you should now pick a password of your own choice.
+LOST_PASSWORD_COMPLETE_MAIL_SUBJECT=Account successfully accessed
+LOST_PASSWORD_COMPLETE_MAIL=The login recovery process has been used to access your account. The password has been randomised, pending you choosing a new password.\n\nIf it was not you who initiated this process, your personal e-mail account may have been compromised and you should also contact the website staff about this incident. Tell them the IP address that recovered the login is {3}.
+LOST_PASSWORD_ERROR_INCORRECT_CODE=You have entered an incorrect login code. The link in the e-mail you received contains the code; if you are viewing the e-mail in plain text make sure you go to the full link including the code portion. If you have since gone through the process again then you must use the link/code from the most recent e-mail.
+LOST_PASSWORD_ERROR_NEVER_HAPPENED=There is no recovery process active for this account.
+LOST_PASSWORD_ERROR_LINK_EXPIRED_AFTER_USE=The lost-password link has expired (these links are only valid for {1} after first used).
+LOST_PASSWORD_ERROR_LINK_EXPIRED_BEFORE_USE=The lost-password link has expired (these links are only valid for {1} from time of sending).
+
+# Config
+LOST_PASSWORD_EXPIRY_AFTER_SEND=Expiry time after send (minutes)
+CONFIG_OPTION_lost_password_expiry_after_send=Number of minutes after the login recovery process is initiated until the login code expires.
+LOST_PASSWORD_EXPIRY_AFTER_USE=Expiry time after use (seconds)
+CONFIG_OPTION_lost_password_expiry_after_use=Number of seconds after the login recovery process is concluded until the login code expires. This is a grace period to allow the link to be used multiple times.
+LOST_PASSWORD_REDIRECT_TARGET=Redirect target
+CONFIG_OPTION_lost_password_redirect_target=Where to redirect the user to at the end of the login recovery process.
+CONFIG_OPTION_lost_password_redirect_target_VALUE_post_login=Default post-login target
+CONFIG_OPTION_lost_password_redirect_target_VALUE_change_password=Change password
+LOST_PASSWORD_ALLOW_ADMIN=Allow administrators to use login recovery process
+CONFIG_OPTION_lost_password_allow_admin=Whether administrators may use the login recovery process to gain access to accounts. Usually the &ldquo;Excluded from the login recovery process&rdquo; privilege is assigned to usergroups that should <strong>not</strong> be using the login recovery process (typical for higher-access usergroups). However, as administrators have all the privileges, this option is required to control the setting for administrators.
+
+# Privileges
+PRIVILEGE_disable_lost_passwords=Excluded from the login recovery process
+
+# Notification to admin on failure
+NOTIFICATION_TYPE_cns_lost_password_non_concluded=Member failed to recover login
+LOST_PASSWORD_NON_CONCLUDED_MAIL_SUBJECT=Member {1} failed to recover login
+LOST_PASSWORD_NON_CONCLUDED_MAIL={{{1}}} tried to reset access to their account but did not conclude the process within {2}, and has not otherwise logged in since.\n\nConsider reaching out to them to see if they need help.
diff --git a/sources/hooks/systems/cron/cns_lost_password_cleanup.php b/sources/hooks/systems/cron/cns_lost_password_cleanup.php
new file mode 100644
index 0000000..6a796a6
--- /dev/null
+++ b/sources/hooks/systems/cron/cns_lost_password_cleanup.php
@@ -0,0 +1,82 @@
+<?php /*
+
+ Composr
+ Copyright (c) ocProducts, 2004-2016
+
+ See text/EN/licence.txt for full licencing information.
+
+
+ NOTE TO PROGRAMMERS:
+   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
+   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****
+
+*/
+
+/**
+ * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
+ * @copyright  ocProducts Ltd
+ * @package    core_cns
+ */
+
+/**
+ * Hook class.
+ */
+class Hook_cron_cns_lost_password_cleanup
+{
+    /**
+     * Run function for CRON hooks. Searches for tasks to perform.
+     */
+    public function run()
+    {
+        // TODO: Update for v11
+
+        if (get_forum_type() != 'cns') {
+            return;
+        }
+
+        if (is_on_multi_site_network()) {
+            return;
+        }
+
+        $time = time();
+        $last_time = intval(get_value('last_lost_password_cleanup', null, true));
+        if ($last_time == 0) {
+            $last_time = time();
+            set_value('last_lost_password_cleanup', strval($time), true);
+        }
+        if ($last_time > time() - 60 * 5) {
+            return;
+        }
+        set_value('last_confirm_reminder_time', strval($time), true);
+
+        $expiry_after_send = intval(get_option('lost_password_expiry_after_send')) * 60;
+        $_expiry_after_send = display_time_period($expiry_after_send);
+
+        $db = $GLOBALS['FORUM_DB'];
+        $sql = 'SELECT id,m_username,m_last_visit_time,m_password_change_code_ip,m_password_change_code_send_time FROM ' . $db->get_table_prefix() . 'f_members WHERE ';
+        $sql .= db_string_not_equal_to('m_password_change_code', '') . ' AND m_password_change_code_activation_time IS NULL AND ';
+        $sql .= 'm_password_change_code_send_time IS NOT NULL AND m_password_change_code_send_time<' . strval(time() - $expiry_after_send);
+        $rows = $db->query($sql);
+
+        if (!empty($rows)) {
+            require_code('notifications');
+            require_lang('cns_lost_password');
+
+            foreach ($rows as $row) {
+                $username = $row['m_username'];
+
+                if (($row['m_last_visit_time'] === null) || ($row['m_last_visit_time'] < time() - $expiry_after_send)) {
+                    $ip = $row['m_password_change_code_ip'];
+                    $send_time = get_timezoned_date($row['m_password_change_code_send_time']);
+                    $last_login_time = ($row['m_last_visit_time'] === null) ? do_lang('NA') : get_timezoned_date($row['m_last_visit_time']);
+                    $subject = do_lang('LOST_PASSWORD_NON_CONCLUDED_MAIL_SUBJECT', $username);
+                    $mail = do_notification_lang('LOST_PASSWORD_NON_CONCLUDED_MAIL', comcode_escape($username), comcode_escape($_expiry_after_send), array(comcode_escape($send_time), comcode_escape($last_login_time), comcode_escape($ip)));
+                    dispatch_notification('cns_lost_password_non_concluded', '', $subject, $mail);
+                }
+
+                $update_map = array('m_password_change_code' => '', 'm_password_change_code_send_time' => null, 'm_password_change_code_activation_time' => null, 'm_password_change_code_ip' => '');
+                $db->query_update('f_members', $update_map, array('id' => $row['id']), '', 1);
+            }
+        }
+    }
+}
diff --git a/sources/hooks/systems/config/lost_password_redirect_target.php b/sources/hooks/systems/config/lost_password_redirect_target.php
new file mode 100644
index 0000000..2b9ee76
--- /dev/null
+++ b/sources/hooks/systems/config/lost_password_redirect_target.php
@@ -0,0 +1,56 @@
+<?php /*
+
+ Composr
+ Copyright (c) ocProducts, 2004-2016
+
+ See text/EN/licence.txt for full licencing information.
+
+
+ NOTE TO PROGRAMMERS:
+   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
+   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****
+
+*/
+
+/**
+ * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
+ * @copyright  ocProducts Ltd
+ * @package    core_cns
+ */
+
+/**
+ * Hook class.
+ */
+class Hook_config_lost_password_redirect_target
+{
+    /**
+     * Gets the details relating to the config option.
+     *
+     * @return ?array The details (null: disabled)
+     */
+    public function get_details()
+    {
+        return array(
+            'human_name' => 'LOST_PASSWORD_REDIRECT_TARGET',
+            'type' => 'list',
+            'category' => 'SECURITY',
+            'group' => 'LOST_PASSWORD',
+            'explanation' => 'CONFIG_OPTION_lost_password_redirect_target',
+            'shared_hosting_restricted' => '0',
+            'list_options' => 'post_login|change_password',
+            'order_in_category_group' => 3,
+
+            'addon' => 'core_cns',
+        );
+    }
+
+    /**
+     * Gets the default value for the config option.
+     *
+     * @return ?string The default value (null: option is disabled)
+     */
+    public function get_default()
+    {
+        return 'change_password';
+    }
+}
diff --git a/sources/hooks/systems/config/lost_password_allow_admin.php b/sources/hooks/systems/config/lost_password_allow_admin.php
new file mode 100644
index 0000000..d77f10c
--- /dev/null
+++ b/sources/hooks/systems/config/lost_password_allow_admin.php
@@ -0,0 +1,56 @@
+<?php /*
+
+ Composr
+ Copyright (c) ocProducts, 2004-2016
+
+ See text/EN/licence.txt for full licencing information.
+
+
+ NOTE TO PROGRAMMERS:
+   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
+   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****
+
+*/
+
+/**
+ * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
+ * @copyright  ocProducts Ltd
+ * @package    core_cns
+ */
+
+/**
+ * Hook class.
+ */
+class Hook_config_lost_password_allow_admin
+{
+    /**
+     * Gets the details relating to the config option.
+     *
+     * @return ?array The details (null: disabled)
+     */
+    public function get_details()
+    {
+        return array(
+            'human_name' => 'LOST_PASSWORD_ALLOW_ADMIN',
+            'type' => 'tick',
+            'category' => 'SECURITY',
+            'group' => 'LOST_PASSWORD',
+            'explanation' => 'CONFIG_OPTION_lost_password_allow_admin',
+            'shared_hosting_restricted' => '0',
+            'list_options' => '',
+            'order_in_category_group' => 4,
+
+            'addon' => 'core_cns',
+        );
+    }
+
+    /**
+     * Gets the default value for the config option.
+     *
+     * @return ?string The default value (null: option is disabled)
+     */
+    public function get_default()
+    {
+        return '0';
+    }
+}
diff --git a/sources/hooks/systems/config/lost_password_expiry_after_send.php b/sources/hooks/systems/config/lost_password_expiry_after_send.php
new file mode 100644
index 0000000..9d4623a
--- /dev/null
+++ b/sources/hooks/systems/config/lost_password_expiry_after_send.php
@@ -0,0 +1,56 @@
+<?php /*
+
+ Composr
+ Copyright (c) ocProducts, 2004-2016
+
+ See text/EN/licence.txt for full licencing information.
+
+
+ NOTE TO PROGRAMMERS:
+   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
+   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****
+
+*/
+
+/**
+ * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
+ * @copyright  ocProducts Ltd
+ * @package    core_cns
+ */
+
+/**
+ * Hook class.
+ */
+class Hook_config_lost_password_expiry_after_send
+{
+    /**
+     * Gets the details relating to the config option.
+     *
+     * @return ?array The details (null: disabled)
+     */
+    public function get_details()
+    {
+        return array(
+            'human_name' => 'LOST_PASSWORD_EXPIRY_AFTER_SEND',
+            'type' => 'integer',
+            'category' => 'SECURITY',
+            'group' => 'LOST_PASSWORD',
+            'explanation' => 'CONFIG_OPTION_lost_password_expiry_after_send',
+            'shared_hosting_restricted' => '0',
+            'list_options' => '',
+            'order_in_category_group' => 1,
+
+            'addon' => 'core_cns',
+        );
+    }
+
+    /**
+     * Gets the default value for the config option.
+     *
+     * @return ?string The default value (null: option is disabled)
+     */
+    public function get_default()
+    {
+        return 60 * 24; // 1 day
+    }
+}
diff --git a/sources/hooks/systems/config/lost_password_expiry_after_use.php b/sources/hooks/systems/config/lost_password_expiry_after_use.php
new file mode 100644
index 0000000..03eea0a
--- /dev/null
+++ b/sources/hooks/systems/config/lost_password_expiry_after_use.php
@@ -0,0 +1,56 @@
+<?php /*
+
+ Composr
+ Copyright (c) ocProducts, 2004-2016
+
+ See text/EN/licence.txt for full licencing information.
+
+
+ NOTE TO PROGRAMMERS:
+   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
+   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****
+
+*/
+
+/**
+ * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
+ * @copyright  ocProducts Ltd
+ * @package    core_cns
+ */
+
+/**
+ * Hook class.
+ */
+class Hook_config_lost_password_expiry_after_use
+{
+    /**
+     * Gets the details relating to the config option.
+     *
+     * @return ?array The details (null: disabled)
+     */
+    public function get_details()
+    {
+        return array(
+            'human_name' => 'LOST_PASSWORD_EXPIRY_AFTER_USE',
+            'type' => 'integer',
+            'category' => 'SECURITY',
+            'group' => 'LOST_PASSWORD',
+            'explanation' => 'CONFIG_OPTION_lost_password_expiry_after_use',
+            'shared_hosting_restricted' => '0',
+            'list_options' => '',
+            'order_in_category_group' => 2,
+
+            'addon' => 'core_cns',
+        );
+    }
+
+    /**
+     * Gets the default value for the config option.
+     *
+     * @return ?string The default value (null: option is disabled)
+     */
+    public function get_default()
+    {
+        return 60 * 10; // 10 minutes
+    }
+}
diff --git a/sources/hooks/systems/commandr_commands/passwd.php b/sources/hooks/systems/commandr_commands/passwd.php
index b562c85..318cc35 100644
--- a/sources/hooks/systems/commandr_commands/passwd.php
+++ b/sources/hooks/systems/commandr_commands/passwd.php
@@ -60,6 +60,9 @@ class Hook_commandr_command_passwd
 
             $update = array();
             $update['m_password_change_code'] = '';
+            $update['m_password_change_code_send_time'] = null;
+            $update['m_password_change_code_activation_time'] = null;
+            $update['m_password_change_code_ip'] = '';
             $salt = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_pass_salt');
             if (is_null($salt)) {
                 return array('', '', '', do_lang('_MEMBER_NO_EXIST', array_key_exists('username', $options) ? $options['username'] : $options['u']));
diff --git a/sources/hooks/systems/addon_registry/core_cns.php b/sources/hooks/systems/addon_registry/core_cns.php
index 6b4ad15..7e11596 100644
--- a/sources/hooks/systems/addon_registry/core_cns.php
+++ b/sources/hooks/systems/addon_registry/core_cns.php
@@ -306,6 +306,13 @@ class Hook_addon_registry_core_cns
             'lang/EN/cns.ini',
             'lang/EN/cns_special_cpf.ini',
             'lang/EN/cns_components.ini',
+            'lang/EN/cns_lost_password.ini',
+            'sources/hooks/systems/config/lost_password_expiry_after_send.php',
+            'sources/hooks/systems/config/lost_password_expiry_after_use.php',
+            'sources/hooks/systems/config/lost_password_redirect_target.php',
+            'sources/hooks/systems/config/lost_password_allow_admin.php',
+            'sources/hooks/systems/notifications/cns_lost_password_cleanup.php',
+            'sources/hooks/systems/cron/cns_lost_password_cleanup.php',
             'lang/EN/cns_config.ini',
             'sources/forum/cns.php',
             'sources/cns_forum_driver_helper.php',
@@ -414,7 +421,6 @@ class Hook_addon_registry_core_cns
             'sources/hooks/systems/config/normal_groups_per_page.php',
             'sources/hooks/systems/config/password_change_days.php',
             'sources/hooks/systems/config/password_expiry_days.php',
-            'sources/hooks/systems/config/password_reset_process.php',
             'sources/hooks/systems/config/primary_members_per_page.php',
             'sources/hooks/systems/config/secondary_members_per_page.php',
             'sources/hooks/systems/config/show_empty_cpfs.php',
diff --git a/sources/forum/cns.php b/sources/forum/cns.php
index 2c33afd..df1062c 100644
--- a/sources/forum/cns.php
+++ b/sources/forum/cns.php
@@ -1766,7 +1767,6 @@ class Forum_driver_cns extends Forum_driver_base
                 if ($submitting) {
                     $change_map['m_last_submit_time'] = time();
                 }
-                $change_map['m_password_change_code'] = ''; // Security, to stop resetting password when account actively in use (stops people planting reset bombs then grabbing the details much later)
 
                 if (get_page_name() != 'lost_password') {
                     if (get_db_type() != 'xml') {
diff --git a/sources/cns_members_action2.php b/sources/cns_members_action2.php
index a270236..ce55b4a 100644
--- a/sources/cns_members_action2.php
+++ b/sources/cns_members_action2.php
@@ -913,6 +913,9 @@ function cns_edit_member($member_id, $email_address, $preview_posts, $dob_day, $
         if ((is_null($password_compatibility_scheme)) && (get_value('no_password_hashing') === '1')) {
             $password_compatibility_scheme = 'plain';
             $update['m_password_change_code'] = '';
+            $update['m_password_change_code_send_time'] = null;
+            $update['m_password_change_code_activation_time'] = null;
+            $update['m_password_change_code_ip'] = '';
             $salt = '';
         }
 
@@ -927,6 +930,9 @@ function cns_edit_member($member_id, $email_address, $preview_posts, $dob_day, $
         } else {
             require_code('crypt');
             $update['m_password_change_code'] = '';
+            $update['m_password_change_code_send_time'] = null;
+            $update['m_password_change_code_activation_time'] = null;
+            $update['m_password_change_code_ip'] = '';
             $salt = $GLOBALS['CNS_DRIVER']->get_member_row_field($member_id, 'm_pass_salt');
             $update['m_pass_hash_salted'] = ratchet_hash($password, $salt);
             $update['m_password_compat_scheme'] = '';
diff --git a/sources/cns_members_action.php b/sources/cns_members_action.php
index 96621f2..f95d66a 100644
--- a/sources/cns_members_action.php
+++ b/sources/cns_members_action.php
@@ -288,6 +288,9 @@ function cns_make_member($username, $password, $email_address, $secondary_groups
         'm_allow_emails' => $allow_emails,
         'm_allow_emails_from_staff' => $allow_emails_from_staff,
         'm_password_change_code' => '',
+        'm_password_change_code_send_time' => null,
+        'm_password_change_code_activation_time' => null,
+        'm_password_change_code_ip' => '',
         'm_password_compat_scheme' => $password_compatibility_scheme,
         'm_on_probation_until' => $on_probation_until,
         'm_profile_views' => $profile_views,
diff --git a/sources/cns_lost_password.php b/sources/cns_lost_password.php
index 19050a0..dc5fa97 100644
--- a/sources/cns_lost_password.php
+++ b/sources/cns_lost_password.php
@@ -23,93 +23,101 @@
  *
  * @param  string $username Username to reset for (may be blank if other is not)
  * @param  string $email_address E-mail address to set for (may be blank if other is not)
- * @return array A tuple: e-mail address, obfuscated e-mail address that is safe(ish) to display, member ID
+ * @param  ?Tempcode $error Error message, returned by reference
+ * @return ?array A tuple: e-mail address, obfuscated e-mail address that is safe(ish) to display, member ID (null: error)
  */
-function lost_password_emailer_step($username, $email_address)
+function lost_password_emailer_step($username, $email_address, &$error)
 {
+    require_lang('cns_lost_password');
+
+    // Look up member
     if (($username == '') && ($email_address == '')) {
-        warn_exit(do_lang_tempcode('PASSWORD_RESET_ERROR'));
+        $error = do_lang_tempcode('LOST_PASSWORD_ERROR_NO_INPUT');
+        return;
     }
-
     if ($username != '') {
         $member_id = $GLOBALS['FORUM_DRIVER']->get_member_from_username($username);
     } else {
         $member_id = $GLOBALS['FORUM_DRIVER']->get_member_from_email_address($email_address);
     }
     if (($member_id === null) || (is_guest($member_id))) {
-        warn_exit(do_lang_tempcode('PASSWORD_RESET_ERROR_2'));
+        $error = do_lang_tempcode('LOST_PASSWORD_ERROR_NO_MATCH');
+        return;
     }
     $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);
-    if (($GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_compat_scheme') == '') && (has_privilege($member_id, 'disable_lost_passwords')) && (!$GLOBALS['IS_ACTUALLY_ADMIN'])) {
-        warn_exit(do_lang_tempcode('NO_RESET_ACCESS'));
+    $email = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_email_address');
+    $join_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_join_time');
+
+    // See if we are allowed to do a reset
+    if ($GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_compat_scheme') == '') {
+        if ((!$GLOBALS['FORUM_DRIVER']->is_super_admin($member_id)) && (has_privilege($member_id, 'disable_lost_passwords')) || ($GLOBALS['FORUM_DRIVER']->is_super_admin($member_id)) && (get_option('lost_password_allow_admin') == '0')) {
+            $error = do_lang_tempcode('LOST_PASSWORD_ERROR_DENIED');
+            return;
+        }
     }
     if ($GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_compat_scheme') == 'httpauth') {
-        warn_exit(do_lang_tempcode('NO_PASSWORD_RESET_HTTPAUTH'));
+        $error = do_lang_tempcode('LOST_PASSWORD_ERROR_HTTPAUTH');
+        return;
     }
     $is_ldap = cns_is_ldap_member($member_id);
     $is_httpauth = cns_is_httpauth_member($member_id);
     if (($is_ldap)/* || ($is_httpauth  Actually covered more explicitly above - over mock-httpauth, like Facebook, may have passwords reset to break the integrations)*/) {
-        warn_exit(do_lang_tempcode('EXT_NO_PASSWORD_CHANGE'));
+        $error = do_lang_tempcode('LOST_PASSWORD_ERROR_EXTAUTH');
+        return;
+    }
+    if ($email == '') {
+        $error = do_lang_tempcode('LOST_PASSWORD_ERROR_NO_EMAIL');
+        return;
     }
 
+    // Re-use existing code if possible, so that overlapping reset emails don't cause chaos
     require_code('crypt');
-    $code = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_change_code'); // Re-use existing code if possible, so that overlapping reset emails don't cause chaos
-    if ($code != '') {
-        if (get_option('password_reset_process') == 'ultra') {
-            list($code, $session_id) = explode('__', $code);
-        }
-    }
-    if (($code == '') || (get_option('password_reset_process') == 'ultra') && ($session_id != get_session_id())) {
+    $code = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_change_code');
+    $update_map = array(
+        'm_password_change_code_send_time' => time(),
+        'm_password_change_code_activation_time' => null,
+        'm_password_change_code_ip' => get_ip_address(),
+    );
+    if (($code == '') || ($GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_change_code_activation_time') !== null)) {
+        // Generate new code
         $code = get_rand_password();
-        if (get_option('password_reset_process') == 'ultra') {
-            $GLOBALS['FORUM_DB']->query_update('f_members', array('m_password_change_code' => $code . '__' . get_session_id()), array('id' => $member_id), '', 1);
-        } else {
-            $GLOBALS['FORUM_DB']->query_update('f_members', array('m_password_change_code' => $code), array('id' => $member_id), '', 1);
-        }
-    }
-
-    $email = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_email_address');
-    if ($email == '') {
-        warn_exit(do_lang_tempcode('MEMBER_NO_EMAIL_ADDRESS_RESET_TO'));
+        $update_map['m_password_change_code'] = $code;
     }
+    $GLOBALS['FORUM_DB']->query_update('f_members', $update_map, array('id' => $member_id), '', 1);
 
-    log_it('LOST_PASSWORD', strval($member_id), $code);
-
-    $join_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_join_time');
-
-    $temporary_passwords = (get_option('password_reset_process') != 'emailed');
+    // Log it
+    log_it('LOST_PASSWORD', strval($member_id), encode_lost_password_login_code($code));
 
     // Send confirm mail
-    if (get_option('password_reset_process') != 'ultra') {
-        $zone = get_module_zone('lost_password');
-        $_url = build_url(array('page' => 'lost_password', 'type' => 'step3', 'code' => $code, 'member' => $member_id), $zone, null, false, false, true);
-        $url = $_url->evaluate();
-        $_url_simple = build_url(array('page' => 'lost_password', 'type' => 'step3', 'code' => null, 'username' => null, 'member' => null), $zone, null, false, false, true);
-        $url_simple = $_url_simple->evaluate();
-        $message = do_lang($temporary_passwords ? 'LOST_PASSWORD_TEXT_TEMPORARY' : 'LOST_PASSWORD_TEXT', comcode_escape(get_site_name()), comcode_escape($username), array($url, comcode_escape($url_simple), strval($member_id), $code), get_lang($member_id));
-        require_code('mail');
-        mail_wrap(do_lang('LOST_PASSWORD_CONFIRM', null, null, null, get_lang($member_id)), $message, array($email), $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), '', '', 3, null, false, null, false, false, false, 'MAIL', true, null, null, $join_time);
-    } else {
-        $old_php_self = cms_srv('PHP_SELF');
-        $old_server_name = cms_srv('SERVER_NAME');
-
-        // Fiddle to try and anonymise details of the e-mail
-        $_SERVER['PHP_SELF'] = "/";
-        $_SERVER['SERVER_NAME'] = cms_srv('SERVER_ADDR');
-
-        $from_email = get_option('website_email');
-        //$from_email = 'noreply@' . $_SERVER['SERVER_ADDR'];  Won't work on most hosting
-        $from_name = do_lang('PASSWORD_RESET_ULTRA_FROM');
-        $subject = do_lang('PASSWORD_RESET_ULTRA_SUBJECT', $code);
-        $body = do_lang('PASSWORD_RESET_ULTRA_BODY', $code);
-        mail($email, $subject, $body, 'From: ' . $from_name . ' <' . $from_email . '>' . "\r\n" . 'Reply-To: ' . $from_name . ' <' . $from_email . '>');
-
-        // Put env details back to how they should be
-        $_SERVER['PHP_SELF'] = $old_php_self;
-        $_SERVER['SERVER_NAME'] = $old_server_name;
-    }
-
+    $zone = get_module_zone('lost_password');
+    $_url = build_url(array('page' => 'lost_password', 'type' => 'step3', 'code' => $code, 'member' => $member_id), $zone, null, false, false, true);
+    $url = $_url->evaluate();
+    $_url_simple = build_url(array('page' => 'lost_password', 'type' => 'step3', 'code' => null, 'username' => null, 'member' => null), $zone, null, false, false, true);
+    $url_simple = $_url_simple->evaluate();
+    $expiry_date = get_timezoned_date(time() + 60 * intval(get_option('lost_password_expiry_after_send')), true, false, false, true, $member_id);
+    require_code('mail');
+    $subject = do_lang('LOST_PASSWORD_INITIATE_MAIL_SUBJECT', get_site_name(), $username, null, get_lang($member_id));
+    $message = do_lang('LOST_PASSWORD_INITIATE_MAIL', comcode_escape(get_site_name()), comcode_escape($username), array($url, comcode_escape($url_simple), strval($member_id), $code, $expiry_date, get_ip_address()), get_lang($member_id));
+    mail_wrap($subject, $message, array($email), $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), '', '', 3, null, false, null, false, false, false, 'MAIL', true, null, null, $join_time);
     $email_address_masked = preg_replace('#^(\w).*@.*(\w\.\w+)$#', '${1}...@...${2}', $email);
 
     return array($email, $email_address_masked, $member_id);
 }
+
+/**
+ * Encode a login code with a 1-way-hash.
+ *
+ * @param  string $code The login code
+ * @return string The hashed code
+ */
+function encode_lost_password_login_code($code)
+{
+    $_code = '';
+    for ($i = 0; $i < strlen($code); $i++) {
+        if ($i % 2 == 0) {
+            $_code .= $code[$i];
+        }
+    }
+    return md5($_code);
+}
+
diff --git a/sources/cns_install.php b/sources/cns_install.php
index 56f86fb..a20ff14 100644
--- a/sources/cns_install.php
+++ b/sources/cns_install.php
@@ -330,6 +330,11 @@ function install_cns($upgrade_from = null)
         require_code('cns_members_action2');
         rebuild_all_cpf_indices();
     }
+    if (($upgrade_from !== null) && ($upgrade_from < 11.0)) {
+        $GLOBALS['FORUM_DB']->add_table_field('f_members', 'm_password_change_code_send_time', '?TIME');
+        $GLOBALS['FORUM_DB']->add_table_field('f_members', 'm_password_change_code_activation_time', '?TIME');
+        $GLOBALS['FORUM_DB']->add_table_field('f_members', 'm_password_change_code_ip', 'IP');
+    }
 
     // If we have the forum installed to this db already, leave
     if (is_null($upgrade_from)) {
@@ -445,6 +450,9 @@ function install_cns($upgrade_from = null)
             'm_pt_rules_text' => 'LONG_TRANS__COMCODE',
             'm_max_email_attach_size_mb' => 'INTEGER',
             'm_password_change_code' => 'SHORT_TEXT',
+            'm_password_change_code_send_time' => '?TIME',
+            'm_password_change_code_activation_time' => '?TIME',
+            'm_password_change_code_ip' => 'IP',
             'm_password_compat_scheme' => 'ID_TEXT',
             'm_on_probation_until' => '?TIME',
             'm_profile_views' => 'UINTEGER',
@@ -900,4 +908,8 @@ function install_cns($upgrade_from = null)
         add_privilege('FORUMS_AND_MEMBERS', 'bypass_dob');
         add_privilege('FORUMS_AND_MEMBERS', 'bypass_dob_if_already_empty');
     }
+
+    if (($upgrade_from === null) || ($upgrade_from < 11.0)) {
+        $GLOBALS['FORUM_DB']->create_index('f_members', 'password_change_code', array('m_password_change_code'));
+    }
 }
diff --git a/pages/modules/lost_password.php b/pages/modules/lost_password.php
index 326dbb7..f082a7b 100644
--- a/pages/modules/lost_password.php
+++ b/pages/modules/lost_password.php
@@ -54,7 +54,9 @@ class Module_lost_password
         $type = get_param_string('type', 'browse');
 
         require_lang('cns');
+        require_lang('cns_lost_password');
         require_css('cns');
+        require_code('cns_lost_password');
 
         if ($type == 'browse') {
             breadcrumb_set_self(do_lang_tempcode('LOST_PASSWORD'));
@@ -121,14 +123,14 @@ class Module_lost_password
 
         if ($check_perms && is_guest($member_id)) {
             return array(
-                'browse' => array('LOST_PASSWORD', 'menu/site_meta/user_actions/lost_password'),
+                'browse' => array('_LOST_PASSWORD', 'menu/site_meta/user_actions/lost_password'),
             );
         }
         return array();
     }
 
     /**
-     * The UI to ask for the username to get the lost password for.
+     * Step 1: Ask about account.
      *
      * @return Tempcode The UI
      */
@@ -142,23 +144,32 @@ class Module_lost_password
         $set_title = do_lang_tempcode('ACCOUNT');
         $field_set = alternate_fields_set__start($set_name);
 
+        $field_set->attach(form_input_email(do_lang_tempcode('EMAIL_ADDRESS'), '', 'email_address', trim(get_param_string('email_address', '')), false));
+
         $field_set->attach(form_input_line(do_lang_tempcode('USERNAME'), '', 'username', trim(get_param_string('username', '')), false));
         // form_input_username not used, so as to stop someone accidentally autocompleting to someone else's similar name - very possible for a person already known to be forgetful
 
-        $field_set->attach(form_input_email(do_lang_tempcode('EMAIL_ADDRESS'), '', 'email_address', trim(get_param_string('email_address', '')), false));
-
         $fields->attach(alternate_fields_set__end($set_name, $set_title, '', $field_set, $required));
 
-        $temporary_passwords = (get_option('password_reset_process') != 'emailed');
-        $text = do_lang_tempcode('_PASSWORD_RESET_TEXT_' . get_option('password_reset_process'));
-        $submit_name = do_lang_tempcode('PASSWORD_RESET_BUTTON');
+        $text = do_lang_tempcode('LOST_PASSWORD_TEXT');
+        $submit_name = do_lang_tempcode('LOST_PASSWORD_REQUEST_LOGIN_LINK');
         $post_url = build_url(array('page' => '_SELF', 'type' => 'step2'), '_SELF');
 
-        return do_template('FORM_SCREEN', array('_GUID' => '080e516fef7c928dbb9fb85beb6e435a', 'SKIP_WEBSTANDARDS' => true, 'TITLE' => $this->title, 'HIDDEN' => '', 'FIELDS' => $fields, 'TEXT' => $text, 'SUBMIT_ICON' => 'menu__site_meta__user_actions__lost_password', 'SUBMIT_NAME' => $submit_name, 'URL' => $post_url));
+        return do_template('FORM_SCREEN', array(
+            '_GUID' => '080e516fef7c928dbb9fb85beb6e435a',
+            'SKIP_WEBSTANDARDS' => true,
+            'TITLE' => $this->title,
+            'HIDDEN' => '',
+            'FIELDS' => $fields,
+            'TEXT' => $text,
+            'SUBMIT_ICON' => 'menu__site_meta__user_actions__lost_password',
+            'SUBMIT_NAME' => $submit_name,
+            'URL' => $post_url,
+        ));
     }
 
     /**
-     * The UI and actualisation for sending out the confirm email.
+     * Step 2: Verify account and start process.
      *
      * @return Tempcode The UI
      */
@@ -167,42 +178,29 @@ class Module_lost_password
         $username = trim(post_param_string('username', ''));
         $email_address = trim(post_param_string('email_address', ''));
 
-        require_code('cns_lost_password');
-        list($email, $email_address_masked, $member_id) = lost_password_emailer_step($username, $email_address);
-
-        if (get_option('password_reset_process') == 'ultra') {
-            // Input UI (as code will be typed immediately, there's no link in the e-mail for 'ultra' mode)
-            $zone = get_module_zone('lost_password');
-            $_url = build_url(array('page' => 'lost_password', 'type' => 'step3', 'member' => $member_id), $zone);
-            require_code('form_templates');
-            $fields = new Tempcode();
-            $fields->attach(form_input_line(do_lang_tempcode('CODE'), '', 'code', null, true));
-            $submit_name = do_lang_tempcode('PROCEED');
-            return do_template('FORM_SCREEN', array(
-                '_GUID' => '9f03d4abe0140559ec6eba2fa34fe3d6',
-                'TITLE' => $this->title,
-                'GET' => true,
-                'SKIP_WEBSTANDARDS' => true,
-                'HIDDEN' => '',
-                'URL' => $_url,
-                'FIELDS' => $fields,
-                'TEXT' => do_lang_tempcode('ENTER_CODE_FROM_EMAIL'),
-                'SUBMIT_ICON' => 'menu__site_meta__user_actions__lost_password',
-                'SUBMIT_NAME' => $submit_name,
-            ));
+        $error = null;
+        $result = lost_password_emailer_step($username, $email_address, $error);
+        if ($result === null) {
+            $redirect_url = build_url(array('page' => '_SELF', 'username' => $username, 'email_address' => $email_address), '_SELF');
+            attach_message($error, 'warn');
+            return redirect_screen($this->title, $redirect_url);
         }
 
-        return inform_screen($this->title, do_lang_tempcode('RESET_CODE_MAILED', escape_html($email_address_masked), escape_html($email)));
+        list($email, $email_address_masked, $member_id) = $result;
+
+        return inform_screen($this->title, do_lang_tempcode('LOGIN_LINK_MAILED', escape_html($email_address_masked), escape_html($email)));
     }
 
     /**
-     * The UI and actualisation for: accepting code if it is correct (and not ''), and setting password to something random, emailing it
+     * Step 2: Verify security token and conclude process.
      *
      * @return Tempcode The UI
      */
     public function step3()
     {
         $code = trim(get_param_string('code', ''));
+
+        // Manually input code if required
         if ($code == '') {
             require_code('form_templates');
             $fields = new Tempcode();
@@ -217,22 +215,33 @@ class Module_lost_password
                 'HIDDEN' => '',
                 'URL' => get_self_url(false, false, null, false, true),
                 'FIELDS' => $fields,
-                'TEXT' => do_lang_tempcode('MISSING_CONFIRM_CODE'),
-                'SUBMIT_ICON' => 'buttons_menu__site_meta__user_actions__lost_password_proceed',
+                'TEXT' => do_lang_tempcode('LOST_PASSWORD_MISSING_CONFIRM_CODE'),
+                'SUBMIT_ICON' => 'menu__site_meta__user_actions__lost_password',
                 'SUBMIT_NAME' => $submit_name,
             ));
         }
+
+        // Check account matches (error: LOST_PASSWORD_ERROR_NO_MATCH)
         $username = get_param_string('username', null);
-        if (!is_null($username)) {
+        if ($username !== null) {
             $username = trim($username);
             $member_id = $GLOBALS['FORUM_DRIVER']->get_member_from_username($username);
             if (($member_id === null) || (is_guest($member_id))) {
-                warn_exit(do_lang_tempcode('PASSWORD_RESET_ERROR_2'));
+                warn_exit(do_lang_tempcode('LOST_PASSWORD_ERROR_NO_MATCH'));
             }
         } else {
             $member_id = get_param_integer('member');
         }
+
+        // Load account details
         $correct_code = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_change_code');
+        $code_send_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_change_code_send_time');
+        $code_activation_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_change_code_activation_time');
+        $email = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_email_address');
+        $join_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_join_time');
+        $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);
+
+        // Error: LOST_PASSWORD_ERROR_NEVER_HAPPENED
         if ($correct_code == '') {
             if (get_member() == $member_id) { // Already reset and already logged in
                 $redirect_url = build_url(array('page' => 'members', 'type' => 'view', 'id' => $member_id), get_module_zone('members'), null, false, false, false, 'tab__edit__settings');
@@ -241,59 +250,60 @@ class Module_lost_password
 
             $_reset_url = build_url(array('page' => '_SELF', 'username' => $GLOBALS['FORUM_DRIVER']->get_username($member_id)), '_SELF');
             $reset_url = $_reset_url->evaluate();
-            warn_exit(do_lang_tempcode('PASSWORD_ALREADY_RESET', escape_html($reset_url), get_site_name()));
-        }
-        if (get_option('password_reset_process') == 'ultra') {
-            list($correct_code, $correct_session) = explode('__', $correct_code);
-            if ($correct_session != get_session_id()) {
-                warn_exit(do_lang_tempcode('WRONG_RESET_SESSION', escape_html(display_time_period(60 * 60 * intval(get_option('session_expiry_time'))))));
-            }
+            warn_exit(do_lang_tempcode('LOST_PASSWORD_ERROR_NEVER_HAPPENED', escape_html($reset_url), get_site_name()));
         }
+
+        // Error: LOST_PASSWORD_ERROR_INCORRECT_CODE
         if ($code != $correct_code) {
-            $test = $GLOBALS['SITE_DB']->query_select_value_if_there('actionlogs', 'date_and_time', array('the_type' => 'LOST_PASSWORD', 'param_a' => strval($member_id), 'param_b' => $code));
-            if (!is_null($test)) {
-                warn_exit(do_lang_tempcode('INCORRECT_PASSWORD_RESET_CODE')); // Just an old code that has expired
+            $test = $GLOBALS['SITE_DB']->query_select_value_if_there('actionlogs', 'date_and_time', array('the_type' => 'LOST_PASSWORD', 'param_a' => strval($member_id), 'param_b' => encode_lost_password_login_code($code)));
+            if ($test !== null) {
+                warn_exit(do_lang_tempcode('LOST_PASSWORD_ERROR_INCORRECT_CODE')); // Just an old code that has expired
             }
             log_hack_attack_and_exit('HACK_ATTACK_PASSWORD_CHANGE'); // Incorrect code, hack attack
         }
 
-        $email = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_email_address');
-        $join_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_join_time');
-        $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);
+        // Error: LOST_PASSWORD_ERROR_LINK_EXPIRED_AFTER_USE
+        if (($code_activation_time !== null) && (time() - $code_activation_time > intval(get_option('lost_password_expiry_after_use')))) {
+            warn_exit(do_lang_tempcode('LOST_PASSWORD_ERROR_LINK_EXPIRED_BEFORE_USE', display_time_period(intval(get_option('lost_password_expiry_after_use')))));
+        }
 
-        require_code('crypt');
-        $new_password = get_rand_password();
+        // Error: LOST_PASSWORD_ERROR_LINK_EXPIRED_BEFORE_USE
+        if (time() - $code_send_time > 60 * intval(get_option('lost_password_expiry_after_send'))) {
+            warn_exit(do_lang_tempcode('LOST_PASSWORD_ERROR_LINK_EXPIRED_BEFORE_USE', display_time_period(60 * intval(get_option('lost_password_expiry_after_use')))));
+        }
 
-        $temporary_passwords = (get_option('password_reset_process') != 'emailed');
-
-        if (!$temporary_passwords) {
-            // Send password in mail
-            $_login_url = build_url(array('page' => 'login', 'type' => 'browse', 'username' => $GLOBALS['FORUM_DRIVER']->get_username($member_id)), get_module_zone('login'), null, false, false, true);
-            $login_url = $_login_url->evaluate();
-            $account_edit_url = build_url(array('page' => 'members', 'type' => 'view'), get_module_zone('members'), null, false, false, true, 'tab__edit');
-            if (get_option('one_per_email_address') != '0') {
-                $lang_string = 'MAIL_NEW_PASSWORD_EMAIL_LOGIN';
-            } else {
-                $lang_string = 'MAIL_NEW_PASSWORD';
-            }
-            $message = do_lang($lang_string, comcode_escape($new_password), $login_url, array(comcode_escape(get_site_name()), comcode_escape($username), $account_edit_url->evaluate(), comcode_escape($email)));
-            require_code('mail');
-            mail_wrap(do_lang('LOST_PASSWORD_FINAL'), $message, array($email), $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), '', '', 3, null, false, null, false, false, false, 'MAIL', true, null, null, $join_time);
+        // Require POST request, in case of link checking tools etc
+        if (cms_srv('REQUEST_METHOD') != 'POST') {
+            return do_template('CONFIRM_SCREEN', array(
+                'TITLE' => $this->title,
+                'TEXT' => do_lang_tempcode('LOST_PASSWORD_CONFIRM', escape_html($username)),
+                'URL' => get_self_url(),
+                'HIDDEN' => '',
+                'FIELDS' => '',
+                'SKIP_BACK' => true,
+            ));
         }
 
-        if ((get_value('no_password_hashing') === '1') && (!$temporary_passwords)) {
+        // Randomise password
+        require_code('crypt');
+        $new_password = get_rand_password();
+        if (get_value('no_password_hashing') === '1') {
             $password_compatibility_scheme = 'plain';
             $new = $new_password;
         } else {
             require_code('crypt');
-            $password_compatibility_scheme = ($temporary_passwords ? 'temporary' : '');
+            $password_compatibility_scheme = '';
             $salt = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_pass_salt');
             $new = ratchet_hash($new_password, $salt);
         }
-
-        unset($_GET['code']);
-        $GLOBALS['FORUM_DB']->query_update('f_members', array('m_validated_email_confirm_code' => '', 'm_password_compat_scheme' => $password_compatibility_scheme, 'm_password_change_code' => '', 'm_pass_hash_salted' => $new), array('id' => $member_id), '', 1);
-
+        $update_map = array(
+            'm_validated_email_confirm_code' => '',
+            'm_password_compat_scheme' => $password_compatibility_scheme,
+            'm_password_change_code_activation_time' => time(),
+            'm_password_change_code_ip' => '',
+            'm_pass_hash_salted' => $new,
+        );
+        $GLOBALS['FORUM_DB']->query_update('f_members', $update_map, array('id' => $member_id), '', 1);
         $password_change_days = get_option('password_change_days');
         if (intval($password_change_days) > 0) {
             if ($password_compatibility_scheme == '') {
@@ -302,17 +312,27 @@ class Module_lost_password
             }
         }
 
-        if ($temporary_passwords) { // Log them in, then invite them to change their password
-            require_code('users_inactive_occasionals');
-            create_session($member_id, 1);
-
-            $redirect_url = build_url(array('page' => 'members', 'type' => 'view', 'id' => $member_id), get_module_zone('members'), null, false, false, false, 'tab__edit__settings');
-            $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);
-            $GLOBALS['FORCE_META_REFRESH'] = true; // Some browsers can't set cookies and redirect at the same time
-            return redirect_screen($this->title, $redirect_url, do_lang_tempcode('YOU_HAVE_TEMPORARY_PASSWORD', escape_html($username)));
+        // Mail the user so they know of the process
+        require_code('mail');
+        $subject = do_lang('LOST_PASSWORD_COMPLETE_MAIL_SUBJECT', get_site_name(), $username, null, get_lang($member_id));
+        $message = do_lang('LOST_PASSWORD_COMPLETE_MAIL', comcode_escape(get_site_name()), comcode_escape($username), array(get_ip_address()), get_lang($member_id));
+        mail_wrap($subject, $message, array($email), $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), '', '', 3, null, false, null, false, false, false, 'MAIL', true, null, null, $join_time);
+
+        // Log them in
+        require_code('users_inactive_occasionals');
+        create_session($member_id, 1);
+
+        // Redirect and invite them to change their password
+        $change_password_url = build_url(array('page' => 'members', 'type' => 'view'), get_module_zone('members'), null, false, false, false, 'tab__edit__settings');
+        if (get_option('lost_password_redirect_target') == 'post_login') {
+            $redirect_url = build_url(array('page' => ''), '');
+            $message = do_lang_tempcode('LOST_PASSWORD_LOGGED_IN_HOME', escape_html($username), escape_html($change_password_url->evaluate()));
+        } else {
+            $redirect_url = $change_password_url;
+            $message = do_lang_tempcode('LOST_PASSWORD_LOGGED_IN_CHANGE_PASSWORD', escape_html($username));
         }
-
-        // Email new password
-        return inform_screen($this->title, do_lang_tempcode('NEW_PASSWORD_MAILED', escape_html($email), escape_html($new_password)));
+        $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);
+        $GLOBALS['FORCE_META_REFRESH'] = true; // Some browsers can't set cookies and redirect at the same time
+        return redirect_screen($this->title, $redirect_url, $message);
     }
 }
diff --git a/lang/EN/cns.ini b/lang/EN/cns.ini
index 77b9741..70ae286 100644
--- a/lang/EN/cns.ini
+++ b/lang/EN/cns.ini
@@ -314,31 +318,6 @@ VIEW_USERGROUP={1}
 CLUB=Club
 GROUP_LED_BY=This usergroup is led by {1}.
 CNS_PROMOTION_INFO=On reaching {1} points, members are automatically promoted to {2}.
-_PASSWORD_RESET_TEXT_emailed=Don't worry, we can help you log back in. You must first enter your username <em>or</em> your e-mail address.<br /><br />An e-mail will be sent to your e-mail address (or if you are not allowed to have your password changed via e-mail communication you will receive a message about this). Upon receipt of the e-mail you may click a link in it, which will cause your password to be reset, with a new password sent in a second e-mail.
-_PASSWORD_RESET_TEXT_temporary=To reset your password, you must first enter your username <em>or</em> your e-mail address.<br /><br />An e-mail will be sent to your e-mail address (or if you are not allowed to have your password changed via e-mail communication you will receive a message about this). Upon receipt of the e-mail you may click a link in it, which will grant you a chance to change your password.
-_PASSWORD_RESET_TEXT_ultra=To reset your password, you must first enter your username <em>or</em> your e-mail address.<br /><br />An e-mail will be sent to your e-mail address (or if you are not allowed to have your password changed via e-mail communication you will receive a message about this). The e-mail will contain a confirmation code (and nothing else). Enter the confirmation code which will grant you a chance to change your password.
-PASSWORD_RESET_ERROR=You must either enter a username or an e-mail address.
-PASSWORD_RESET_ERROR_2=There is no such member on the system.
-PASSWORD_RESET_ULTRA_SUBJECT=Reset code
-PASSWORD_RESET_ULTRA_BODY={1}
-PASSWORD_RESET_ULTRA_FROM=Auth service
-PASSWORD_RESET_BUTTON=Send confirmation e-mail
-ENTER_CODE_FROM_EMAIL=Enter the confirmation code that you were just e-mailed (you may need to wait a few minutes).
-NO_RESET_ACCESS=This member is in a usergroup which is protected from passwords resets, for a higher level of security. If you have lost access to an admin account, see the software's &ldquo;Disaster recovery&rdquo; tutorial for lower-level ways to get back in.
-WRONG_RESET_SESSION=This password reset seemed to have been generated from another session, which has expired. We cannot accept the code. You will need to generate another one and and click it within {1}.
-MEMBER_NO_EMAIL_ADDRESS_RESET_TO=This member has no configured e-mail address to send a reset to, so unfortunately we cannot proceed.
-INCORRECT_PASSWORD_RESET_CODE=You have entered an incorrect reset code. You probably reached this screen, directly or indirectly, from the e-mail you received after choosing to have your password reset. The link in the e-mail contains the confirmation code; if you are viewing the e-mail in plain text make sure you go to the full link including the code portion. If you have done multiple resets then you must use the link/code from the most recent e-mail.
-PASSWORD_ALREADY_RESET=You have already confirmed your e-mail address and thus a new password has already been e-mailed to you. Please check your e-mail and use the new password to log back into {2}. If you do not receive a new password, please either contact the staff, or <a href="{1}">follow the reset process again</a>.
-LOST_PASSWORD_TEXT=Dear {2},\n\nBelow is the confirmation link to have a new password e-mailed to you:\n[url="{3}"]{3}[/url]\n\nIf you did not request this password change, please ignore this e-mail unless you have reason to believe that your account has been compromised.
-LOST_PASSWORD_TEXT_TEMPORARY=Someone has tried to reset your {1} password (username: {2}). If this was not you, then please ignore this e-mail (they may have just got the wrong username). If this was you, please click the following link, and a temporary login will be e-mailed to you (you'll need to choose a new password):\n[url="{3}"]{3}[/url]\n\nIf an error is returned when clicking that link you may try manually entering the details using the link below:\n[url="{4}"]{4}[/url]\nUsername: {2}\nCode: {6}
-MAIL_NEW_PASSWORD=The password for your account (username: {4}) has successfully been changed to '{1}'. You may log back into {3} from...\n\n[url="{2}"]{2}[/url]\n\n\nIf you wish to change your password to something more memorable you can do so by [url="{5}"]editing your account[/url].
-MAIL_NEW_PASSWORD_EMAIL_LOGIN=The password for your account ({6}) has successfully been changed to '{1}'. You may log back into {3} from...\n\n[url="{2}"]{2}[/url]\n\n\nIf you wish to change your password to something more memorable you can do so by [url="{5}"]editing your account[/url].
-RESET_CODE_MAILED=A reset link has been e-mailed to the specified member's e-mail address.
-NEW_PASSWORD_MAILED=A new random password has been e-mailed to {1}.
-NEW_PASSWORD=New password
-LOST_PASSWORD=Reset password
-LOST_PASSWORD_CONFIRM=Confirm password reset
-LOST_PASSWORD_FINAL=Your new password
 HACK_ATTACK_PASSWORD_CHANGE=It appears a member tried to fake the confirm code to change a member's password.
 DOUBLE_POST_PREVENTED=This post would have been the same as your previous post. It has been blocked.
 __JOIN=Join {1}
@@ -641,7 +624,6 @@ NEW_PT_NOTIFICATION_DETAILS={1} &ldquo;{2}&rdquo; sent by {3} on {4}
 ALREADY_VALIDATED=Already validated/Invalid code
 ALREADY_APPLIED_FOR_GROUP=Already applied for this usergroup
 ALREADY_IN_GROUP=Already in this usergroup
-EXT_NO_PASSWORD_CHANGE=This username is tied into an external architecture, and the password cannot be changed.
 DELETE_IF_EMPTY=Delete topic if emptied
 DESCRIPTION_DELETE_IF_EMPTY=Whether to delete the topic if all posts in it are being moved by this operation. If not all posts are being moved, this option has no effect.
 CHOOSE_JOIN_USERGROUP=Please select the account-type you wish for:
-NO_PASSWORD_RESET_HTTPAUTH=This is a single-sign-on member, so the password cannot be reset.
+NEW_PASSWORD=New password
diff --git a/lang/EN/cns_config.ini b/lang/EN/cns_config.ini
index 0188330..3b08ce4 100644
--- a/lang/EN/cns_config.ini
+++ b/lang/EN/cns_config.ini
@@ -140,7 +140,6 @@ PRIVILEGE_run_multi_moderations=Run multi-moderations (applies for those forums
 PRIVILEGE_may_choose_custom_title=May choose a custom title
 PRIVILEGE_view_other_pt=View other people's Private Topics and posts
 PRIVILEGE_use_quick_reply=Use quick reply
-PRIVILEGE_disable_lost_passwords=Member's passwords may not be reset using 'Forgot your password' feature
 PRIVILEGE_view_poll_results_before_voting=View poll results before voting
 PRIVILEGE_may_report_post=May report posts
 PRIVILEGE_use_pt=Create Private Topics
@@ -241,11 +240,6 @@ CONFIG_OPTION_minimum_password_strength_VALUE_7=7
 CONFIG_OPTION_minimum_password_strength_VALUE_8=8
 CONFIG_OPTION_minimum_password_strength_VALUE_9=9
 CONFIG_OPTION_minimum_password_strength_VALUE_10=10 (Strongest)
-PASSWORD_RESET_PROCESS=Password reset process
-CONFIG_OPTION_password_reset_process=All methods involved an e-mailed reset code. Set this to &ldquo;Temporary password&rdquo; if password resets should result in a temporary password (i.e. user must immediately choose a new password). Set this to &ldquo;E-mailed&rdquo; if a new password should be randomly generated and sent in another e-mail after the auth code check has passed (less secure). &ldquo;Ultra-secure&rdquo; is the same as &ldquo;Temporary password&rdquo; except that the e-mailed reset code is sent in an extremely basic format, to make it harder to see what website it might relate to; it requires your server supports raw PHP e-mail and it bypasses the e-mail queue.
-CONFIG_OPTION_password_reset_process_VALUE_emailed=E-mailed password
-CONFIG_OPTION_password_reset_process_VALUE_temporary=Temporary password
-CONFIG_OPTION_password_reset_process_VALUE_ultra=Ultra-secure
 USERNAMES_AND_PASSWORDS=Usernames and passwords
 INTRO_FORUM_ID=Intro Forum ID
 CONFIG_OPTION_intro_forum_id=If you want new members to be invited to introduce themselves on the signup form, select a forum here. Their topic will be created in this forum, and will be automatically validated.
lost_password_changes.diff (60,971 bytes)   
Time estimation (hours)8
Sponsorship open

Sponsor

Date Added Member Amount Sponsored

Activities

Add Note

View Status
Note
Upload Files
Maximum size: 32,768 KiB

Attach files by dragging & dropping, selecting or pasting them.
You are not logged in You are not logged in. This means you will not get any e-mail notifications. And if you reply, we will not know for sure you are the original poster of the issue.

Issue History

Date Modified Username Field Change
2021-11-03 02:25 Chris Graham New Issue
2021-11-03 02:25 Chris Graham Tag Attached: Has Patch
2021-11-03 02:25 Chris Graham Tag Attached: Roadmap: v12
2021-11-03 02:25 Chris Graham Tag Attached: Type: Security
2021-11-03 02:25 Chris Graham Time estimation (hours) => 8
2021-11-03 02:25 Chris Graham File Added: lost_password_changes.diff
2022-08-15 16:59 Chris Graham Tag Detached: Roadmap: v12
2022-08-15 16:59 Chris Graham Tag Attached: Roadmap: v11
2022-08-15 20:43 Chris Graham Assigned To => Chris Graham
2022-08-15 20:43 Chris Graham Status Not Assigned => Assigned
2022-11-20 02:58 Chris Graham Tag Detached: Roadmap: v11
2022-11-20 02:58 Chris Graham Tag Attached: Roadmap: v12
2022-11-20 03:06 Chris Graham Assigned To Chris Graham =>
2022-11-20 03:06 Chris Graham Status Assigned => Not Assigned
2024-03-26 00:58 PDStig Tag Renamed Roadmap: v12 => Roadmap: Over the horizon