From 36b1c74c91cbbfa5fa0fa5eb9d64be1f9c431e2d Mon Sep 17 00:00:00 2001
From: Chris Hallberg <crhallberg@gmail.com>
Date: Mon, 14 Apr 2014 15:39:55 -0400
Subject: [PATCH] Password change and recovery. - Resolves VUFIND-272 and
 VUFIND-296.

---
 config/vufind/config.ini                      |  13 +
 languages/en.ini                              |  20 ++
 module/VuFind/config/module.config.php        |  13 +-
 module/VuFind/sql/mysql.sql                   |   1 +
 module/VuFind/sql/postgres.sql                |   1 +
 .../VuFind/src/VuFind/Auth/AbstractBase.php   |  28 ++
 module/VuFind/src/VuFind/Auth/Database.php    |  82 +++++-
 module/VuFind/src/VuFind/Auth/Manager.php     |  56 +++-
 .../Controller/MyResearchController.php       | 246 ++++++++++++++++++
 module/VuFind/src/VuFind/Db/Row/User.php      |  13 +
 module/VuFind/src/VuFind/Db/Table/User.php    |  11 +
 .../src/VuFind/View/Helper/Root/Auth.php      |  26 ++
 themes/blueprint/css/styles.css               |  19 ++
 themes/blueprint/images/silk/cog.png          | Bin 0 -> 512 bytes
 themes/blueprint/images/silk/key_go.png       | Bin 0 -> 744 bytes
 themes/blueprint/images/silk/lock.png         | Bin 0 -> 749 bytes
 .../templates/Auth/AbstractBase/login.phtml   |   3 +
 .../templates/Auth/Database/newpassword.phtml |  12 +
 .../templates/Auth/Database/recovery.phtml    |   7 +
 .../blueprint/templates/myresearch/menu.phtml |   8 +
 .../templates/myresearch/newpassword.phtml    |  22 ++
 .../templates/myresearch/recover.phtml        |   9 +
 themes/bootprint/css/icons.css                |   3 +
 themes/bootprint/images/icons/cog.png         | Bin 0 -> 512 bytes
 themes/bootprint/images/icons/key_go.png      | Bin 0 -> 744 bytes
 themes/bootprint/images/icons/lock.png        | Bin 0 -> 749 bytes
 .../templates/Auth/AbstractBase/login.phtml   |   6 +-
 .../templates/Auth/Database/newpassword.phtml |  28 ++
 .../templates/Auth/Database/recovery.phtml    |  14 +
 .../bootstrap/templates/myresearch/menu.phtml |  28 +-
 .../templates/myresearch/newpassword.phtml    |  26 ++
 .../templates/myresearch/profile.phtml        |   2 +-
 .../templates/myresearch/recover.phtml        |   9 +
 .../templates/Auth/AbstractBase/login.phtml   |   3 +
 .../templates/Auth/Database/newpassword.phtml |  15 ++
 .../templates/Auth/Database/recovery.phtml    |  10 +
 .../templates/myresearch/footer-navbar.phtml  |   3 +
 .../templates/myresearch/newpassword.phtml    |  29 +++
 .../templates/myresearch/recover.phtml        |  27 ++
 .../templates/Email/recover-password.phtml    |   3 +
 40 files changed, 763 insertions(+), 33 deletions(-)
 create mode 100644 themes/blueprint/images/silk/cog.png
 create mode 100644 themes/blueprint/images/silk/key_go.png
 create mode 100644 themes/blueprint/images/silk/lock.png
 create mode 100644 themes/blueprint/templates/Auth/Database/newpassword.phtml
 create mode 100644 themes/blueprint/templates/Auth/Database/recovery.phtml
 create mode 100644 themes/blueprint/templates/myresearch/newpassword.phtml
 create mode 100644 themes/blueprint/templates/myresearch/recover.phtml
 create mode 100644 themes/bootprint/images/icons/cog.png
 create mode 100644 themes/bootprint/images/icons/key_go.png
 create mode 100644 themes/bootprint/images/icons/lock.png
 create mode 100644 themes/bootstrap/templates/Auth/Database/newpassword.phtml
 create mode 100644 themes/bootstrap/templates/Auth/Database/recovery.phtml
 create mode 100644 themes/bootstrap/templates/myresearch/newpassword.phtml
 create mode 100644 themes/bootstrap/templates/myresearch/recover.phtml
 create mode 100644 themes/jquerymobile/templates/Auth/Database/newpassword.phtml
 create mode 100644 themes/jquerymobile/templates/Auth/Database/recovery.phtml
 create mode 100644 themes/jquerymobile/templates/myresearch/newpassword.phtml
 create mode 100644 themes/jquerymobile/templates/myresearch/recover.phtml
 create mode 100644 themes/root/templates/Email/recover-password.phtml

diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 3bdefa43909..3df7cf9d036 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -216,6 +216,19 @@ hideLogin = false
 ; (only applies when method = Database above).
 hash_passwords = false
 
+; Allow users to recover passwords via email (if supported by Auth method)
+; You can set the subject of recovery emails in your
+; language files under the term "recovery_email_subject"
+recover_password = false
+; Time (seconds) before another recovery attempt can be made
+recover_interval      = 60
+; Length of time before a recovery hash can no longer be used (expires)
+; Default: Two weeks
+recover_hash_lifetime = 1209600
+
+; Allow users to set change their passwords (if supported by Auth method)
+change_password = true
+
 ; Set this to false if you would like to store catalog passwords in plain text
 encrypt_ils_password = false
 
diff --git a/languages/en.ini b/languages/en.ini
index c81f5be82a9..7786ee79333 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -146,6 +146,7 @@ cat_establish_account = "In order to establish your account profile, please ente
 cat_password_abbrev = "Catalog Password"
 cat_username_abbrev = "Catalog Username"
 CD = CD
+Change Password = "Change Password"
 Check Hold = "Check Hold"
 Check Recall = "Check Recall"
 Checked Out = "Checked Out"
@@ -185,6 +186,7 @@ confirm_hold_cancel_all_text = "Do you wish to cancel all your current holds?"
 confirm_hold_cancel_selected_text = "Do you wish to cancel your selected holds?"
 confirm_ill_request_cancel_all_text = "Do you wish to cancel all your current interlibrary loan requests?"
 confirm_ill_request_cancel_selected_text = "Do you wish to cancel your selected interlibrary loan requests?"
+confirm_new_password = "Confirm New Password"
 confirm_storage_retrieval_request_cancel_all_text = "Do you wish to cancel all your current storage retrieval requests?"
 confirm_storage_retrieval_request_cancel_selected_text = "Do you wish to cancel your selected storage retrieval requests?"
 Contents = Contents
@@ -199,6 +201,7 @@ course_reserves_empty_list = "No matching Course Reserves found."
 Cover Image = "Cover Image"
 Create a List = "Create a List"
 Create New Account = "Create New Account"
+Create New Password = "Create New Password"
 Created = Created
 Date = Date
 date_day_placeholder = "D"
@@ -525,6 +528,8 @@ New Item Search Results = "New Item Search Results"
 New Items = "New Items"
 New Title = "New Title"
 Newspaper = Newspaper
+new_password = "New Password"
+new_password_success = "Your password has successfully been changed"
 Next = Next
 No citations are available for this record = "No citations are available for this record"
 No Cover Image = "No Cover Image"
@@ -579,6 +584,7 @@ no_items_selected = "No Items were Selected"
 Number = Number
 OAI Server = "OAI Server"
 of = of
+old_password = "Old Password"
 On Reserve - Ask at Circulation Desk = "On Reserve - Ask at Circulation Desk"
 On Reserve = "On Reserve"
 Online Access = "Online Access"
@@ -612,6 +618,7 @@ Please contact the Library Reference Department for assistance = "Please contact
 Please enable JavaScript. = "Please enable JavaScript."
 Posted by = "Posted by"
 posted_on = "on"
+Preferences = Preferences
 Preferred Library = "Preferred Library"
 Prev = Prev
 Preview = "Preview"
@@ -639,6 +646,19 @@ Read the full review online... = "Read the full review online..."
 Recall This = "Recall This"
 Record Citations = "Record Citations"
 Record Count = "Record Count"
+recovery_by_email = "Recover by email"
+recovery_by_username = "Recover by username"
+recovery_disabled = "Password recovery not enabled"
+recovery_email_notification = "A request was just made to recover the password for your account with %%library%%."
+recovery_email_sent = "Password recovery instructions have been sent to the email address registered with this account."
+recovery_email_subject = "VuFind Account Recovery"
+recovery_email_url_pretext  = "Please navigate to this url to set a new password: %%url%%"
+recovery_expired_hash = "This recovery link has expired"
+recovery_invalid_hash = "Recovery link not recognized"
+recovery_new_disabled = "You are not allowed to change your password at this time"
+recovery_title  = "Password Recovery"
+recovery_too_soon  = "Too many recovery requests have been made, please try again later"
+recovery_user_not_found = "We could not find your account"
 Region = Region
 Related Author = "Related Author"
 Related Items = "Related Items"
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index cb6821b0acc..b60712b1ef3 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -580,13 +580,14 @@ $staticRoutes = array(
     'Install/FixSecurity', 'Install/FixSolr', 'Install/Home',
     'Install/PerformSecurityFix', 'Install/ShowSQL',
     'LibGuides/Home', 'LibGuides/Results',
-    'MyResearch/Account', 'MyResearch/CheckedOut', 'MyResearch/Delete',
-    'MyResearch/DeleteList', 'MyResearch/Edit', 'MyResearch/Email',
-    'MyResearch/Favorites', 'MyResearch/Fines',
+    'MyResearch/Account', 'MyResearch/ChangePassword', 'MyResearch/CheckedOut',
+    'MyResearch/Delete', 'MyResearch/DeleteList', 'MyResearch/Edit',
+    'MyResearch/Email', 'MyResearch/Favorites', 'MyResearch/Fines',
     'MyResearch/Holds', 'MyResearch/Home',
-    'MyResearch/ILLRequests',
-    'MyResearch/Logout', 'MyResearch/Profile',
-    'MyResearch/SaveSearch', 'MyResearch/StorageRetrievalRequests',
+    'MyResearch/ILLRequests', 'MyResearch/Logout',
+    'MyResearch/NewPassword', 'MyResearch/Profile',
+    'MyResearch/Recover', 'MyResearch/SaveSearch',
+    'MyResearch/StorageRetrievalRequests', 'MyResearch/Verify',
     'Primo/Advanced', 'Primo/Home', 'Primo/Search',
     'QRCode/Show', 'QRCode/Unavailable',
     'OAI/Server', 'Pazpar2/Home', 'Pazpar2/Search', 'Records/Home',
diff --git a/module/VuFind/sql/mysql.sql b/module/VuFind/sql/mysql.sql
index 954c5253e8d..5cfb9252dc3 100644
--- a/module/VuFind/sql/mysql.sql
+++ b/module/VuFind/sql/mysql.sql
@@ -178,6 +178,7 @@ CREATE TABLE `user` (
   `major` varchar(100) NOT NULL DEFAULT '',
   `home_library` varchar(100) NOT NULL DEFAULT '',
   `created` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
+  `verify_hash` varchar(42) NOT NULL DEFAULT '',
   PRIMARY KEY (`id`),
   UNIQUE KEY `username` (`username`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
diff --git a/module/VuFind/sql/postgres.sql b/module/VuFind/sql/postgres.sql
index eb4c419725a..2d8c9aff055 100644
--- a/module/VuFind/sql/postgres.sql
+++ b/module/VuFind/sql/postgres.sql
@@ -110,6 +110,7 @@ college varchar(100) NOT NULL DEFAULT '',
 major varchar(100) NOT NULL DEFAULT '',
 home_library varchar(100) NOT NULL DEFAULT '',
 created timestamp NOT NULL DEFAULT '1970-01-01 00:00:00',
+verify_hash varchar(42) NOT NULL DEFAULT '',
 PRIMARY KEY (id),
 UNIQUE (username)
 ); 
diff --git a/module/VuFind/src/VuFind/Auth/AbstractBase.php b/module/VuFind/src/VuFind/Auth/AbstractBase.php
index 464452378e5..54d74e5174c 100644
--- a/module/VuFind/src/VuFind/Auth/AbstractBase.php
+++ b/module/VuFind/src/VuFind/Auth/AbstractBase.php
@@ -144,6 +144,23 @@ abstract class AbstractBase implements \VuFind\Db\Table\DbTableAwareInterface
         );
     }
 
+    /**
+     * Update a user's password from the request.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object containing
+     * new account details.
+     *
+     * @throws AuthException
+     * @return \VuFind\Db\Row\User New user row.
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function updatePassword($request)
+    {
+        throw new AuthException(
+            'Account password updating not supported by ' . get_class($this)
+        );
+    }
+
     /**
      * Get the URL to establish a session (needed when the internal VuFind login
      * form is inadequate).  Returns false when no session initiator is needed.
@@ -184,6 +201,17 @@ abstract class AbstractBase implements \VuFind\Db\Table\DbTableAwareInterface
         return false;
     }
 
+    /**
+     * Does this authentication method support password changing
+     *
+     * @return bool
+     */
+    public function supportsPasswordChange()
+    {
+        // By default, password changing is not supported.
+        return false;
+    }
+
     /**
      * Does the class allow for authentication from more than one auth method?
      * If so return an array that lists the classes for the methods allowed.
diff --git a/module/VuFind/src/VuFind/Auth/Database.php b/module/VuFind/src/VuFind/Auth/Database.php
index 4938747b6d1..cbd231fd56b 100644
--- a/module/VuFind/src/VuFind/Auth/Database.php
+++ b/module/VuFind/src/VuFind/Auth/Database.php
@@ -119,18 +119,8 @@ class Database extends AbstractBase
         }
 
         // Validate Input
-        // Needs a username
-        if (trim($params['username']) == '') {
-            throw new AuthException('Username cannot be blank');
-        }
-        // Needs a password
-        if (trim($params['password']) == '') {
-            throw new AuthException('Password cannot be blank');
-        }
-        // Passwords don't match
-        if ($params['password'] != $params['password2']) {
-            throw new AuthException('Passwords do not match');
-        }
+        $this->validateUsernameAndPassword($params);
+
         // Invalid Email Check
         $validator = new \Zend\Validator\EmailAddress();
         if (!$validator->isValid($params['email'])) {
@@ -170,6 +160,64 @@ class Database extends AbstractBase
         return $table->getByUsername($params['username'], false);
     }
 
+    /**
+     * Update a user's password from the request.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object containing
+     * new account details.
+     *
+     * @throws AuthException
+     * @return \VuFind\Db\Row\User New user row.
+     */
+    public function updatePassword($request)
+    {
+        // Ensure that all expected parameters are populated to avoid notices
+        // in the code below.
+        $params = array(
+            'username' => '', 'password' => '', 'password2' => ''
+        );
+        foreach ($params as $param => $default) {
+            $params[$param] = $request->getPost()->get($param, $default);
+        }
+        
+        // Validate Input
+        $this->validateUsernameAndPassword($params);
+        
+        // Create the row and send it back to the caller:
+        $table = $this->getUserTable();
+        $user = $table->getByUsername($params['username'], false);
+        if ($this->passwordHashingEnabled()) {
+            $bcrypt = new Bcrypt();
+            $user->pass_hash = $bcrypt->create($params['password']);
+        } else {
+            $user->password = $params['password'];
+        }
+        $user->save();
+        return $user;
+    }
+
+    /**
+     * Make sure username and password aren't blank
+     * Make sure passwords match
+     *
+     * @param array $params
+     */
+    protected function validateUsernameAndPassword($params)
+    {
+        // Needs a username
+        if (trim($params['username']) == '') {
+            throw new AuthException('Username cannot be blank');
+        }
+        // Needs a password
+        if (trim($params['password']) == '') {
+            throw new AuthException('Password cannot be blank');
+        }
+        // Passwords don't match
+        if ($params['password'] != $params['password2']) {
+            throw new AuthException('Passwords do not match');
+        }
+    }
+
     /**
      * Check that the user's password matches the provided value.
      *
@@ -240,4 +288,14 @@ class Database extends AbstractBase
     {
         return true;
     }
+
+    /**
+     * Does this authentication method support password changing
+     *
+     * @return bool
+     */
+    public function supportsPasswordChange()
+    {
+        return true;
+    }
 }
\ No newline at end of file
diff --git a/module/VuFind/src/VuFind/Auth/Manager.php b/module/VuFind/src/VuFind/Auth/Manager.php
index b27a2968e2c..f217c64488f 100644
--- a/module/VuFind/src/VuFind/Auth/Manager.php
+++ b/module/VuFind/src/VuFind/Auth/Manager.php
@@ -156,7 +156,7 @@ class Manager implements ServiceLocatorAwareInterface
     /**
      * Does the current configuration support account creation?
      *
-     *@param string $authMethod optional; check this auth method rather than 
+     * @param string $authMethod optional; check this auth method rather than
      *  the one in config file
      *
      * @return bool
@@ -169,6 +169,43 @@ class Manager implements ServiceLocatorAwareInterface
         return $this->getAuth()->supportsCreation();
     }
 
+    /**
+     * Does the current configuration support password recovery?
+     *
+     * @param string $authMethod optional; check this auth method rather than 
+     *  the one in config file
+     *
+     * @return bool
+     */
+    public function supportsRecovery($authMethod=null)
+    {
+        if ($authMethod != null) {
+            $this->setActiveAuthClass($authMethod);
+        }
+        if ($this->getAuth()->supportsPasswordChange()) {
+            return isset($this->config->Authentication->recover_password)
+                && $this->config->Authentication->recover_password;
+        }
+        return false;
+    }
+
+    /**
+     * Is new passwords currently allowed?
+     *
+     * @return bool
+     */
+    public function supportsPasswordChange($authMethod=null)
+    {
+        if ($authMethod != null) {
+            $this->setActiveAuthClass($authMethod);
+        }
+        if ($this->getAuth()->supportsPasswordChange()) {
+            return isset($this->config->Authentication->change_password)
+                && $this->config->Authentication->change_password;
+        }
+        return false;
+    }
+
     /**
      * Get the URL to establish a session (needed when the internal VuFind login
      * form is inadequate).  Returns false when no session initiator is needed.
@@ -385,6 +422,22 @@ class Manager implements ServiceLocatorAwareInterface
         return $user;
     }
 
+    /**
+     * Update a user's password from the request.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object containing
+     * new account details.
+     *
+     * @throws AuthException
+     * @return \VuFind\Db\Row\User New user row.
+     */
+    public function updatePassword($request)
+    {
+        $user = $this->getAuth()->updatePassword($request);
+        $this->updateSession($user);
+        return $user;
+    }
+
     /**
      * Try to log in the user using current query parameters; return User object
      * on success, throws exception on failure.
@@ -528,5 +581,4 @@ class Manager implements ServiceLocatorAwareInterface
         $this->authToProxy = $method;
         $this->authProxied = false;
     }
-
 }
diff --git a/module/VuFind/src/VuFind/Controller/MyResearchController.php b/module/VuFind/src/VuFind/Controller/MyResearchController.php
index 3713c052146..2fcfe668c94 100644
--- a/module/VuFind/src/VuFind/Controller/MyResearchController.php
+++ b/module/VuFind/src/VuFind/Controller/MyResearchController.php
@@ -28,6 +28,7 @@
 namespace VuFind\Controller;
 
 use VuFind\Exception\Auth as AuthException,
+    VuFind\Exception\Mail as MailException,
     VuFind\Exception\ListPermission as ListPermissionException,
     VuFind\Exception\RecordMissing as RecordMissingException,
     Zend\Stdlib\Parameters;
@@ -1109,4 +1110,249 @@ class MyResearchController extends AbstractBase
         $url = $this->getServerUrl('myresearch-home');
         return $this->getAuthManager()->getSessionInitiator($url);
     }
+
+    /**
+     * Send account recovery email
+     *
+     * @return View object
+     */
+    public function recoverAction()
+    {
+        // Make sure we're configured to do this
+        if (!$this->getAuthManager()->supportsRecovery()) {
+            $this->flashMessenger()->setNamespace('error')
+                ->addMessage('recovery_disabled');
+            return $this->redirect()->toRoute('myresearch-home');
+        }
+        if ($this->getUser()) {
+            return $this->redirect()->toRoute('myresearch-home');
+        }
+        // Database
+        $table = $this->getTable('User');
+        $user = false;
+        // Check if we have a submitted form, and use the information to get
+        // the user's information
+        if ($email = $this->params()->fromPost('email')) {
+            $user = $table->getByEmail($email);
+        } elseif ($username = $this->params()->fromPost('username')) {
+            $user = $table->getByUsername($username, false);
+        }
+        // If we have a submitted form
+        if (false != $user) {
+            $this->sendRecoveryEmail($user, $this->getConfig());
+        } else if ($this->params()->fromPost('submit')) {
+            $this->flashMessenger()->setNamespace('error')
+                ->addMessage('recovery_user_not_found');
+        }
+        return $this->createViewModel();
+    }
+
+    /**
+     * Helper function for recoverAction
+     *
+     * @param \VuFind\Db\Row\User $user   User object we're recovering
+     * @param \VuFind\Config      $config Configuration object
+     *
+     * @return void (sends email or adds error message)
+     */
+    protected function sendRecoveryEmail($user, $config)
+    {
+        // If we can't find a user
+        if (null == $user) {
+            $this->flashMessenger()->setNamespace('error')
+                ->addMessage('recovery_user_not_found');
+        } else {
+            // Make sure we've waiting long enough
+            $hashtime = $this->getHashAge($user->verify_hash);
+            $recoveryInterval = isset($config->Authentication->recover_interval)
+                ? $config->Authentication->recover_interval
+                : 60;
+            if (time()-$hashtime < $recoveryInterval) {
+                $this->flashMessenger()->setNamespace('error')
+                    ->addMessage('recovery_too_soon');
+            } else {
+                // Attempt to send the email
+                try {
+                    // Create a fresh hash
+                    $user->updateHash();
+                    $config = $this->getConfig();
+                    $renderer = $this->getViewRenderer();
+                    // Custom template for emails (text-only)
+                    $message = $renderer->render(
+                        'Email/recover-password.phtml',
+                        array(
+                            'library' => $config->Site->title,
+                            'url' => $this->getServerUrl('myresearch-verify')
+                                . '?hash='
+                                . $user->verify_hash
+                        )
+                    );
+                    $this->getServiceLocator()->get('VuFind\Mailer')->send(
+                        $user->email,
+                        $config->Site->email,
+                        $this->translate('recovery_email_subject'),
+                        $message
+                    );
+                    $this->flashMessenger()->setNamespace('info')
+                        ->addMessage('recovery_email_sent');
+                } catch (MailException $e) {
+                    $this->flashMessenger()->setNamespace('error')
+                        ->addMessage($e->getMessage());
+                }
+            }
+        }
+    }
+
+    /**
+     * Receive a hash and display the new password form if it's valid
+     *
+     * @return view
+     */
+    public function verifyAction()
+    {
+        // If we have a submitted form
+        $hash = $this->params()->fromQuery('hash');
+        // Submitted form
+        if (null != $hash) {
+            $hashtime = $this->getHashAge($hash);
+            $config = $this->getConfig();
+            // Check if hash is expired
+            $hashLifetime = isset($config->Authentication->recover_hash_lifetime)
+                ? $config->Authentication->recover_hash_lifetime
+                : 1209600; // Two weeks
+            if (time()-$hashtime > $hashLifetime) {
+                $this->flashMessenger()->setNamespace('error')
+                    ->addMessage('recovery_expired_hash');
+                return $this->forwardTo('MyResearch', 'Login');
+            } else {
+                $table = $this->getTable('User');
+                $user = $table->getByVerifyHash($hash);
+                // If the hash is valid, forward user to create new password
+                if (null != $user) {
+                    $view = $this->createViewModel();
+                    $view->hash = $hash;
+                    $view->username = $user->username;
+                    $view->setTemplate('myresearch/newpassword');
+                    return $view;
+                }
+            }
+        }
+        $this->flashMessenger()->setNamespace('error')
+            ->addMessage('recovery_invalid_hash');
+        return $this->forwardTo('MyResearch', 'Login');
+    }
+
+    /**
+     * Handling submission of a new password for a user.
+     *
+     * @return view
+     */
+    public function newPasswordAction()
+    {
+        // Have we submitted the form?
+        if (!$this->params()->fromPost('submit', false)) {
+            return $this->redirect()->toRoute('home');
+        }
+        // Pull in from POST
+        $request = $this->getRequest();
+        $post = $request->getPost();
+        // Verify hash
+        $userFromHash = isset($post->hash)
+            ? $this->getTable('User')->getByVerifyHash($post->hash)
+            : false;
+        // Missing or invalid hash
+        if (false == $userFromHash) {
+            $this->flashMessenger()->setNamespace('error')
+                ->addMessage('recovery_user_not_found');
+            // Force login or restore hash
+            return $this->forwardTo('MyResearch', 'ChangePassword');
+        } else if ($userFromHash->username !== $post->username) {
+            $this->flashMessenger()->setNamespace('error')
+                ->addMessage('authentication_error_invalid');
+            $userFromHash->updateHash();
+            $post->username = $userFromHash->username;
+            $post->hash = $userFromHash->verify_hash;
+            return $this->createViewModel($post);
+        }
+        // Verify old password if we're logged in
+        if ($this->getUser()) {
+            if (isset($post->oldpwd)) {
+                try {
+                    // Reassign oldpwd to password in the request so login works
+                    $temp_password = $post->password;
+                    $post->password = $post->oldpwd;
+                    $authClass = $this->getAuthManager()->login($request);
+                    $post->password = $temp_password;
+                } catch(AuthException $e) {
+                    $this->flashMessenger()->setNamespace('error')
+                        ->addMessage($e->getMessage());
+                    return $this->createViewModel(
+                        $this->params()->fromPost()
+                    );
+                }
+            } else {
+                $this->flashMessenger()->setNamespace('error')
+                    ->addMessage('authentication_error_invalid');
+                $post['verifyold'] = true;
+                return $this->createViewModel($post);
+            }
+        }
+        // Update password
+        try {
+            $user = $this->getAuthManager()->updatePassword($this->getRequest());
+        } catch (AuthException $e) {
+            $this->flashMessenger()->setNamespace('error')
+                ->addMessage($e->getMessage());
+            return $this->createViewModel(
+                $this->params()->fromPost()
+            );
+        }
+        // Update hash to prevent reusing hash
+        $user->updateHash();
+        // Login
+        $this->getAuthManager()->login($this->request);
+        // Go to favorites
+        $this->flashMessenger()->setNamespace('info')
+            ->addMessage('new_password_success');
+        return $this->redirect()->toRoute('myresearch-home');
+    }
+
+    /**
+     * Handling submission of a new password for a user.
+     *
+     * @return view
+     */
+    public function changePasswordAction()
+    {
+        if (!$this->getAuthManager()->isLoggedIn()) {
+            return $this->forceLogin();
+        }
+        // If not submitted, are we logged in?
+        if (!$this->getAuthManager()->supportsPasswordChange()) {
+            $this->flashMessenger()->setNamespace('error')
+                ->addMessage('recovery_new_disabled');
+            return $this->redirect()->toRoute('home');
+        }
+        $view = $this->createViewModel($this->params()->fromPost());
+        // Verify user password
+        $view->verifyold = true;
+        // Display username
+        $user = $this->getUser();
+        $view->username = $user->username;
+        // Identification
+        $user->updateHash();
+        $view->hash = $user->verify_hash;
+        $view->setTemplate('myresearch/newpassword');
+        return $view;
+    }
+
+    /**
+     * Helper function for verification hashes
+     *
+     * @return int age in seconds
+     */
+    protected function getHashAge($hash)
+    {
+        return intval(substr($hash, -10));
+    }
 }
diff --git a/module/VuFind/src/VuFind/Db/Row/User.php b/module/VuFind/src/VuFind/Db/Row/User.php
index dbc1d5bc4f5..aac4c06cc44 100644
--- a/module/VuFind/src/VuFind/Db/Row/User.php
+++ b/module/VuFind/src/VuFind/Db/Row/User.php
@@ -406,4 +406,17 @@ class User extends ServiceLocatorAwareGateway
         // Remove the user itself:
         return parent::delete();
     }
+
+    /**
+     * Update the verification hash for this user
+     *
+     * @return bool save success
+     */
+    public function updateHash()
+    {
+        $this->verify_hash = md5(
+            $this->username . $this->password . $this->pass_hash . rand()
+        ) . (time() % pow(10,10));
+        return $this->save();
+    }
 }
diff --git a/module/VuFind/src/VuFind/Db/Table/User.php b/module/VuFind/src/VuFind/Db/Table/User.php
index 6174b4a2c58..0f91d6a906d 100644
--- a/module/VuFind/src/VuFind/Db/Table/User.php
+++ b/module/VuFind/src/VuFind/Db/Table/User.php
@@ -93,4 +93,15 @@ class User extends Gateway
         };
         return $this->select($callback);
     }
+
+    /**
+     * Return a row by a verification hash
+     *
+     * @return mixed
+     */
+    public function getByVerifyHash($hash)
+    {
+        $row = $this->select(array('verify_hash' => $hash))->current();
+        return $row;
+    }
 }
diff --git a/module/VuFind/src/VuFind/View/Helper/Root/Auth.php b/module/VuFind/src/VuFind/View/Helper/Root/Auth.php
index 9c852d6632c..f2286308dea 100644
--- a/module/VuFind/src/VuFind/View/Helper/Root/Auth.php
+++ b/module/VuFind/src/VuFind/View/Helper/Root/Auth.php
@@ -227,4 +227,30 @@ class Auth extends \Zend\View\Helper\AbstractHelper
         $classParts = explode('\\', $className);
         return array_pop($classParts);
     }
+
+    
+    /**
+     * Render the new password form template.
+     *
+     * @param array $context Context for rendering template
+     *
+     * @return string
+     */
+    public function getNewPasswordForm($context = array())
+    {
+        return $this->renderTemplate('newpassword.phtml', $context);
+    }
+
+    
+    /**
+     * Render the password recovery form template.
+     *
+     * @param array $context Context for rendering template
+     *
+     * @return string
+     */
+    public function getPasswordRecoveryForm($context = array())
+    {
+        return $this->renderTemplate('recovery.phtml', $context);
+    }
 }
diff --git a/themes/blueprint/css/styles.css b/themes/blueprint/css/styles.css
index 49f518ec79a..af0baeb04de 100644
--- a/themes/blueprint/css/styles.css
+++ b/themes/blueprint/css/styles.css
@@ -647,6 +647,18 @@ input.bookbagEmpty {
     background-position: left;
     padding:.5em .5em .5em 20px;
 }
+.forgot_password {
+    background-image:url(../images/silk/key_go.png);
+    background-repeat:no-repeat;
+    background-position: left;
+    padding:.5em .5em .5em 20px;
+}
+.lock {
+    background-image:url(../images/silk/lock.png);
+    background-repeat:no-repeat;
+    background-position: left;
+    padding:.5em .5em .5em 20px;
+}
 .cite {
     background-image:url(../images/silk/report.png);
     background-repeat:no-repeat;
@@ -724,6 +736,13 @@ input.bookbagEmpty {
     padding:.5em .5em .5em 20px;
     /*margin-left:1em;*/
 }
+.gear {
+    background-image:url(../images/silk/cog.png);
+    background-repeat:no-repeat;
+    background-position: left;
+    padding:.6em .5em .5em 20px;
+    /*margin-left:1em;*/
+}
 h3.list {
     padding-bottom:0;
     margin-bottom:0;
diff --git a/themes/blueprint/images/silk/cog.png b/themes/blueprint/images/silk/cog.png
new file mode 100644
index 0000000000000000000000000000000000000000..67de2c6ccbeac17742f56cf7391e72b2bf5033ba
GIT binary patch
literal 512
zcmV+b0{{JqP)<h;3K|Lk000e1NJLTq000mG000mO1ONa4wfZ;e00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzl1W5CR4C6?
zQB6w%VGv&Y0p9%y{*S$M5weR^mokV7i!Mcpk=YVa(4jh%5k+ND8>CQDsH?WF>AIFt
zQuJ}i;w2$ZUU#3SZ6RY0Gw;kZ&ol1~2ky^QZ(fom$=jN<T*X<otG0ao1Md*)XSSIA
z*x3T8gv)x7DUK|A#S3CA>JZt!z7w_pH~wdQ;R)Gh%BbQFCx+Nm!4SuS-vkr`vhhrX
zM*>w%e+v~?m@q~ImPAgtLkR_3U<2F8LP3W5=LJ*ZN|S5p#sf4YFr$p~Q~Z*0Ngxf2
zjk#J#<7EAlhzlrV53~GF&pIzcCN_lz9@05Ue<MwWI-*!M0jvB0F~pIke2>oUXiK%N
z#x+4o*i_c|6_Uu1+&TIho?3@y4k-#b8Y_o94zW*B3a1ne2-Y5s0uke$$|@=}OP-i=
zNYZQA=>PrZu0MfSL=b8UhD_={W4IY1{b{)U)*gc45xtL%IYLY&hF;d`@GzI&7H&D#
zh;z_BX$#hqh@q?AY3sJTod2%*Yd)_>YM0#q&ixGuh+PQsneK)F0000<MNUMnLSTXw
CAKTXe

literal 0
HcmV?d00001

diff --git a/themes/blueprint/images/silk/key_go.png b/themes/blueprint/images/silk/key_go.png
new file mode 100644
index 0000000000000000000000000000000000000000..30b0dc316e52dba388d88112d4c1cc32672fffbb
GIT binary patch
literal 744
zcmV<E0vG*>P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!dPzh<R5;6x
zlU+!YVHk$LRU{on2NEG99t4gN<(k<vgSIj~$mk$~eksv@gmxl=Ow(;Fr^B3}b5Lr_
z2G&-MqzD~`qBeg)s5vYXON+MJpPSp<&)xfdS}mK_2lvVQa$nE=JkR@40I2o!$#_If
zgcYe*-~X369QeQ}9^~I<-#CKyR)jn~<T%PeX7qx)>jGlUfiHCke$)P}4*HwX@?mb`
zzgiE#M5fLDxtj=lZ6q=+Lkt2$!wyVqxC~@X03De&Gn$t$kdWJig()R`(|L#ldNHNi
zi?tK@-;xA1zab1r3XkO&T;m5wdrx2~XU8>fpl4v~7ZF1h+!NXGy*rQ6jw}?mrNKGI
zL&zzIrIL-VTRiLE`+jy5wt-SCISiy4pO}x6>V>$`&V{7&3{GoOF)87oTao_ek0H|L
zXxL5q?8f4p7$RLZL=Q4>?LH3$EqhS@^b{VAG@wL(0y*{DquI)BECvw!(t<WU7OeSj
z4slT2o&n>y8jr^s8DqzY3Mx|xw6AM%RhNVG>V(j48H=@2*+n87;k63kFsH&hc^Czx
zU)p@TON5%2#gM-!LRIG_NS|MUrcZ`*_YPuLB^9Iro+W2D85SRofmAHM&xik`9B1#a
z@o-oLow*L$!CJHqC<x>_n){?E(&Zwhf|^V!qqZ#X+&c)jMMwsg3*W31W<{FoWOGV1
zuOTTStWS(&DYr&0v}HowTZPN*IY_RcCU%rj3Cs*;4T3Shy&sFSmGFOV!%!{P*`>;C
zSiN43jAg&56(U(ojS}<bU;n~z%OUZEdjI^WYM*^X$^G8flvN$?agoUOo#Ks1ETcBX
ap8o(~AJmyDx~^sb0000<MNUMnLSTY)_fHN0

literal 0
HcmV?d00001

diff --git a/themes/blueprint/images/silk/lock.png b/themes/blueprint/images/silk/lock.png
new file mode 100644
index 0000000000000000000000000000000000000000..2ebc4f6f9663e32cad77d67ef93ab8843dfea3c0
GIT binary patch
literal 749
zcmV<J0uud+P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!e@R3^R5;6R
zQbB7IK^T3Tq)8K-7_~JtN==(+K|#b@JqUt5h~h!Lc~ek?Ku+zk2XpMN@Q@-NdiDp1
z1*uxlMiZNsG$@o5lP0D~c6MfcvuoP5I`A<w-+bSj_uh<Q+cvyoX=!OhDK#ghoMD_~
zfbo;DVp-N=E|>e|tv9>?g+k#9o0pTx<YX)sgU{y!_vrO{sMqV*;vmqy`T6;^e*oA#
z!o!d0bUI_2CTg`BI-QQb9f3dqiA2JwD;A3z%w1ksSm^4#Z-B()v+?oqj1U6la(T1e
zZl|~o>d@;_sq{kwlU;^VvV*?BV8P@}BoaZTQUROpWV6|-M`|^n&)=+8tHo3*<<$NU
zU`%V~ZF;?hBSYsjJ6%JzV}E(D{pOLqQklliUf9um_tGl-wty`y*p?eYNW56P>X@1s
zZs7KrRZKtmV7Lqj^5Fgr7_`LjhdJK@ltF&O`j7?*NUM$KvmNGz)3WjM?V$vHlP<J&
zUm*}0g<*`aa0m#;nO4C59%Snq%<gw6YaijsENrvy0U$*veUpji`g`g;hWN#6sJ&if
z|7lEIpGEWQIsqDprcRKsge^=jfN*5kq#B>T0AFyF?kLE<#HZabCSW3-o<y$`V(q@e
zY5?H;1Doz@RIRn~d5tXI@x+4aDfGLfYLi*%3!3F^SFTb{&mjZ7(WsOVKc9j>a*6;Z
zrXD`Ulwd<^2glP%1Y1Kc1Ij%DU^=ME(jKf6APNlA$Uu;J4bVilQHSWX5uJ$9Zsp4M
z0%!@LvyTxz=Z6stxlichODIY+yNGt%RM;m`>H4LOKLFs9Y%b5aUN|2|{0Zw|<_~i}
fmXz*V19AKYa<a0`hi}0}00000NkvXXu0mjf@;P6V

literal 0
HcmV?d00001

diff --git a/themes/blueprint/templates/Auth/AbstractBase/login.phtml b/themes/blueprint/templates/Auth/AbstractBase/login.phtml
index 30df536ea06..ee38214e7d5 100644
--- a/themes/blueprint/templates/Auth/AbstractBase/login.phtml
+++ b/themes/blueprint/templates/Auth/AbstractBase/login.phtml
@@ -15,6 +15,9 @@
   <? if ($account->supportsCreation()): ?>
     <a class="new_account" href="<?=$this->url('myresearch-account')?>?auth_method=<?=$this->auth()->getActiveAuthMethod()?>"><?=$this->transEsc('Create New Account')?></a>
   <? endif; ?>
+  <? if ($account->supportsRecovery()): ?>
+    <a class="forgot_password" href="<?=$this->url('myresearch-recover')?>"><?=$this->transEsc('Forgot Password')?></a>
+  <? endif; ?>
 <? else: ?>
   <a href="<?=$this->escapeHtml($sessionInitiator)?>"><?=$this->transEsc("Institutional Login")?></a>
 <? endif; ?>
diff --git a/themes/blueprint/templates/Auth/Database/newpassword.phtml b/themes/blueprint/templates/Auth/Database/newpassword.phtml
new file mode 100644
index 00000000000..a5c74c252e1
--- /dev/null
+++ b/themes/blueprint/templates/Auth/Database/newpassword.phtml
@@ -0,0 +1,12 @@
+<? if (isset($this->username)): ?>
+  <label class="span-4"><?=$this->transEsc('Username') ?>:</label>
+  <input type="text" disabled value="<?=$this->username ?>"/><br/>
+<? endif; ?>
+<? if (isset($this->verifyold) && $this->verifyold || isset($this->oldpwd)): ?>
+  <label class="span-4"><?=$this->transEsc('old_password') ?>:</label>
+  <input type="password" name="oldpwd"/><br/>
+<? endif; ?>
+<label class="span-4"><?=$this->transEsc('new_password') ?>:</label>
+<input type="password" name="password"/><br/>
+<label class="span-4"><?=$this->transEsc('confirm_new_password') ?>:</label>
+<input type="password" name="password2"/><br/>
\ No newline at end of file
diff --git a/themes/blueprint/templates/Auth/Database/recovery.phtml b/themes/blueprint/templates/Auth/Database/recovery.phtml
new file mode 100644
index 00000000000..359cc4f965d
--- /dev/null
+++ b/themes/blueprint/templates/Auth/Database/recovery.phtml
@@ -0,0 +1,7 @@
+<label class="span-4"><?=$this->transEsc('recovery_by_username') ?>:</label>
+<input type="text" name="username"/>
+<input type="submit" name="submit"/>
+<br/><br/>
+<label class="span-4"><?=$this->transEsc('recovery_by_email') ?>:</label>
+<input type="email" name="email"/>
+<input type="submit" name="submit"/>
\ No newline at end of file
diff --git a/themes/blueprint/templates/myresearch/menu.phtml b/themes/blueprint/templates/myresearch/menu.phtml
index 2c316aadcd8..a1b6c496e0d 100644
--- a/themes/blueprint/templates/myresearch/menu.phtml
+++ b/themes/blueprint/templates/myresearch/menu.phtml
@@ -18,6 +18,14 @@
     <? endif; ?>
     <li<?=$this->active == 'history' ? ' class="active"' : ''?>><a href="<?=$this->url('search-history')?>?require_login"><?=$this->transEsc('history_saved_searches')?></a></li>
   </ul>
+  <? if ($this->auth()->isLoggedIn() && $this->auth()->getManager()->supportsPasswordChange()): ?>
+    <h4 class="gear"><?=$this->transEsc('Preferences')?></h4>
+    <ul>
+      <li>
+        <a href="<?=$this->url('myresearch-changepassword') ?>"><?=$this->transEsc('Change Password') ?></a>
+      </li>
+    </ul>
+  <? endif; ?>
   <? if ($this->userlist()->getMode() !== 'disabled' && $user = $this->auth()->isLoggedIn()): ?>
     <h4 class="list"><?=$this->transEsc('Your Lists')?></h4>
     <ul>
diff --git a/themes/blueprint/templates/myresearch/newpassword.phtml b/themes/blueprint/templates/myresearch/newpassword.phtml
new file mode 100644
index 00000000000..41741c051c3
--- /dev/null
+++ b/themes/blueprint/templates/myresearch/newpassword.phtml
@@ -0,0 +1,22 @@
+<div class="<?=$this->layoutClass('mainbody')?>">
+  <h2><?=$this->transEsc('Create New Password') ?></h2>
+  <?=$this->flashmessages() ?>
+  <? if (!$this->auth()->getManager()->supportsPasswordChange()): ?>
+    <div class="error"><?=$this->transEsc('recovery_new_disabled') ?></div>
+  <? elseif (!isset($this->hash)): ?>
+    <div class="error"><?=$this->transEsc('recovery_user_not_found') ?></div>
+  <? else: ?>
+    <form action="<?=$this->url('myresearch-newpassword') ?>" method="post">
+      <?=$this->auth()->getNewPasswordForm() ?>
+      <input type="hidden" value="<?=$this->hash ?>" name="hash"/>
+      <input type="hidden" value="<?=$this->username ?>" name="username"/>
+      <input name="submit" type="submit"/>
+    </form>
+  <? endif; ?>
+</div>
+
+<? if ($this->auth()->isLoggedIn()): ?>
+  <div class="<?=$this->layoutClass('sidebar')?>">
+    <?=$this->context($this)->renderInContext("myresearch/menu.phtml", array('active' => 'newpassword'))?>
+  </div>
+<? endif; ?>
\ No newline at end of file
diff --git a/themes/blueprint/templates/myresearch/recover.phtml b/themes/blueprint/templates/myresearch/recover.phtml
new file mode 100644
index 00000000000..afffcc651e8
--- /dev/null
+++ b/themes/blueprint/templates/myresearch/recover.phtml
@@ -0,0 +1,9 @@
+<h2><?=$this->transEsc('Password Recovery') ?></h2>
+<?=$this->flashmessages()?>
+<? if (!$this->auth()->getManager()->supportsRecovery()): ?>
+  <div class="error"><?=$this->transEsc('recovery_disabled') ?></div>
+<? else: ?>
+  <form action="" method="post">
+    <?=$this->auth()->getPasswordRecoveryForm() ?>
+  </form>
+<? endif; ?>
\ No newline at end of file
diff --git a/themes/bootprint/css/icons.css b/themes/bootprint/css/icons.css
index 29565471d61..d5b276b922f 100644
--- a/themes/bootprint/css/icons.css
+++ b/themes/bootprint/css/icons.css
@@ -23,6 +23,7 @@ i.icon-home,
 i.icon-inbox,
 i.icon-leaf,
 i.icon-list-alt,
+i.icon-lock,
 i.icon-minus-sign,
 i.icon-ok,
 i.icon-phone-sign,
@@ -102,6 +103,7 @@ i.icon-user {
 .small i.icon-inbox,
 .small i.icon-leaf,
 .small i.icon-list-alt,
+.small i.icon-lock,
 .small i.icon-minus-sign,
 .small i.icon-ok,
 .small i.icon-phone-sign,
@@ -154,6 +156,7 @@ i.icon-home {background:url('../images/icons/house.png')}
 i.icon-inbox {background:url('../images/icons/box.png')}
 i.icon-leaf,.icon-sitemap {background:url('../images/icons/treeCurrent.png')}
 i.icon-list-alt,i.icon-export {background:url('../images/icons/application_add.png')}
+i.icon-lock {background:url('../images/icons/lock.png')}
 i.icon-minus-sign {background:url('../images/icons/delete.png')}
 i.icon-ok {background:url('../images/icons/tick.png')}
 i.icon-phone-sign {background:url('../images/icons/phone.png')}
diff --git a/themes/bootprint/images/icons/cog.png b/themes/bootprint/images/icons/cog.png
new file mode 100644
index 0000000000000000000000000000000000000000..67de2c6ccbeac17742f56cf7391e72b2bf5033ba
GIT binary patch
literal 512
zcmV+b0{{JqP)<h;3K|Lk000e1NJLTq000mG000mO1ONa4wfZ;e00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzl1W5CR4C6?
zQB6w%VGv&Y0p9%y{*S$M5weR^mokV7i!Mcpk=YVa(4jh%5k+ND8>CQDsH?WF>AIFt
zQuJ}i;w2$ZUU#3SZ6RY0Gw;kZ&ol1~2ky^QZ(fom$=jN<T*X<otG0ao1Md*)XSSIA
z*x3T8gv)x7DUK|A#S3CA>JZt!z7w_pH~wdQ;R)Gh%BbQFCx+Nm!4SuS-vkr`vhhrX
zM*>w%e+v~?m@q~ImPAgtLkR_3U<2F8LP3W5=LJ*ZN|S5p#sf4YFr$p~Q~Z*0Ngxf2
zjk#J#<7EAlhzlrV53~GF&pIzcCN_lz9@05Ue<MwWI-*!M0jvB0F~pIke2>oUXiK%N
z#x+4o*i_c|6_Uu1+&TIho?3@y4k-#b8Y_o94zW*B3a1ne2-Y5s0uke$$|@=}OP-i=
zNYZQA=>PrZu0MfSL=b8UhD_={W4IY1{b{)U)*gc45xtL%IYLY&hF;d`@GzI&7H&D#
zh;z_BX$#hqh@q?AY3sJTod2%*Yd)_>YM0#q&ixGuh+PQsneK)F0000<MNUMnLSTXw
CAKTXe

literal 0
HcmV?d00001

diff --git a/themes/bootprint/images/icons/key_go.png b/themes/bootprint/images/icons/key_go.png
new file mode 100644
index 0000000000000000000000000000000000000000..30b0dc316e52dba388d88112d4c1cc32672fffbb
GIT binary patch
literal 744
zcmV<E0vG*>P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!dPzh<R5;6x
zlU+!YVHk$LRU{on2NEG99t4gN<(k<vgSIj~$mk$~eksv@gmxl=Ow(;Fr^B3}b5Lr_
z2G&-MqzD~`qBeg)s5vYXON+MJpPSp<&)xfdS}mK_2lvVQa$nE=JkR@40I2o!$#_If
zgcYe*-~X369QeQ}9^~I<-#CKyR)jn~<T%PeX7qx)>jGlUfiHCke$)P}4*HwX@?mb`
zzgiE#M5fLDxtj=lZ6q=+Lkt2$!wyVqxC~@X03De&Gn$t$kdWJig()R`(|L#ldNHNi
zi?tK@-;xA1zab1r3XkO&T;m5wdrx2~XU8>fpl4v~7ZF1h+!NXGy*rQ6jw}?mrNKGI
zL&zzIrIL-VTRiLE`+jy5wt-SCISiy4pO}x6>V>$`&V{7&3{GoOF)87oTao_ek0H|L
zXxL5q?8f4p7$RLZL=Q4>?LH3$EqhS@^b{VAG@wL(0y*{DquI)BECvw!(t<WU7OeSj
z4slT2o&n>y8jr^s8DqzY3Mx|xw6AM%RhNVG>V(j48H=@2*+n87;k63kFsH&hc^Czx
zU)p@TON5%2#gM-!LRIG_NS|MUrcZ`*_YPuLB^9Iro+W2D85SRofmAHM&xik`9B1#a
z@o-oLow*L$!CJHqC<x>_n){?E(&Zwhf|^V!qqZ#X+&c)jMMwsg3*W31W<{FoWOGV1
zuOTTStWS(&DYr&0v}HowTZPN*IY_RcCU%rj3Cs*;4T3Shy&sFSmGFOV!%!{P*`>;C
zSiN43jAg&56(U(ojS}<bU;n~z%OUZEdjI^WYM*^X$^G8flvN$?agoUOo#Ks1ETcBX
ap8o(~AJmyDx~^sb0000<MNUMnLSTY)_fHN0

literal 0
HcmV?d00001

diff --git a/themes/bootprint/images/icons/lock.png b/themes/bootprint/images/icons/lock.png
new file mode 100644
index 0000000000000000000000000000000000000000..2ebc4f6f9663e32cad77d67ef93ab8843dfea3c0
GIT binary patch
literal 749
zcmV<J0uud+P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00004XF*Lt006JZ
zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!e@R3^R5;6R
zQbB7IK^T3Tq)8K-7_~JtN==(+K|#b@JqUt5h~h!Lc~ek?Ku+zk2XpMN@Q@-NdiDp1
z1*uxlMiZNsG$@o5lP0D~c6MfcvuoP5I`A<w-+bSj_uh<Q+cvyoX=!OhDK#ghoMD_~
zfbo;DVp-N=E|>e|tv9>?g+k#9o0pTx<YX)sgU{y!_vrO{sMqV*;vmqy`T6;^e*oA#
z!o!d0bUI_2CTg`BI-QQb9f3dqiA2JwD;A3z%w1ksSm^4#Z-B()v+?oqj1U6la(T1e
zZl|~o>d@;_sq{kwlU;^VvV*?BV8P@}BoaZTQUROpWV6|-M`|^n&)=+8tHo3*<<$NU
zU`%V~ZF;?hBSYsjJ6%JzV}E(D{pOLqQklliUf9um_tGl-wty`y*p?eYNW56P>X@1s
zZs7KrRZKtmV7Lqj^5Fgr7_`LjhdJK@ltF&O`j7?*NUM$KvmNGz)3WjM?V$vHlP<J&
zUm*}0g<*`aa0m#;nO4C59%Snq%<gw6YaijsENrvy0U$*veUpji`g`g;hWN#6sJ&if
z|7lEIpGEWQIsqDprcRKsge^=jfN*5kq#B>T0AFyF?kLE<#HZabCSW3-o<y$`V(q@e
zY5?H;1Doz@RIRn~d5tXI@x+4aDfGLfYLi*%3!3F^SFTb{&mjZ7(WsOVKc9j>a*6;Z
zrXD`Ulwd<^2glP%1Y1Kc1Ij%DU^=ME(jKf6APNlA$Uu;J4bVilQHSWX5uJ$9Zsp4M
z0%!@LvyTxz=Z6stxlichODIY+yNGt%RM;m`>H4LOKLFs9Y%b5aUN|2|{0Zw|<_~i}
fmXz*V19AKYa<a0`hi}0}00000NkvXXu0mjf@;P6V

literal 0
HcmV?d00001

diff --git a/themes/bootstrap/templates/Auth/AbstractBase/login.phtml b/themes/bootstrap/templates/Auth/AbstractBase/login.phtml
index 1bee344cd83..539c1e484b9 100644
--- a/themes/bootstrap/templates/Auth/AbstractBase/login.phtml
+++ b/themes/bootstrap/templates/Auth/AbstractBase/login.phtml
@@ -6,10 +6,14 @@
     <input type="hidden" name="auth_method" value="<?=$this->auth()->getActiveAuthMethod()?>">
     <div class="control-group">
       <div class="controls">
-        <input class="btn btn-primary" type="submit" name="processLogin" value="Login">
         <? if ($account->supportsCreation()): ?>
           <a class="btn btn-link createAccountLink" href="<?=$this->url('myresearch-account') ?>"><?=$this->transEsc('Create New Account')?></a>
         <? endif; ?>
+        <input class="btn btn-primary" type="submit" name="processLogin" value="Login">
+        <? if ($account->supportsRecovery()): ?>
+          <br/>
+          <a class="btn btn-link" href="<?=$this->url('myresearch-recover') ?>"><?=$this->transEsc('Forgot Password')?></a>
+        <? endif; ?>
       </div>
     </div>
   </form>
diff --git a/themes/bootstrap/templates/Auth/Database/newpassword.phtml b/themes/bootstrap/templates/Auth/Database/newpassword.phtml
new file mode 100644
index 00000000000..40189de011f
--- /dev/null
+++ b/themes/bootstrap/templates/Auth/Database/newpassword.phtml
@@ -0,0 +1,28 @@
+<? if (isset($this->username)): ?>
+  <div class="control-group">
+    <label class="control-label"><?=$this->transEsc('Username') ?>:</label>
+    <div class="controls">
+      <span class="input-xlarge uneditable-input"><?=$this->username ?></span>
+    </div>
+  </div>
+<? endif; ?>
+<? if (isset($this->verifyold) && $this->verifyold || isset($this->oldpwd)): ?>
+  <div class="control-group">
+    <label class="control-label"><?=$this->transEsc('old_password') ?>:</label>
+    <div class="controls">
+      <input type="password" name="oldpwd"/>
+    </div>
+  </div>
+<? endif; ?>
+<div class="control-group">
+  <label class="control-label"><?=$this->transEsc('new_password') ?>:</label>
+  <div class="controls">
+    <input type="password" name="password"/>
+  </div>
+</div>
+<div class="control-group">
+  <label class="control-label"><?=$this->transEsc('confirm_new_password') ?>:</label>
+  <div class="controls">
+    <input type="password" name="password2"/>
+  </div>
+</div>
\ No newline at end of file
diff --git a/themes/bootstrap/templates/Auth/Database/recovery.phtml b/themes/bootstrap/templates/Auth/Database/recovery.phtml
new file mode 100644
index 00000000000..56355213823
--- /dev/null
+++ b/themes/bootstrap/templates/Auth/Database/recovery.phtml
@@ -0,0 +1,14 @@
+<div class="control-group">
+  <label class="control-label"><?=$this->transEsc('recovery_by_username') ?>:</label>
+  <div class="controls">
+    <input type="text" name="username"/>
+    <input class="btn" name="submit" type="submit"/>
+  </div>
+</div>
+<div class="control-group">
+  <label class="control-label"><?=$this->transEsc('recovery_by_email') ?>:</label>
+  <div class="controls">
+    <input type="email" name="email"/>
+    <input class="btn" name="submit" type="submit"/>
+  </div>
+</div>
\ No newline at end of file
diff --git a/themes/bootstrap/templates/myresearch/menu.phtml b/themes/bootstrap/templates/myresearch/menu.phtml
index 157ac8fae80..da74118efaa 100644
--- a/themes/bootstrap/templates/myresearch/menu.phtml
+++ b/themes/bootstrap/templates/myresearch/menu.phtml
@@ -20,14 +20,20 @@
     <li><a href="<?=$this->url('myresearch-logout')?>"><?=$this->transEsc("Log Out")?> <i class="icon-signout pull-right"></i></a></li>
   <? endif; ?>
 </ul>
-  <? if ($this->userlist()->getMode() !== 'disabled' && $user = $this->auth()->isLoggedIn()): ?>
-    <h4 class="list"><?=$this->transEsc('Your Lists')?></h4>
-    <ul class="nav nav-list">
-      <li<?=$this->active == 'favorites' ? ' class="active"' : ''?>><a href="<?=$this->url('myresearch-favorites')?>"><?=$this->transEsc('Your Favorites')?> <i class="icon-star pull-right"></i></a></li>
-      <? $lists = $user->getLists() ?>
-      <? foreach ($lists as $list): ?>
-          <li<?=$this->active == 'list' . $list['id'] ? ' class="active"' : ''?>> <a href="<?=$this->url('userList', array('id' => $list['id']))?>"><?=$this->escapeHtml($list['title'])?> <span class="pull-right"><?=$list->cnt?></span></a></li>
-      <? endforeach; ?>
-      <li><a href="<?=$this->url('editList', array('id'=>'NEW'))?>" title="<?=$this->transEsc('Create a List') ?>"><?=$this->transEsc('Create a List') ?> <span class="pull-right"><i class="icon-plus"></i></span></a></li>
-    </ul>
-  <? endif ?>
+<? if ($this->auth()->isLoggedIn() && $this->auth()->getManager()->supportsPasswordChange()): ?>
+  <h4><?=$this->transEsc('Preferences')?></h4>
+  <ul class="nav nav-list">
+    <li<?=$this->active == 'newpassword' ? ' class="active"' : ''?>><a href="<?=$this->url('myresearch-changepassword') ?>"><?=$this->transEsc('Change Password') ?> <i class="icon-lock pull-right"></i></a></li>
+  </ul>
+<? endif; ?>
+<? if ($this->userlist()->getMode() !== 'disabled' && $user = $this->auth()->isLoggedIn()): ?>
+  <h4><?=$this->transEsc('Your Lists')?></h4>
+  <ul class="nav nav-list">
+    <li<?=$this->active == 'favorites' ? ' class="active"' : ''?>><a href="<?=$this->url('myresearch-favorites')?>"><?=$this->transEsc('Your Favorites')?> <i class="icon-star pull-right"></i></a></li>
+    <? $lists = $user->getLists() ?>
+    <? foreach ($lists as $list): ?>
+        <li<?=$this->active == 'list' . $list['id'] ? ' class="active"' : ''?>> <a href="<?=$this->url('userList', array('id' => $list['id']))?>"><?=$this->escapeHtml($list['title'])?> <span class="pull-right"><?=$list->cnt?></span></a></li>
+    <? endforeach; ?>
+    <li><a href="<?=$this->url('editList', array('id'=>'NEW'))?>" title="<?=$this->transEsc('Create a List') ?>"><?=$this->transEsc('Create a List') ?> <span class="pull-right"><i class="icon-plus"></i></span></a></li>
+  </ul>
+<? endif ?>
diff --git a/themes/bootstrap/templates/myresearch/newpassword.phtml b/themes/bootstrap/templates/myresearch/newpassword.phtml
new file mode 100644
index 00000000000..74951da80b7
--- /dev/null
+++ b/themes/bootstrap/templates/myresearch/newpassword.phtml
@@ -0,0 +1,26 @@
+<div class="<?=$this->layoutClass('mainbody')?>">
+  <h2><?=$this->transEsc('Create New Password') ?></h2>
+  <?=$this->flashmessages() ?>
+  <? if (!$this->auth()->getManager()->supportsPasswordChange()): ?>
+    <div class="error"><?=$this->transEsc('recovery_new_disabled') ?></div>
+  <? elseif (!isset($this->hash)): ?>
+    <div class="error"><?=$this->transEsc('recovery_user_not_found') ?></div>
+  <? else: ?>
+    <form class="form-horizontal" action="<?=$this->url('myresearch-newpassword') ?>" method="post">
+      <input type="hidden" value="<?=$this->hash ?>" name="hash"/>
+      <input type="hidden" value="<?=$this->username ?>" name="username"/>
+      <?=$this->auth()->getNewPasswordForm() ?>
+      <div class="control-group">
+        <div class="controls">
+          <input class="btn" name="submit" type="submit"/>
+        </div>
+      </div>
+    </form>
+  <? endif; ?>
+</div>
+
+<? if ($this->auth()->isLoggedIn()): ?>
+  <div class="<?=$this->layoutClass('sidebar')?>">
+    <?=$this->context($this)->renderInContext("myresearch/menu.phtml", array('active' => 'newpassword'))?>
+  </div>
+<? endif; ?>
\ No newline at end of file
diff --git a/themes/bootstrap/templates/myresearch/profile.phtml b/themes/bootstrap/templates/myresearch/profile.phtml
index 989b5862ff3..96dcb5cb94f 100644
--- a/themes/bootstrap/templates/myresearch/profile.phtml
+++ b/themes/bootstrap/templates/myresearch/profile.phtml
@@ -33,7 +33,7 @@
             ? $this->profile['home_library'] : $this->defaultPickupLocation
       ?>
       <td>
-        <form method="post" action="" id="profile_form">
+        <form id="profile_form" class="form-inline" action="" method="post">
           <select id="home_library" name="home_library">
             <? foreach ($this->pickup as $lib): ?>
               <option value="<?=$this->escapeHtml($lib['locationID'])?>"<?=($selected == $lib['locationID'])?' selected="selected"':''?>><?=$this->escapeHtml($lib['locationDisplay'])?></option>
diff --git a/themes/bootstrap/templates/myresearch/recover.phtml b/themes/bootstrap/templates/myresearch/recover.phtml
new file mode 100644
index 00000000000..e2c25e006b1
--- /dev/null
+++ b/themes/bootstrap/templates/myresearch/recover.phtml
@@ -0,0 +1,9 @@
+<h2><?=$this->transEsc('Password Recovery') ?></h2>
+<?=$this->flashmessages()?>
+<? if (!$this->auth()->getManager()->supportsRecovery()): ?>
+  <div class="error"><?=$this->transEsc('recovery_disabled') ?></div>
+<? else: ?>
+  <form class="form-horizontal" action="" method="post">
+    <?=$this->auth()->getPasswordRecoveryForm() ?>
+  </form>
+<? endif; ?>
\ No newline at end of file
diff --git a/themes/jquerymobile/templates/Auth/AbstractBase/login.phtml b/themes/jquerymobile/templates/Auth/AbstractBase/login.phtml
index e41194cc528..e7f616f8c31 100644
--- a/themes/jquerymobile/templates/Auth/AbstractBase/login.phtml
+++ b/themes/jquerymobile/templates/Auth/AbstractBase/login.phtml
@@ -15,6 +15,9 @@
   <? if ($account->supportsCreation()): ?>
     <a rel="external" data-role="button" class="new_account" href="<?=$this->url('myresearch-account')?>?auth_method=<?=$this->auth()->getActiveAuthMethod()?>"><?=$this->transEsc('Create New Account')?></a>
   <? endif; ?>
+  <? if ($account->supportsRecovery()): ?>
+    <a rel="external" data-role="button" class="recover_password" href="<?=$this->url('myresearch-recover')?>"><?=$this->transEsc('Forgot Password')?></a>
+  <? endif; ?>
 <? else: ?>
   <a rel="external" data-role="button" href="<?=$this->escapeHtml($sessionInitiator)?>"><?=$this->transEsc("Institutional Login")?></a>
 <? endif; ?>
diff --git a/themes/jquerymobile/templates/Auth/Database/newpassword.phtml b/themes/jquerymobile/templates/Auth/Database/newpassword.phtml
new file mode 100644
index 00000000000..4c0b2d107fd
--- /dev/null
+++ b/themes/jquerymobile/templates/Auth/Database/newpassword.phtml
@@ -0,0 +1,15 @@
+<div data-role="fieldcontain" class="ui-field-contain ui-body ui-br">
+  <? if (isset($this->username)): ?>
+    <input type="hidden" name="username" value="<?=$this->username ?>"/>
+    <label class="ui-input-text"><?=$this->transEsc('Username') ?>:</label>
+    <input type="text" name="username" id="username" value="<?=$this->username ?>" disabled class="ui-input-text ui-body-c ui-corner-all ui-shadow-inset" style="border:1px solid #CCC;box-shadow:rgba(0, 0, 0, 0.1) 0px 1px 4px 0px inset;color:#777"/><br/>
+  <? endif; ?>
+  <? if (isset($this->verifyold) && $this->verifyold || isset($this->oldpwd)): ?>
+    <label for="oldpwd" class="ui-input-text"><?=$this->transEsc('old_password') ?>:</label>
+    <input type="password" name="oldpwd" id="oldpwd" class="ui-input-text ui-body-c ui-corner-all ui-shadow-inset"/><br/>
+  <? endif; ?>
+  <label for="password" class="ui-input-text"><?=$this->transEsc('new_password') ?>:</label>
+  <input type="password" name="password" id="password" class="ui-input-text ui-body-c ui-corner-all ui-shadow-inset"/><br/>
+  <label for="password2" class="ui-input-text"><?=$this->transEsc('confirm_new_password') ?>:</label>
+  <input type="password" name="password2" id="password2" class="ui-input-text ui-body-c ui-corner-all ui-shadow-inset"/><br/>
+</div>
\ No newline at end of file
diff --git a/themes/jquerymobile/templates/Auth/Database/recovery.phtml b/themes/jquerymobile/templates/Auth/Database/recovery.phtml
new file mode 100644
index 00000000000..b1503fb4a54
--- /dev/null
+++ b/themes/jquerymobile/templates/Auth/Database/recovery.phtml
@@ -0,0 +1,10 @@
+<div class="ui-grid-b">
+  <div class="ui-block-a"><label><?=$this->transEsc('recovery_by_username') ?>:</label></div>
+  <div class="ui-block-b"><input type="text" name="username" style="margin-top:.5em;height:28px"/></div>
+  <div class="ui-block-c"><input type="submit" name="submit" value="<?=$this->transEsc('Submit') ?>"/></div>
+</div>
+<div class="ui-grid-b">
+  <div class="ui-block-a"><label><?=$this->transEsc('recovery_by_email') ?>:</label></div>
+  <div class="ui-block-b"><input type="email" name="email" style="margin-top:.5em;height:28px"/></div>
+  <div class="ui-block-c"><input type="submit" name="submit" value="<?=$this->transEsc('Submit') ?>"/></div>
+</div>
\ No newline at end of file
diff --git a/themes/jquerymobile/templates/myresearch/footer-navbar.phtml b/themes/jquerymobile/templates/myresearch/footer-navbar.phtml
index aec903518b6..624e3b6788a 100644
--- a/themes/jquerymobile/templates/myresearch/footer-navbar.phtml
+++ b/themes/jquerymobile/templates/myresearch/footer-navbar.phtml
@@ -5,6 +5,9 @@
         <li><a rel="external" <?=$this->layout()->templateName=="mylist" ? ' class="ui-btn-active"' : ''?> href="<?=$this->url('myresearch-favorites')?>"><?=$this->transEsc('Favorites')?></a></li>
       <? endif; ?>
       <li><a rel="external" <?=$this->layout()->templateName=="history" ? ' class="ui-btn-active"' : ''?> href="<?=$this->url('search-history')?>?require_login"><?=$this->transEsc('History')?></a></li>
+      <? if ($this->auth()->getManager()->supportsPasswordChange()): ?>
+        <li><a rel="external" href="<?=$this->url('myresearch-changepassword')?>"><?=$this->transEsc("Change Password")?></a></li>
+      <? endif; ?>
       <li><a rel="external" href="<?=$this->url('myresearch-logout')?>"><?=$this->transEsc("Log Out")?></a></li>
     </ul>
   </div>
diff --git a/themes/jquerymobile/templates/myresearch/newpassword.phtml b/themes/jquerymobile/templates/myresearch/newpassword.phtml
new file mode 100644
index 00000000000..9a8e1ecbaaa
--- /dev/null
+++ b/themes/jquerymobile/templates/myresearch/newpassword.phtml
@@ -0,0 +1,29 @@
+<?
+  // Set up page title:
+  $this->headTitle(isset($list) ? $list->title : $this->translate('Create New Password'));
+
+  // Set up extra button for header:
+  $extraButton = '<a rel="external" href="'
+    . $this->url('myresearch-home')
+    . '" data-icon="back" class="ui-btn-left">'
+    . $this->transEsc('My Profile')
+    . '</a>';
+?>
+<div data-role="page" id="MyResearch-newpassword" class="newpassword">
+  <?=$this->mobileMenu()->header(array('extraButtons'=>array($extraButton))) ?>
+  <div data-role="content">
+    <?=$this->flashmessages() ?>
+    <? if (!$this->auth()->getManager()->supportsPasswordChange()): ?>
+      <div class="error"><?=$this->transEsc('recovery_new_disabled') ?></div>
+    <? elseif (!isset($this->hash)): ?>
+      <div class="error"><?=$this->transEsc('recovery_user_not_found') ?></div>
+    <? else: ?>
+      <form data-ajax="false" action="<?=$this->url('myresearch-newpassword') ?>" method="post">
+        <?=$this->auth()->getNewPasswordForm() ?>
+        <input type="hidden" value="<?=$this->hash ?>" name="hash"/>
+        <input type="submit" name="submit" value="<?=$this->transEsc('Submit') ?>"/>
+      </form>
+    <? endif; ?>
+  </div>
+  <?=$this->mobileMenu()->footer() ?>
+</div>
\ No newline at end of file
diff --git a/themes/jquerymobile/templates/myresearch/recover.phtml b/themes/jquerymobile/templates/myresearch/recover.phtml
new file mode 100644
index 00000000000..4e73da0009c
--- /dev/null
+++ b/themes/jquerymobile/templates/myresearch/recover.phtml
@@ -0,0 +1,27 @@
+<?
+  // Set up page title:
+  $this->headTitle(isset($list) ? $list->title : $this->translate('recovery_title'));
+
+  // Set up extra button for header:
+  $extraButton = '<a rel="external" href="'
+    . $this->url('myresearch-home')
+    . '" data-icon="back" class="ui-btn-left">';
+  $extraButton .= $this->auth()->isLoggedIn()
+    ? $this->transEsc('My Profile')
+    : $this->transEsc('Login');
+  $extraButton .= '</a>';
+?>
+<div data-role="page" id="MyResearch-recover" class="results-page">
+  <?=$this->mobileMenu()->header(array('extraButtons'=>array($extraButton))) ?>
+  <div data-role="content">
+    <?=$this->flashmessages()?>
+    <? if (!$this->auth()->getManager()->supportsRecovery()): ?>
+      <div class="error"><?=$this->transEsc('recovery_disabled') ?></div>
+    <? else: ?>
+      <form data-ajax="false" action="" method="post">
+        <?=$this->auth()->getPasswordRecoveryForm() ?>
+      </form>
+    <? endif; ?>
+  </div>
+  <?=$this->mobileMenu()->footer() ?>
+</div>
\ No newline at end of file
diff --git a/themes/root/templates/Email/recover-password.phtml b/themes/root/templates/Email/recover-password.phtml
new file mode 100644
index 00000000000..6037d640e28
--- /dev/null
+++ b/themes/root/templates/Email/recover-password.phtml
@@ -0,0 +1,3 @@
+<?=$this->translate('recovery_email_notification', array('%%library%%' => $this->library)) ?>
+
+<?=$this->translate('recovery_email_url_pretext', array('%%url%%' => $this->url)) ?>
\ No newline at end of file
-- 
GitLab