From ecb378e26f0a7534b2a4f346f96603021179b319 Mon Sep 17 00:00:00 2001
From: Ere Maijala <ere.maijala@helsinki.fi>
Date: Tue, 17 Dec 2019 22:58:41 +0200
Subject: [PATCH] Change the email authenticator to use database instead of
 session (#1520)

- Includes some more general bug fixes to email authentication.
---
 config/vufind/config.ini                      |   4 +
 module/VuFind/config/module.config.php        |   2 +
 .../pgsql/6.1/004-add-auth-hash-table.sql     |  15 ++
 module/VuFind/sql/mysql.sql                   |  21 +++
 module/VuFind/sql/pgsql.sql                   |  18 +++
 module/VuFind/src/VuFind/Auth/ChoiceAuth.php  |  33 +++-
 module/VuFind/src/VuFind/Auth/Email.php       |   7 +-
 .../src/VuFind/Auth/EmailAuthenticator.php    |  80 ++++++----
 .../VuFind/Auth/EmailAuthenticatorFactory.php |   6 +-
 module/VuFind/src/VuFind/Db/Row/AuthHash.php  |  53 +++++++
 .../src/VuFind/Db/Row/PluginManager.php       |   1 +
 .../VuFind/src/VuFind/Db/Table/AuthHash.php   | 145 ++++++++++++++++++
 .../src/VuFind/Db/Table/PluginManager.php     |   1 +
 .../PhpEnvironment/RemoteAddressFactory.php   |  72 +++++++++
 module/VuFindConsole/Module.php               |   1 +
 module/VuFindConsole/config/module.config.php |   1 +
 .../Controller/UtilController.php             |  20 +++
 17 files changed, 442 insertions(+), 38 deletions(-)
 create mode 100644 module/VuFind/sql/migrations/pgsql/6.1/004-add-auth-hash-table.sql
 create mode 100644 module/VuFind/src/VuFind/Db/Row/AuthHash.php
 create mode 100644 module/VuFind/src/VuFind/Db/Table/AuthHash.php
 create mode 100644 module/VuFind/src/VuFind/Http/PhpEnvironment/RemoteAddressFactory.php

diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index 728a9ff30be..518994e6187 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -381,6 +381,10 @@ force_first_scheduled_email = false
 ; also supported as the primary authentication mechanism for some ILS drivers (e.g.
 ; Alma). In these cases, ChoiceAuth is not needed, and ILS should be configured as
 ; the Authentication method; see the ILS driver's configuration for possible options.
+;
+; Also note that the Email method stores hashes in your database's auth_hash table.
+; You should run the "php $VUFIND_HOME/public/index.php util expire_auth_hashes"
+; utility periodically to clean out old data in this table.
 [Authentication]
 ;method          = LDAP
 ;method         = ILS
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index fc4f6273914..b0e499d1418 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -430,6 +430,7 @@ $config = [
             'VuFindHttp\HttpService' => 'VuFind\Service\HttpServiceFactory',
             'VuFindSearch\Service' => 'VuFind\Service\SearchServiceFactory',
             'Zend\Db\Adapter\Adapter' => 'VuFind\Db\AdapterFactory',
+            'Zend\Http\PhpEnvironment\RemoteAddress' => 'VuFind\Http\PhpEnvironment\RemoteAddressFactory',
             'Zend\Mvc\I18n\Translator' => 'VuFind\I18n\Translator\TranslatorFactory',
             'Zend\Session\SessionManager' => 'VuFind\Session\ManagerFactory',
         ],
@@ -526,6 +527,7 @@ $config = [
         'config_reader' => [ /* see VuFind\Config\PluginManager for defaults */ ],
         // PostgreSQL sequence mapping
         'pgsql_seq_mapping'  => [
+            'auth_hash'        => ['id', 'auth_hash_id_seq'],
             'comments'         => ['id', 'comments_id_seq'],
             'external_session' => ['id', 'external_session_id_seq'],
             'oai_resumption'   => ['id', 'oai_resumption_id_seq'],
diff --git a/module/VuFind/sql/migrations/pgsql/6.1/004-add-auth-hash-table.sql b/module/VuFind/sql/migrations/pgsql/6.1/004-add-auth-hash-table.sql
new file mode 100644
index 00000000000..5898a0ebc3e
--- /dev/null
+++ b/module/VuFind/sql/migrations/pgsql/6.1/004-add-auth-hash-table.sql
@@ -0,0 +1,15 @@
+--
+-- Table structure for table auth_hash
+--
+
+CREATE TABLE auth_hash (
+id BIGSERIAL,
+session_id varchar(128),
+hash varchar(255),
+type varchar(50),
+data text,
+created timestamp NOT NULL default '1970-01-01 00:00:00',
+PRIMARY KEY (id),
+UNIQUE (hash, type)
+);
+CREATE INDEX auth_hash_created_idx on auth_hash(created);
diff --git a/module/VuFind/sql/mysql.sql b/module/VuFind/sql/mysql.sql
index e92f516a87e..d4e1340ae7b 100644
--- a/module/VuFind/sql/mysql.sql
+++ b/module/VuFind/sql/mysql.sql
@@ -322,3 +322,24 @@ CREATE TABLE `record` (
   UNIQUE KEY `record_id_source` (`record_id`, `source`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 /*!40101 SET character_set_client = @saved_cs_client */;
+
+--
+-- Table structure for table `auth_hash`
+--
+
+/*!40101 SET @saved_cs_client     = @@character_set_client */;
+/*!40101 SET character_set_client = utf8 */;
+CREATE TABLE `auth_hash` (
+  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+  `session_id` varchar(128) DEFAULT NULL,
+  `hash` varchar(255) NOT NULL DEFAULT '',
+  `type` varchar(50) DEFAULT NULL,
+  `data` mediumtext,
+  `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `session_id` (`session_id`),
+  UNIQUE KEY `hash_type` (`hash`, `type`),
+  KEY `created` (`created`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+/*!40101 SET character_set_client = @saved_cs_client */;
+
diff --git a/module/VuFind/sql/pgsql.sql b/module/VuFind/sql/pgsql.sql
index 744cc0f6bb5..39abd1cbc29 100644
--- a/module/VuFind/sql/pgsql.sql
+++ b/module/VuFind/sql/pgsql.sql
@@ -306,6 +306,24 @@ CONSTRAINT user_card_ibfk_1 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELE
 CREATE INDEX user_card_cat_username_idx ON user_card (cat_username);
 CREATE INDEX user_card_user_id_idx ON user_card (user_id);
 
+--
+-- Table structure for table auth_hash
+--
+
+DROP TABLE IF EXISTS "auth_hash";
+
+CREATE TABLE auth_hash (
+id BIGSERIAL,
+session_id varchar(128),
+hash varchar(255),
+type varchar(50),
+data text,
+created timestamp NOT NULL default '1970-01-01 00:00:00',
+PRIMARY KEY (id),
+UNIQUE (hash, type)
+);
+CREATE INDEX auth_hash_created_idx on auth_hash(created);
+
 -- --------------------------------------------------------
 
 --
diff --git a/module/VuFind/src/VuFind/Auth/ChoiceAuth.php b/module/VuFind/src/VuFind/Auth/ChoiceAuth.php
index 43b5ed01745..adbbeb42d4a 100644
--- a/module/VuFind/src/VuFind/Auth/ChoiceAuth.php
+++ b/module/VuFind/src/VuFind/Auth/ChoiceAuth.php
@@ -337,6 +337,20 @@ class ChoiceAuth extends AbstractBase
         return $this->proxyAuthMethod('getDelegateAuthMethod', func_get_args());
     }
 
+    /**
+     * Is the configured strategy on the list of legal options?
+     *
+     * @return bool
+     */
+    protected function hasLegalStrategy()
+    {
+        // Do a case-insensitive search of the strategy list:
+        return in_array(
+            strtolower($this->strategy),
+            array_map('strtolower', $this->strategies)
+        );
+    }
+
     /**
      * Proxy auth method; a helper function to be called like:
      *   return $this->proxyAuthMethod(METHOD, func_get_args());
@@ -354,7 +368,7 @@ class ChoiceAuth extends AbstractBase
             return false;
         }
 
-        if (!in_array($this->strategy, $this->strategies)) {
+        if (!$this->hasLegalStrategy()) {
             throw new InvalidArgumentException("Illegal setting: {$this->strategy}");
         }
         $authenticator = $this->getPluginManager()->get($this->strategy);
@@ -434,4 +448,21 @@ class ChoiceAuth extends AbstractBase
         }
         return isset($user) && $user instanceof User;
     }
+
+    /**
+     * Whether this authentication method needs CSRF checking for the request.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object.
+     *
+     * @return bool
+     *
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function needsCsrfCheck($request)
+    {
+        if (!$this->strategy) {
+            return true;
+        }
+        return $this->proxyAuthMethod('needsCsrfCheck', func_get_args());
+    }
 }
diff --git a/module/VuFind/src/VuFind/Auth/Email.php b/module/VuFind/src/VuFind/Auth/Email.php
index 3d5725feb6e..9c9e9a0ee7d 100644
--- a/module/VuFind/src/VuFind/Auth/Email.php
+++ b/module/VuFind/src/VuFind/Auth/Email.php
@@ -86,8 +86,11 @@ class Email extends AbstractBase
                 $loginData = [
                     'vufind_id' => $user['id']
                 ];
-                $this->emailAuthenticator
-                    ->sendAuthenticationLink($user['email'], $loginData);
+                $this->emailAuthenticator->sendAuthenticationLink(
+                    $user['email'],
+                    $loginData,
+                    ['auth_method' => 'email']
+                );
             }
             // Don't reveal the result
             throw new \VuFind\Exception\AuthInProgress('email_login_link_sent');
diff --git a/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php b/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php
index 2fe718eb32e..19f1faa775e 100644
--- a/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php
+++ b/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php
@@ -27,7 +27,9 @@
  */
 namespace VuFind\Auth;
 
+use VuFind\DB\Table\AuthHash as AuthHashTable;
 use VuFind\Exception\Auth as AuthException;
+use Zend\Http\PhpEnvironment\RemoteAddress;
 
 /**
  * Class for managing email-based authentication.
@@ -74,11 +76,11 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
     protected $viewRenderer = null;
 
     /**
-     * Request
+     * Remote address
      *
-     * @var \Zend\Stdlib\RequestInterface
+     * @var RemoteAddress
      */
-    protected $request;
+    protected $remoteAddress;
 
     /**
      * Configuration
@@ -94,6 +96,13 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
      */
     protected $loginRequestValidTime = 600;
 
+    /**
+     * Database table for authentication hashes
+     *
+     * @var AuthHashTable
+     */
+    protected $authHashTable;
+
     /**
      * Constructor
      *
@@ -101,21 +110,23 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
      * @param \VuFind\Validator\Csrf                $csrf         CSRF Validator
      * @param \VuFind\Mailer\Mailer                 $mailer       Mailer
      * @param \Zend\View\Renderer\RendererInterface $viewRenderer View Renderer
-     * @param \Zend\Stdlib\RequestInterface         $request      Request
+     * @param RemoteAddress                         $remoteAddr   Remote address
      * @param \Zend\Config\Config                   $config       Configuration
+     * @param AuthHashTable                         $authHash     AuthHash Table
      */
     public function __construct(\Zend\Session\SessionManager $session,
         \VuFind\Validator\Csrf $csrf, \VuFind\Mailer\Mailer $mailer,
         \Zend\View\Renderer\RendererInterface $viewRenderer,
-        \Zend\Stdlib\RequestInterface $request,
-        \Zend\Config\Config $config
+        RemoteAddress $remoteAddr,
+        \Zend\Config\Config $config, AuthHashTable $authHash
     ) {
         $this->sessionManager = $session;
         $this->csrf = $csrf;
         $this->mailer = $mailer;
         $this->viewRenderer = $viewRenderer;
-        $this->request = $request;
+        $this->remoteAddress = $remoteAddr;
         $this->config = $config;
+        $this->authHashTable = $authHash;
     }
 
     /**
@@ -138,14 +149,14 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
         $subject = 'email_login_subject',
         $template = 'Email/login-link.phtml'
     ) {
-        $sessionContainer = $this->getSessionContainer();
-
         // Make sure we've waited long enough
         $recoveryInterval = isset($this->config->Authentication->recover_interval)
             ? $this->config->Authentication->recover_interval
             : 60;
-        if (null !== $sessionContainer->timestamp
-            && time() - $sessionContainer->timestamp < $recoveryInterval
+        $sessionId = $this->sessionManager->getId();
+
+        if (($row = $this->authHashTable->getLatestBySessionId($sessionId))
+            && time() - strtotime($row['created']) < $recoveryInterval
         ) {
             throw new AuthException('authentication_error_in_progress');
         }
@@ -154,14 +165,17 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
         $linkData = [
             'timestamp' => time(),
             'data' => $data,
-            'email' => $email
+            'email' => $email,
+            'ip' => $this->remoteAddress->getIpAddress()
         ];
         $hash = $this->csrf->getHash(true);
 
-        if (!isset($sessionContainer->requests)) {
-            $sessionContainer->requests = [];
-        }
-        $sessionContainer->requests[$hash] = $linkData;
+        $row = $this->authHashTable
+            ->getByHashAndType($hash, AuthHashTable::TYPE_EMAIL);
+
+        $row['session_id'] = $sessionId;
+        $row['data'] = json_encode($linkData);
+        $row->save();
 
         $serverHelper = $this->viewRenderer->plugin('serverurl');
         $urlHelper = $this->viewRenderer->plugin('url');
@@ -192,14 +206,23 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
      */
     public function authenticate($hash)
     {
-        $sessionContainer = $this->getSessionContainer();
+        $row = $this->authHashTable
+            ->getByHashAndType($hash, AuthHashTable::TYPE_EMAIL, false);
+        if (!$row) {
+            throw new AuthException('authentication_error_denied');
+        }
+        $linkData = json_decode($row['data'], true);
+        $row->delete();
 
-        if (!isset($sessionContainer->requests[$hash])) {
+        if (time() - strtotime($row['created']) > $this->loginRequestValidTime) {
             throw new AuthException('authentication_error_denied');
         }
-        $linkData = $sessionContainer->requests[$hash];
-        unset($sessionContainer->requests[$hash]);
-        if (time() - $linkData['timestamp'] > $this->loginRequestValidTime) {
+
+        // Require same session id or IP address:
+        $sessionId = $this->sessionManager->getId();
+        if ($row['session_id'] !== $sessionId
+            && $linkData['ip'] !== $this->remoteAddress->getIpAddress()
+        ) {
             throw new AuthException('authentication_error_denied');
         }
 
@@ -220,19 +243,10 @@ class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInter
             $request->getQuery()->get('hash', '')
         );
         if ($hash) {
-            $sessionContainer = $this->getSessionContainer();
-            return isset($sessionContainer->requests[$hash]);
+            $row = $this->authHashTable
+                ->getByHashAndType($hash, AuthHashTable::TYPE_EMAIL, false);
+            return !empty($row);
         }
         return false;
     }
-
-    /**
-     * Get the session container
-     *
-     * @return \Zend\Session\Container
-     */
-    protected function getSessionContainer()
-    {
-        return new \Zend\Session\Container('EmailAuth', $this->sessionManager);
-    }
 }
diff --git a/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php b/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php
index 150b8c7d701..2cfa4abb879 100644
--- a/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php
+++ b/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php
@@ -66,8 +66,10 @@ class EmailAuthenticatorFactory
             $container->get(\VuFind\Validator\Csrf::class),
             $container->get(\VuFind\Mailer\Mailer::class),
             $container->get('ViewRenderer'),
-            $container->get('Request'),
-            $container->get(\VuFind\Config\PluginManager::class)->get('config')
+            $container->get(\Zend\Http\PhpEnvironment\RemoteAddress::class),
+            $container->get(\VuFind\Config\PluginManager::class)->get('config'),
+            $container->get(\VuFind\Db\Table\PluginManager::class)
+                ->get(\VuFind\Db\Table\AuthHash::class)
         );
     }
 }
diff --git a/module/VuFind/src/VuFind/Db/Row/AuthHash.php b/module/VuFind/src/VuFind/Db/Row/AuthHash.php
new file mode 100644
index 00000000000..265338271e7
--- /dev/null
+++ b/module/VuFind/src/VuFind/Db/Row/AuthHash.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * Row Definition for auth_hash
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2010.
+ * Copyright (C) The National Library of Finland 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Db_Row
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+namespace VuFind\Db\Row;
+
+/**
+ * Row Definition for auth_hash
+ *
+ * @category VuFind
+ * @package  Db_Row
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+class AuthHash extends RowGateway
+{
+    /**
+     * Constructor
+     *
+     * @param \Zend\Db\Adapter\Adapter $adapter Database adapter
+     */
+    public function __construct($adapter)
+    {
+        parent::__construct('id', 'auth_hash', $adapter);
+    }
+}
diff --git a/module/VuFind/src/VuFind/Db/Row/PluginManager.php b/module/VuFind/src/VuFind/Db/Row/PluginManager.php
index 5b402dec18f..02d8d0fb4db 100644
--- a/module/VuFind/src/VuFind/Db/Row/PluginManager.php
+++ b/module/VuFind/src/VuFind/Db/Row/PluginManager.php
@@ -67,6 +67,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      * @var array
      */
     protected $factories = [
+        AuthHash::class => RowGatewayFactory::class,
         ChangeTracker::class => RowGatewayFactory::class,
         Comments::class => RowGatewayFactory::class,
         ExternalSession::class => RowGatewayFactory::class,
diff --git a/module/VuFind/src/VuFind/Db/Table/AuthHash.php b/module/VuFind/src/VuFind/Db/Table/AuthHash.php
new file mode 100644
index 00000000000..0918b0aa60a
--- /dev/null
+++ b/module/VuFind/src/VuFind/Db/Table/AuthHash.php
@@ -0,0 +1,145 @@
+<?php
+/**
+ * Table Definition for auth_hash
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2010.
+ * Copyright (C) The National Library of Finland 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  Db_Table
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Page
+ */
+namespace VuFind\Db\Table;
+
+use VuFind\Db\Row\RowGateway;
+use Zend\Db\Adapter\Adapter;
+
+/**
+ * Table Definition for auth_hash
+ *
+ * @category VuFind
+ * @package  Db_Table
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org Main Site
+ */
+class AuthHash extends Gateway
+{
+    use ExpirationTrait;
+
+    const TYPE_EMAIL = 'email'; // EmailAuthenticator
+
+    /**
+     * Constructor
+     *
+     * @param Adapter       $adapter Database adapter
+     * @param PluginManager $tm      Table manager
+     * @param array         $cfg     Zend Framework configuration
+     * @param RowGateway    $rowObj  Row prototype object (null for default)
+     * @param string        $table   Name of database table to interface with
+     */
+    public function __construct(Adapter $adapter, PluginManager $tm, $cfg,
+        RowGateway $rowObj = null, $table = 'auth_hash'
+    ) {
+        parent::__construct($adapter, $tm, $cfg, $rowObj, $table);
+    }
+
+    /**
+     * Retrieve an object from the database based on hash and type; create a new
+     * row if no existing match is found.
+     *
+     * @param string $hash   Hash
+     * @param string $type   Hash type
+     * @param bool   $create Should we create rows that don't already exist?
+     *
+     * @return \VuFind\Db\Row\AuthHash
+     */
+    public function getByHashAndType($hash, $type, $create = true)
+    {
+        $row = $this->select(['hash' => $hash, 'type' => $type])->current();
+        if ($create && empty($row)) {
+            $row = $this->createRow();
+            $row->hash = $hash;
+            $row->type = $type;
+            $row->created = date('Y-m-d H:i:s');
+        }
+        return $row;
+    }
+
+    /**
+     * Retrieve last object from the database based on session id.
+     *
+     * @param string $sessionId Session ID
+     *
+     * @return \VuFind\Db\Row\AuthHash
+     */
+    public function getLatestBySessionId($sessionId)
+    {
+        $callback = function ($select) use ($sessionId) {
+            $select->where->equalTo('session_id', $sessionId);
+            $select->order('created DESC');
+        };
+        return $this->select($callback)->current();
+    }
+
+    /**
+     * Get a query representing expired sessions (this can be passed
+     * to select() or delete() for further processing).
+     *
+     * @param int $daysOld Age in days of an "expired" session.
+     *
+     * @return function
+     */
+    public function getExpiredQuery($daysOld = 2)
+    {
+        // Determine the expiration date:
+        $expireDate = time() - $daysOld * 24 * 60 * 60;
+        $callback = function ($select) use ($expireDate) {
+            $select->where->lessThan('created', date('Y-m-d H:i:s', $expireDate));
+        };
+        return $callback;
+    }
+
+    /**
+     * Update the select statement to find records to delete.
+     *
+     * @param Select $select  Select clause
+     * @param int    $daysOld Age in days of an "expired" record.
+     * @param int    $idFrom  Lowest id of rows to delete.
+     * @param int    $idTo    Highest id of rows to delete.
+     *
+     * @return void
+     */
+    protected function expirationCallback($select, $daysOld, $idFrom = null,
+        $idTo = null
+    ) {
+        $expireDate = time() - $daysOld * 24 * 60 * 60;
+        $where = $select->where
+            ->lessThan('created', date('Y-m-d H:i:s', $expireDate));
+        if (null !== $idFrom) {
+            $where->and->greaterThanOrEqualTo('id', $idFrom);
+        }
+        if (null !== $idTo) {
+            $where->and->lessThanOrEqualTo('id', $idTo);
+        }
+    }
+}
diff --git a/module/VuFind/src/VuFind/Db/Table/PluginManager.php b/module/VuFind/src/VuFind/Db/Table/PluginManager.php
index e08e1e41276..761b62288d8 100644
--- a/module/VuFind/src/VuFind/Db/Table/PluginManager.php
+++ b/module/VuFind/src/VuFind/Db/Table/PluginManager.php
@@ -67,6 +67,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
      * @var array
      */
     protected $factories = [
+        AuthHash::class => GatewayFactory::class,
         ChangeTracker::class => GatewayFactory::class,
         Comments::class => GatewayFactory::class,
         ExternalSession::class => GatewayFactory::class,
diff --git a/module/VuFind/src/VuFind/Http/PhpEnvironment/RemoteAddressFactory.php b/module/VuFind/src/VuFind/Http/PhpEnvironment/RemoteAddressFactory.php
new file mode 100644
index 00000000000..8e113bf5a94
--- /dev/null
+++ b/module/VuFind/src/VuFind/Http/PhpEnvironment/RemoteAddressFactory.php
@@ -0,0 +1,72 @@
+<?php
+/**
+ * RemoteAddress utility factory. This uses the core Zend RemoteAddress but
+ * configures it according to VuFind settings.
+ *
+ * PHP version 7
+ *
+ * Copyright (C) Villanova University 2019.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Http\PhpEnvironment;
+
+use Interop\Container\ContainerInterface;
+use Zend\ServiceManager\Factory\FactoryInterface;
+
+/**
+ * RemoteAddress utility factory.
+ *
+ * @category VuFind
+ * @package  View_Helpers
+ * @author   Demian Katz <demian.katz@villanova.edu>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class RemoteAddressFactory implements FactoryInterface
+{
+    /**
+     * Create an object
+     *
+     * @param ContainerInterface $container     Service manager
+     * @param string             $requestedName Service being created
+     * @param null|array         $options       Extra options (optional)
+     *
+     * @return object
+     *
+     * @throws ServiceNotFoundException if unable to resolve the service.
+     * @throws ServiceNotCreatedException if an exception is raised when
+     * creating a service.
+     * @throws ContainerException if any other error occurs
+     */
+    public function __invoke(ContainerInterface $container, $requestedName,
+        array $options = null
+    ) {
+        if (!empty($options)) {
+            throw new \Exception('Unexpected options sent to factory.');
+        }
+        $cfg = $container->get(\VuFind\Config\PluginManager::class)->get('config');
+        $object = new $requestedName();
+        if ($cfg->Site->reverse_proxy ?? false) {
+            $object->setUseProxy(true);
+        }
+        return $object;
+    }
+}
diff --git a/module/VuFindConsole/Module.php b/module/VuFindConsole/Module.php
index ae048d2d0d3..8a509e31dea 100644
--- a/module/VuFindConsole/Module.php
+++ b/module/VuFindConsole/Module.php
@@ -123,6 +123,7 @@ class Module implements \Zend\ModuleManager\Feature\ConsoleUsageProviderInterfac
             'util createHierarchyTrees' => 'Cache populator for hierarchies',
             'util cssBuilder' => 'LESS compiler',
             'util deletes' => 'Tool for deleting Solr records',
+            'util expire_auth_hashes' => 'Database auth_hash table cleanup',
             'util expire_external_sessions'
                 => 'Database external_session table cleanup',
             'util expire_searches' => 'Database search table cleanup',
diff --git a/module/VuFindConsole/config/module.config.php b/module/VuFindConsole/config/module.config.php
index 210192d5762..b153af319f3 100644
--- a/module/VuFindConsole/config/module.config.php
+++ b/module/VuFindConsole/config/module.config.php
@@ -76,6 +76,7 @@ $routes = [
     'util/createHierarchyTrees' => 'util createHierarchyTrees [--skip-xml|-sx] [--skip-json|-sj] [<backend>] [--help|-h]',
     'util/cssBuilder' => 'util cssBuilder [...themes]',
     'util/deletes' => 'util deletes [--verbose] [<filename>] [<format>] [<index>]',
+    'util/expire_auth_hashes' => 'util expire_auth_hashes [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
     'util/expire_external_sessions' => 'util expire_external_sessions [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
     'util/expire_searches' => 'util expire_searches [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
     'util/expire_sessions' => 'util expire_sessions [--help|-h] [--batch=] [--sleep=] [<daysOld>]',
diff --git a/module/VuFindConsole/src/VuFindConsole/Controller/UtilController.php b/module/VuFindConsole/src/VuFindConsole/Controller/UtilController.php
index 2514ec0d605..644aa0fee48 100644
--- a/module/VuFindConsole/src/VuFindConsole/Controller/UtilController.php
+++ b/module/VuFindConsole/src/VuFindConsole/Controller/UtilController.php
@@ -556,6 +556,26 @@ class UtilController extends AbstractBase
         );
     }
 
+    /**
+     * Command-line tool to clear unwanted entries
+     * from auth_hash database table.
+     *
+     * @return \Zend\Console\Response
+     */
+    public function expireauthhashesAction()
+    {
+        $request = $this->getRequest();
+        if ($request->getParam('help') || $request->getParam('h')) {
+            return $this->expirationHelp('authentication hashes');
+        }
+
+        return $this->expire(
+            \VuFind\Db\Table\AuthHash::class,
+            '%%count%% expired authentication hashes deleted.',
+            'No expired authentication hashes to delete.'
+        );
+    }
+
     /**
      * Command-line tool to delete suppressed records from the index.
      *
-- 
GitLab