diff --git a/config/vufind/Alma.ini b/config/vufind/Alma.ini
index 7c5b26fe8be91a1ed4d24f06430a54141b910418..c9a7d749854090a1523df76fc9316b4161099210 100644
--- a/config/vufind/Alma.ini
+++ b/config/vufind/Alma.ini
@@ -9,6 +9,8 @@ http_timeout = 30
 ; vufind    Use VuFind's user database for authentication -- patrons are retrieved
 ;           from Alma without a password (default)
 ; password  Use password authentication with Alma internal users
+; email     Username needs to be a valid email address for the user (an
+;           authentication link is sent by email)
 ;loginMethod = vufind
 
 [Holds]
diff --git a/config/vufind/Demo.ini b/config/vufind/Demo.ini
index a435f8d21a7356b37d22ceeae5453afe8b1cc376..71de1079c0aa7b4c2cc63427ad1507e4eb790d0a 100644
--- a/config/vufind/Demo.ini
+++ b/config/vufind/Demo.ini
@@ -10,6 +10,11 @@ storageRetrievalRequests = true
 ; Whether to support ILL requests
 ILLRequests = true
 
+; Patron login method to use. The following options are available:
+; password  Normal username+password (the default)
+; email     Username is an email address
+;loginMethod = email
+
 ; Holds and holds-logic-related configuration options.
 [Holds]
 ; Max. no. of items displayed in the holdings tab. A paginator is used when there are
diff --git a/config/vufind/config.ini b/config/vufind/config.ini
index cd89e934d09fcf73529425e166ead27f974cf557..a8e4840c487e54f23de14d04ceeda3d5bdcca6b7 100644
--- a/config/vufind/config.ini
+++ b/config/vufind/config.ini
@@ -342,8 +342,16 @@ set_home_library = true
 ; You can use an LDAP directory, the local ILS (or multiple ILSes through
 ; the MultiILS option), the VuFind database (Database), a hard-coded list of
 ; access passwords (PasswordAccess), AlmaDatabase (combination
-; of VuFind database and Alma account), Shibboleth, SIP2, CAS, Facebook or some
-; combination of these (via the MultiAuth or ChoiceAuth options).
+; of VuFind database and Alma account), Shibboleth, SIP2, CAS, Facebook, Email or
+; some combination of these (via the MultiAuth or ChoiceAuth options).
+;
+; The Email method is special; it is intended to be used through ChoiceAuth in
+; combination with Database authentication (or any other method that reliably stores
+; the user's email address) to make it possible to log in by receiving an
+; authentication link at the email address stored in VuFind's database. Email is
+; 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.
 [Authentication]
 ;method          = LDAP
 ;method         = ILS
@@ -357,6 +365,7 @@ method         = Database
 ;method         = MultiILS
 ;method         = Facebook
 ;method         = PasswordAccess
+;method         = Email
 
 ; This setting only applies when method is set to ILS.  It determines which
 ; field of the ILS driver's patronLogin() return array is used as the username
diff --git a/languages/en.ini b/languages/en.ini
index fc56a294e6f7d4a9c10f9a81a1a7e9136d7b4a0c..92f69cd1b97a243be4d1661545c5270ee3ad1730 100644
--- a/languages/en.ini
+++ b/languages/en.ini
@@ -72,6 +72,7 @@ authentication_error_blank = "Login information cannot be blank."
 authentication_error_creation_blocked = "You do not have permission to create an account."
 authentication_error_denied = "Credentials do not match! Access denied."
 authentication_error_email_not_verified_html = "Your email address has not been verified yet. Please check your spam filter for the verification message. If necessary, we can <a href="%%url%%">Resend the Verification Email</a>."
+authentication_error_in_progress = "Authentication request is already being processed. Please try again later if you need to start over."
 authentication_error_invalid = "Invalid login -- please try again."
 authentication_error_loggedout = "You have logged out."
 authentication_error_technical = "We cannot log you in at this time. Please try again later."
@@ -333,6 +334,11 @@ Email this Search = "Email this Search"
 email_change_pending_html = "You have a pending email change to %%pending%%. Please click the link in the verification email sent to this address to complete the change. If necessary, we can <a href="%%url%%">Resend the Verification Email</a>."
 email_failure = "Error - Message Cannot Be Sent"
 email_link = "Link"
+email_login_desc = "Please use the following link to log in. If you did not initiate login, you may safely ignore this message. Please note that the link is only valid for a limited time and only in the browser you entered the email address with."
+email_login_link = "Link to login: <%%url%%>"
+email_login_link_sent = "We have sent a login link to your email address. It may take a few moments for the link to arrive. If you don't receive the link shortly, please check also your spam filter."
+email_login_requested = "Login has been requested with your email address at %%title%%."
+email_login_subject = "Login to %%title%%"
 email_maximum_recipients_note = "At most %%max%% recipients are allowed."
 email_multiple_recipients_note = "You may specify multiple recipients separated by commas."
 email_selected = "Email Selected"
diff --git a/languages/fi.ini b/languages/fi.ini
index 6a5bc88948105dffb6f611ab7964e580af4364e7..6c7afcc2c3ba2a81ae38a92cb379e9338c5f226b 100644
--- a/languages/fi.ini
+++ b/languages/fi.ini
@@ -69,7 +69,9 @@ Audio = "Ääni"
 authentication_error_admin = "Sisäänkirjautuminen epäonnistui. Ota yhteyttä järjestelmän ylläpitäjään."
 authentication_error_blank = "Käyttäjätunnus/salasana ei voi olla tyhjä."
 authentication_error_creation_blocked = "Sinulla ei ole oikeutta luoda käyttäjätiliä."
-authentication_error_denied = "Käyttäjätunnus/salasana ei täsmää! Ei pääsyä järjestelmään."
+authentication_error_denied = "Käyttäjätiedot eivät täsmää. Ei pääsyä järjestelmään."
+authentication_error_email_not_verified_html = "Sähköpostiosoitettasi ei ole vielä vahvistettu. Tarkista myös roskapostikansio siltä varalta, että varmistusviesti on mennyt sinne. Tarvittaessa voit <a href="%%url%%">pyytää uuden vahvistusviestin</a>."
+authentication_error_in_progress = "Kirjautuminen on jo käynnissä. Yritä myöhemmin uudelleen, jos haluat aloittaa alusta."
 authentication_error_invalid = "Käyttäjätunnus/salasana ei täsmää. Yritä uudestaan."
 authentication_error_loggedout = "Olet kirjautunut ulos."
 authentication_error_technical = "Sisäänkirjautuminen epäonnistui. Yritä uudestaan hetken kuluttua."
@@ -330,6 +332,11 @@ Email this = "Lähetä sähköpostilla"
 Email this Search = "Lähetä haku sähköpostilla"
 email_failure = "Virhe - viestiä ei voitu lähettää"
 email_link = "Linkki"
+email_login_desc = "Käytä seuraavaa linkkiä kirjautuaksesi sisään. Jos et ole kirjautumassa sisään, voit huoletta jättää tämän viestin huomiotta. Huomaa, että linkki on voimassa rajoitetun ajan ja toimii vain selaimessa, jossa annoit sähköpostiosoitteesi."
+email_login_link = "Linkki kirjautumiseen: <%%url%%>"
+email_login_link_sent = "Lähetimme kirjautumislinkin sähköpostiisi. Linkin saapuminen voi kestää hetken. Jos linkkiä ei ala kuulua, kannattaa tarkistaa sähköpostin roskapostikansio."
+email_login_requested = "Sähköpostiosoitteellasi on tehty kirjautumispyyntö palvelussa %%title%%."
+email_login_subject = "Kirjaudu palveluun %%title%%"
 email_maximum_recipients_note = "Korkeintaan %%max%% vastaanottajaa sallittu."
 email_multiple_recipients_note = "Voit luetella useita vastaanottajia pilkuilla eroteltuina."
 email_selected = "Lähetä valitut sähköpostilla"
diff --git a/languages/sv.ini b/languages/sv.ini
index 7c20dcd635011b0c2781ea29e871c6a7a2684a60..885236d1a8efa01069c9cca87a08fc355db07e59 100644
--- a/languages/sv.ini
+++ b/languages/sv.ini
@@ -70,6 +70,7 @@ authentication_error_admin = "Inloggningen misslyckades. Kontakta systemadminist
 authentication_error_blank = "Användarnamn/lösenord kan inte vara tomt."
 authentication_error_creation_blocked = "Du har inte tillstånd att skapa ett konto."
 authentication_error_denied = "Användarnamn/lösenord stämmer inte! Ingen tillgång till systemet."
+authentication_error_email_not_verified_html = "Din e-postadress har inte ännu verifierats. Kontrollera din skräppostmap för verifieringsmeddelandet. Du kan också begära <a href="%%url%%">ett nytt verifieringsmeddelande</a>."
 authentication_error_invalid = "Användarnamn/lösenord stämmer inte. Försök igen."
 authentication_error_loggedout = "Du har loggat ut."
 authentication_error_technical = "Inloggningen misslyckades. Försök igen efter en stund."
@@ -325,6 +326,11 @@ Email this = "Skicka per e-post"
 Email this Search = "Skicka sökningen per e-post"
 email_failure = "Fel - meddelandet kunde inte skickas"
 email_link = "Länk"
+email_login_desc = "Använd följande länk för att logga in. Om du inte begärde inloggning, kan du ignorera det här meddelandet. Observera att länken är giltig en begränsad tid och endast i webbläsaren du angav e-postadressen med."
+email_login_link = "Länk till inloggning: <%%url%%>"
+email_login_link_sent = "Vi skickade en inloggningslänk till din e-postadress. Det kan ta någon tid tills meddelanden kommer fram. Om du inte får länken inom kort, kontrollera även skräppostmappen."
+email_login_requested = "Inloggning har begärts med din e-postadress i %%title%%."
+email_login_subject = "Logga in till %%title%%"
 email_maximum_recipients_note = "Som mest %%max%% mottagare tillåtna."
 email_multiple_recipients_note = "Du kan ange flera mottagare separerade med kommatecken."
 email_selected = "E-posta valda"
diff --git a/module/VuFind/config/module.config.php b/module/VuFind/config/module.config.php
index b413c471867c5d49913691b117fd21598cc1beb2..5de84f136d395a35d52ea01e5a8d4645b7cfbf49 100644
--- a/module/VuFind/config/module.config.php
+++ b/module/VuFind/config/module.config.php
@@ -326,6 +326,7 @@ $config = [
         'factories' => [
             'ProxyManager\Configuration' => 'VuFind\Service\ProxyConfigFactory',
             'VuFind\AjaxHandler\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
+            'VuFind\Auth\EmailAuthenticator' => 'VuFind\Auth\EmailAuthenticatorFactory',
             'VuFind\Auth\ILSAuthenticator' => 'VuFind\Auth\ILSAuthenticatorFactory',
             'VuFind\Auth\Manager' => 'VuFind\Auth\ManagerFactory',
             'VuFind\Auth\PluginManager' => 'VuFind\ServiceManager\AbstractPluginManagerFactory',
diff --git a/module/VuFind/src/VuFind/Auth/Email.php b/module/VuFind/src/VuFind/Auth/Email.php
new file mode 100644
index 0000000000000000000000000000000000000000..3d5725feb6ec10a7f92f54a6cf0440ab6b79313d
--- /dev/null
+++ b/module/VuFind/src/VuFind/Auth/Email.php
@@ -0,0 +1,159 @@
+<?php
+/**
+ * Email authentication module.
+ *
+ * PHP version 7
+ *
+ * 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  Authentication
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
+ */
+namespace VuFind\Auth;
+
+use VuFind\Exception\Auth as AuthException;
+
+/**
+ * Email authentication module.
+ *
+ * @category VuFind
+ * @package  Authentication
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
+ */
+class Email extends AbstractBase
+{
+    /**
+     * Email Authenticator
+     *
+     * @var EmailAuthenticator
+     */
+    protected $emailAuthenticator;
+
+    /**
+     * Constructor
+     *
+     * @param EmailAuthenticator $emailAuth Email authenticator
+     */
+    public function __construct(EmailAuthenticator $emailAuth)
+    {
+        $this->emailAuthenticator = $emailAuth;
+    }
+
+    /**
+     * Attempt to authenticate the current user.  Throws exception if login fails.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object containing
+     * account credentials.
+     *
+     * @throws AuthException
+     * @return \VuFind\Db\Row\User Object representing logged-in user.
+     */
+    public function authenticate($request)
+    {
+        // This is a dual-mode method:
+        // First, try to find a user account with the provided email address and send
+        // a login link.
+        // Second, log the user in with the hash from the login link.
+
+        $email = trim($request->getPost()->get('username'));
+        $hash = $request->getQuery('hash');
+        if (!$email && !$hash) {
+            throw new AuthException('authentication_error_blank');
+        }
+
+        if (!$hash) {
+            // Validate the credentials:
+            $user = $this->getUserTable()->getByEmail($email, false);
+            if ($user) {
+                $loginData = [
+                    'vufind_id' => $user['id']
+                ];
+                $this->emailAuthenticator
+                    ->sendAuthenticationLink($user['email'], $loginData);
+            }
+            // Don't reveal the result
+            throw new \VuFind\Exception\AuthInProgress('email_login_link_sent');
+        }
+
+        $loginData = $this->emailAuthenticator->authenticate($hash);
+        if (isset($loginData['vufind_id'])) {
+            return $this->getUserTable()->getById($loginData['vufind_id']);
+        } else {
+            return $this->processUser($loginData);
+        }
+
+        // If we got this far, we have a problem:
+        throw new AuthException('authentication_error_invalid');
+    }
+
+    /**
+     * Whether this authentication method needs CSRF checking for the request.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object.
+     *
+     * @return bool
+     */
+    public function needsCsrfCheck($request)
+    {
+        // Disable CSRF if we get a hash in the request
+        return $request->getQuery('hash') ? false : true;
+    }
+
+    /**
+     * Update the database using login user details, then return the User object.
+     *
+     * @param array $info User details returned by the login initiator like ILS.
+     *
+     * @throws AuthException
+     * @return \VuFind\Db\Row\User Processed User object.
+     */
+    protected function processUser($info)
+    {
+        // Check to see if we already have an account for this user:
+        $userTable = $this->getUserTable();
+        if (!empty($info['id'])) {
+            $user = $userTable->getByCatalogId($info['id']);
+            if (empty($user)) {
+                $user = $userTable->getByUsername($info['email']);
+                $user->saveCatalogId($info['id']);
+            }
+        } else {
+            $user = $userTable->getByUsername($info['email']);
+        }
+
+        // No need to store a password in VuFind's main password field:
+        $user->password = '';
+
+        // Update user information based on received data:
+        $fields = ['firstname', 'lastname', 'email', 'major', 'college'];
+        foreach ($fields as $field) {
+            $user->$field = $info[$field] ?? ' ';
+        }
+
+        // Update the user in the database, then return it to the caller:
+        $user->saveCredentials(
+            $info['cat_username'] ?? ' ',
+            $info['cat_password'] ?? ' '
+        );
+
+        return $user;
+    }
+}
diff --git a/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php b/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php
new file mode 100644
index 0000000000000000000000000000000000000000..5775a59d72cac37d151977c21b622e06482efb70
--- /dev/null
+++ b/module/VuFind/src/VuFind/Auth/EmailAuthenticator.php
@@ -0,0 +1,237 @@
+<?php
+/**
+ * Class for managing email-based authentication.
+ *
+ * PHP version 7
+ *
+ * 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  Authentication
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
+ */
+namespace VuFind\Auth;
+
+use VuFind\Exception\Auth as AuthException;
+
+/**
+ * Class for managing email-based authentication.
+ *
+ * This class provides functionality for authentication based on a known-valid email
+ * address.
+ *
+ * @category VuFind
+ * @package  Authentication
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
+ */
+class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInterface
+{
+    use \VuFind\I18n\Translator\TranslatorAwareTrait;
+
+    /**
+     * Session Manager
+     *
+     * @var \Zend\Session\SessionManager
+     */
+    protected $sessionManager = null;
+
+    /**
+     * CSRF Validator
+     *
+     * @var \VuFind\Validator\Csrf $csrf CSRF validator
+     */
+    protected $csrf = null;
+
+    /**
+     * Mailer
+     *
+     * @var \VuFind\Mailer\Mailer
+     */
+    protected $mailer = null;
+
+    /**
+     * View Renderer
+     *
+     * @var \Zend\View\Renderer\RendererInterface
+     */
+    protected $viewRenderer = null;
+
+    /**
+     * Request
+     *
+     * @var \Zend\Stdlib\RequestInterface
+     */
+    protected $request;
+
+    /**
+     * Configuration
+     *
+     * @var \Zend\Config\Config
+     */
+    protected $config;
+
+    /**
+     * How long a login request is considered to be valid (seconds)
+     *
+     * @var int
+     */
+    protected $loginRequestValidTime = 600;
+
+    /**
+     * Constructor
+     *
+     * @param \Zend\Session\SessionManager          $session      Session Manager
+     * @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 \Zend\Config\Config                   $config       Configuration
+     */
+    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
+    ) {
+        $this->sessionManager = $session;
+        $this->csrf = $csrf;
+        $this->mailer = $mailer;
+        $this->viewRenderer = $viewRenderer;
+        $this->request = $request;
+        $this->config = $config;
+    }
+
+    /**
+     * Send an email authentication link to the specified email address.
+     *
+     * Stores the required information in the session.
+     *
+     * @param string $email     Email address to send the link to
+     * @param array  $data      Information from the authentication request (such as
+     * user details)
+     * @param array  $urlParams Default parameters for the generated URL
+     * @param string $linkRoute The route to use as the base url for the login link
+     *
+     * @return void
+     */
+    public function sendAuthenticationLink($email, $data,
+        $urlParams, $linkRoute = 'myresearch-home'
+    ) {
+        $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
+        ) {
+            throw new AuthException('authentication_error_in_progress');
+        }
+
+        $this->csrf->trimTokenList(5);
+        $linkData = [
+            'timestamp' => time(),
+            'data' => $data,
+            'email' => $email
+        ];
+        $hash = $this->csrf->getHash(true);
+
+        if (!isset($sessionContainer->requests)) {
+            $sessionContainer->requests = [];
+        }
+        $sessionContainer->requests[$hash] = $linkData;
+
+        $serverHelper = $this->viewRenderer->plugin('serverurl');
+        $urlHelper = $this->viewRenderer->plugin('url');
+        $urlParams['hash'] = $hash;
+        $viewParams = $linkData;
+        $viewParams['url'] = $serverHelper(
+            $urlHelper($linkRoute, [], ['query' => $urlParams])
+        );
+        $viewParams['title'] = $this->config->Site->title;
+
+        $message = $this->viewRenderer->render(
+            'Email/login-link.phtml',
+            $viewParams
+        );
+        $from = !empty($this->config->Mail->user_email_in_from)
+            ? $email
+            : ($this->config->Mail->default_from ?? $this->config->Site->email);
+        $subject = $this->translator->translate('email_login_subject');
+        $subject = str_replace('%%title%%', $viewParams['title'], $subject);
+
+        $this->mailer->send($email, $from, $subject, $message);
+    }
+
+    /**
+     * Authenticate using a hash
+     *
+     * @param string $hash Hash
+     *
+     * @return array
+     * @throws AuthException
+     */
+    public function authenticate($hash)
+    {
+        $sessionContainer = $this->getSessionContainer();
+
+        if (!isset($sessionContainer->requests[$hash])) {
+            throw new AuthException('authentication_error_denied');
+        }
+        $linkData = $sessionContainer->requests[$hash];
+        unset($sessionContainer->requests[$hash]);
+        if (time() - $linkData['timestamp'] > $this->loginRequestValidTime) {
+            throw new AuthException('authentication_error_denied');
+        }
+
+        return $linkData['data'];
+    }
+
+    /**
+     * Check if the given request is a valid login request
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object.
+     *
+     * @return bool
+     */
+    public function isValidLoginRequest(\Zend\Http\PhpEnvironment\Request $request)
+    {
+        $hash = $request->getPost()->get(
+            'hash',
+            $request->getQuery()->get('hash', '')
+        );
+        if ($hash) {
+            $sessionContainer = $this->getSessionContainer();
+            return isset($sessionContainer->requests[$hash]);
+        }
+        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
new file mode 100644
index 0000000000000000000000000000000000000000..150b8c7d701430e6999dcfbf5183a92ad034b847
--- /dev/null
+++ b/module/VuFind/src/VuFind/Auth/EmailAuthenticatorFactory.php
@@ -0,0 +1,73 @@
+<?php
+/**
+ * Factory for email authenticator module.
+ *
+ * PHP version 7
+ *
+ * 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  Authentication
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Auth;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for email authenticator module.
+ *
+ * @category VuFind
+ * @package  Authentication
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class EmailAuthenticatorFactory
+    implements \Zend\ServiceManager\Factory\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.');
+        }
+        return new $requestedName(
+            $container->get(\Zend\Session\SessionManager::class),
+            $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')
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/Auth/EmailFactory.php b/module/VuFind/src/VuFind/Auth/EmailFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..60dc9055212f634d000e3c17ce35f19be50975a1
--- /dev/null
+++ b/module/VuFind/src/VuFind/Auth/EmailFactory.php
@@ -0,0 +1,67 @@
+<?php
+/**
+ * Factory for Email authentication module.
+ *
+ * PHP version 7
+ *
+ * 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  Authentication
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Auth;
+
+use Interop\Container\ContainerInterface;
+
+/**
+ * Factory for Email authentication module.
+ *
+ * @category VuFind
+ * @package  Authentication
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class EmailFactory implements \Zend\ServiceManager\Factory\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.');
+        }
+        return new $requestedName(
+            $container->get(EmailAuthenticator::class)
+        );
+    }
+}
diff --git a/module/VuFind/src/VuFind/Auth/ILS.php b/module/VuFind/src/VuFind/Auth/ILS.php
index f7d8e691c2fb30c83b3e0775d36e6da661c950be..8923d7aa042daf07b759867bed9fa8c911df256f 100644
--- a/module/VuFind/src/VuFind/Auth/ILS.php
+++ b/module/VuFind/src/VuFind/Auth/ILS.php
@@ -58,17 +58,27 @@ class ILS extends AbstractBase
     protected $catalog = null;
 
     /**
-     * Set the ILS connection for this object.
+     * Email Authenticator
+     *
+     * @var EmailAuthenticator
+     */
+    protected $emailAuthenticator;
+
+    /**
+     * Constructor
      *
      * @param \VuFind\ILS\Connection    $connection    ILS connection to set
      * @param \VuFind\ILS\Authenticator $authenticator ILS authenticator
+     * @param EmailAuthenticator        $emailAuth     Email authenticator
      */
     public function __construct(
         \VuFind\ILS\Connection $connection,
-        \VuFind\Auth\ILSAuthenticator $authenticator
+        \VuFind\Auth\ILSAuthenticator $authenticator,
+        EmailAuthenticator $emailAuth = null
     ) {
         $this->setCatalog($connection);
         $this->authenticator = $authenticator;
+        $this->emailAuthenticator = $emailAuth;
     }
 
     /**
@@ -107,27 +117,9 @@ class ILS extends AbstractBase
     {
         $username = trim($request->getPost()->get('username'));
         $password = trim($request->getPost()->get('password'));
-        if ($username == '' || $password == '') {
-            throw new AuthException('authentication_error_blank');
-        }
-
-        // Connect to catalog:
-        try {
-            $patron = $this->getCatalog()->patronLogin($username, $password);
-        } catch (AuthException $e) {
-            // Pass Auth exceptions through
-            throw $e;
-        } catch (\Exception $e) {
-            throw new AuthException('authentication_error_technical');
-        }
-
-        // Did the patron successfully log in?
-        if ($patron) {
-            return $this->processILSUser($patron);
-        }
+        $loginMethod = $this->getILSLoginMethod();
 
-        // If we got this far, we have a problem:
-        throw new AuthException('authentication_error_invalid');
+        return $this->handleLogin($username, $password, $loginMethod);
     }
 
     /**
@@ -208,6 +200,88 @@ class ILS extends AbstractBase
         return $user;
     }
 
+    /**
+     * What login method does the ILS use (password, email, vufind)
+     *
+     * @param string $target Login target (MultiILS only)
+     *
+     * @return string
+     */
+    public function getILSLoginMethod($target = '')
+    {
+        $config = $this->getCatalog()->checkFunction(
+            'patronLogin', ['patron' => ['cat_username' => "$target.login"]]
+        );
+        return $config['loginMethod'] ?? 'password';
+    }
+
+    /**
+     * Returns any authentication method this request should be delegated to.
+     *
+     * @param \Zend\Http\PhpEnvironment\Request $request Request object.
+     *
+     * @return string|bool
+     */
+    public function getDelegateAuthMethod(\Zend\Http\PhpEnvironment\Request $request)
+    {
+        return (null !== $this->emailAuthenticator
+            && $this->emailAuthenticator->isValidLoginRequest($request))
+                ? 'Email' : false;
+    }
+
+    /**
+     * Handle the actual login with the ILS.
+     *
+     * @param string $username    User name
+     * @param string $password    Password
+     * @param string $loginMethod Login method
+     *
+     * @throws AuthException
+     * @return \VuFind\Db\Row\User Processed User object.
+     */
+    protected function handleLogin($username, $password, $loginMethod)
+    {
+        if ($username == '' || ('password' === $loginMethod && $password == '')) {
+            throw new AuthException('authentication_error_blank');
+        }
+
+        // Connect to catalog:
+        try {
+            $patron = $this->getCatalog()->patronLogin($username, $password);
+        } catch (AuthException $e) {
+            // Pass Auth exceptions through
+            throw $e;
+        } catch (\Exception $e) {
+            throw new AuthException('authentication_error_technical');
+        }
+
+        // Did the patron successfully log in?
+        if ('email' === $loginMethod) {
+            if (null === $this->emailAuthenticator) {
+                throw new \Exception('Email authenticator not set');
+            }
+            if ($patron) {
+                $class = get_class($this);
+                if ($p = strrpos($class, '\\')) {
+                    $class = substr($class, $p + 1);
+                }
+                $this->emailAuthenticator->sendAuthenticationLink(
+                    $patron['email'],
+                    $patron,
+                    ['auth_method' => $class]
+                );
+            }
+            // Don't reveal the result
+            throw new \VuFind\Exception\AuthInProgress('email_login_link_sent');
+        }
+        if ($patron) {
+            return $this->processILSUser($patron);
+        }
+
+        // If we got this far, we have a problem:
+        throw new AuthException('authentication_error_invalid');
+    }
+
     /**
      * Update the database using details from the ILS, then return the User object.
      *
diff --git a/module/VuFind/src/VuFind/Auth/ILSAuthenticator.php b/module/VuFind/src/VuFind/Auth/ILSAuthenticator.php
index 60473282fd58789a1065214ebc5cbd761f341815..0a1f0232dcf0123eed8ca930003a524be6850ca0 100644
--- a/module/VuFind/src/VuFind/Auth/ILSAuthenticator.php
+++ b/module/VuFind/src/VuFind/Auth/ILSAuthenticator.php
@@ -54,6 +54,13 @@ class ILSAuthenticator
      */
     protected $catalog;
 
+    /**
+     * Email authenticator
+     *
+     * @var EmailAuthenticator
+     */
+    protected $emailAuthenticator;
+
     /**
      * Cache for ILS account information (keyed by username)
      *
@@ -64,13 +71,16 @@ class ILSAuthenticator
     /**
      * Constructor
      *
-     * @param Manager       $auth    Auth manager
-     * @param ILSConnection $catalog ILS connection
+     * @param Manager            $auth      Auth manager
+     * @param ILSConnection      $catalog   ILS connection
+     * @param EmailAuthenticator $emailAuth Email authenticator
      */
-    public function __construct(Manager $auth, ILSConnection $catalog)
-    {
+    public function __construct(Manager $auth, ILSConnection $catalog,
+        EmailAuthenticator $emailAuth = null
+    ) {
         $this->auth = $auth;
         $this->catalog = $catalog;
+        $this->emailAuthenticator = $emailAuth;
     }
 
     /**
@@ -146,15 +156,77 @@ class ILSAuthenticator
     {
         $result = $this->catalog->patronLogin($username, $password);
         if ($result) {
-            $user = $this->auth->isLoggedIn();
-            if ($user) {
-                $user->saveCredentials($username, $password);
-                $this->auth->updateSession($user);
-                // cache for future use
-                $this->ilsAccount[$username] = $result;
-            }
+            $this->updateUser($username, $password, $result);
             return $result;
         }
         return false;
     }
+
+    /**
+     * Send email authentication link
+     *
+     * @param string $email Email address
+     * @param string $route Route for the login link
+     *
+     * @return void
+     */
+    public function sendEmailLoginLink($email, $route)
+    {
+        if (null === $this->emailAuthenticator) {
+            throw new \Exception('Email authenticator not set');
+        }
+
+        $patron = $this->catalog->patronLogin($email, '');
+        if ($patron) {
+            $this->emailAuthenticator->sendAuthenticationLink(
+                $patron['email'],
+                $patron,
+                ['auth_method' => 'ILS'],
+                $route
+            );
+        }
+    }
+
+    /**
+     * Process email login
+     *
+     * @param string $hash Login hash
+     *
+     * @return array|bool
+     * @throws ILSException
+     */
+    public function processEmailLoginHash($hash)
+    {
+        if (null === $this->emailAuthenticator) {
+            throw new \Exception('Email authenticator not set');
+        }
+
+        try {
+            $patron = $this->emailAuthenticator->authenticate($hash);
+        } catch (\Vufind\Exception\Auth $e) {
+            return false;
+        }
+        $this->updateUser($patron['cat_username'], '', $patron);
+        return $patron;
+    }
+
+    /**
+     * Update current user account with the patron information
+     *
+     * @param string $catUsername Catalog username
+     * @param string $catPassword Catalog password
+     * @param array  $patron      Patron
+     *
+     * @return void
+     */
+    protected function updateUser($catUsername, $catPassword, $patron)
+    {
+        $user = $this->auth->isLoggedIn();
+        if ($user) {
+            $user->saveCredentials($catUsername, $catPassword);
+            $this->auth->updateSession($user);
+            // cache for future use
+            $this->ilsAccount[$catUsername] = $patron;
+        }
+    }
 }
diff --git a/module/VuFind/src/VuFind/Auth/ILSAuthenticatorFactory.php b/module/VuFind/src/VuFind/Auth/ILSAuthenticatorFactory.php
index 7b7e64cd48ec37c5bf7fc731fa6c9a2683370eee..92cbaddd7f45e379a2cd9e8e33e99e969c9f3290 100644
--- a/module/VuFind/src/VuFind/Auth/ILSAuthenticatorFactory.php
+++ b/module/VuFind/src/VuFind/Auth/ILSAuthenticatorFactory.php
@@ -70,7 +70,8 @@ class ILSAuthenticatorFactory implements FactoryInterface
             // Generate wrapped object:
             $auth = $container->get(\VuFind\Auth\Manager::class);
             $catalog = $container->get(\VuFind\ILS\Connection::class);
-            $wrapped = new $requestedName($auth, $catalog);
+            $emailAuth = $container->get(\VuFind\Auth\EmailAuthenticator::class);
+            $wrapped = new $requestedName($auth, $catalog, $emailAuth);
 
             // Indicate that initialization is complete to avoid reinitialization:
             $proxy->setProxyInitializer(null);
diff --git a/module/VuFind/src/VuFind/Auth/ILSFactory.php b/module/VuFind/src/VuFind/Auth/ILSFactory.php
index 2c6972dc742decd2ada7e9997c67a01f8b488ecf..2d1d59469f576ae9ee73bcd1f8ca6b4fedaf25d0 100644
--- a/module/VuFind/src/VuFind/Auth/ILSFactory.php
+++ b/module/VuFind/src/VuFind/Auth/ILSFactory.php
@@ -62,7 +62,8 @@ class IlsFactory implements \Zend\ServiceManager\Factory\FactoryInterface
         }
         return new $requestedName(
             $container->get(\VuFind\ILS\Connection::class),
-            $container->get(ILSAuthenticator::class)
+            $container->get(ILSAuthenticator::class),
+            $container->get(EmailAuthenticator::class)
         );
     }
 }
diff --git a/module/VuFind/src/VuFind/Auth/Manager.php b/module/VuFind/src/VuFind/Auth/Manager.php
index ae0119c9c4c77092cd80f7043808a3b303ff64eb..2c8a21f8c47bf1f4011707c8564749d9d811438d 100644
--- a/module/VuFind/src/VuFind/Auth/Manager.php
+++ b/module/VuFind/src/VuFind/Auth/Manager.php
@@ -596,6 +596,8 @@ class Manager implements \ZfcRbac\Identity\IdentityProviderInterface
      * account credentials.
      *
      * @throws AuthException
+     * @throws \VuFind\Exception\PasswordSecurity
+     * @throws \VuFind\Exception\AuthInProgress
      * @return UserRow Object representing logged-in user.
      */
     public function login($request)
@@ -702,6 +704,22 @@ class Manager implements \ZfcRbac\Identity\IdentityProviderInterface
         return $this->getAuth()->validateCredentials($request);
     }
 
+    /**
+     * What login method does the ILS use (password, email, vufind)
+     *
+     * @param string $target Login target (MultiILS only)
+     *
+     * @return array|false
+     */
+    public function getILSLoginMethod($target = '')
+    {
+        $auth = $this->getAuth();
+        if (is_callable([$auth, 'getILSLoginMethod'])) {
+            return $auth->getILSLoginMethod($target);
+        }
+        return false;
+    }
+
     /**
      * Update common user attributes on login
      *
diff --git a/module/VuFind/src/VuFind/Auth/MultiILS.php b/module/VuFind/src/VuFind/Auth/MultiILS.php
index e4cd1822bfc4937051f5062311ccdbbec27d02ca..73bc43dfa14b05d542c7cdce8d27f431c8e9b9de 100644
--- a/module/VuFind/src/VuFind/Auth/MultiILS.php
+++ b/module/VuFind/src/VuFind/Auth/MultiILS.php
@@ -57,35 +57,17 @@ class MultiILS extends ILS
      */
     public function authenticate($request)
     {
-        $target = trim($request->getPost()->get('target'));
         $username = trim($request->getPost()->get('username'));
         $password = trim($request->getPost()->get('password'));
-        if ($username == '' || $password == '') {
-            throw new AuthException('authentication_error_blank');
-        }
+        $target = trim($request->getPost()->get('target'));
+        $loginMethod = $this->getILSLoginMethod($target);
 
         // We should have target either separately or already embedded into username
         if ($target) {
             $username = "$target.$username";
         }
 
-        // Connect to catalog:
-        try {
-            $patron = $this->getCatalog()->patronLogin($username, $password);
-        } catch (AuthException $e) {
-            // Pass Auth exceptions through
-            throw $e;
-        } catch (\Exception $e) {
-            throw new AuthException('authentication_error_technical');
-        }
-
-        // Did the patron successfully log in?
-        if ($patron) {
-            return $this->processILSUser($patron);
-        }
-
-        // If we got this far, we have a problem:
-        throw new AuthException('authentication_error_invalid');
+        return $this->handleLogin($username, $password, $loginMethod);
     }
 
     /**
diff --git a/module/VuFind/src/VuFind/Auth/PluginManager.php b/module/VuFind/src/VuFind/Auth/PluginManager.php
index 7daaff81ac0a350b69b2819b742a88399d68deff..4d29dc5346ed2711f566a25e836ea8c3c807b292 100644
--- a/module/VuFind/src/VuFind/Auth/PluginManager.php
+++ b/module/VuFind/src/VuFind/Auth/PluginManager.php
@@ -50,6 +50,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         'cas' => CAS::class,
         'choiceauth' => ChoiceAuth::class,
         'database' => Database::class,
+        'email' => Email::class,
         'facebook' => Facebook::class,
         'ils' => ILS::class,
         'ldap' => LDAP::class,
@@ -72,6 +73,7 @@ class PluginManager extends \VuFind\ServiceManager\AbstractPluginManager
         CAS::class => InvokableFactory::class,
         ChoiceAuth::class => ChoiceAuthFactory::class,
         Database::class => InvokableFactory::class,
+        Email::class => EmailFactory::class,
         Facebook::class => FacebookFactory::class,
         ILS::class => ILSFactory::class,
         LDAP::class => InvokableFactory::class,
diff --git a/module/VuFind/src/VuFind/Controller/AbstractBase.php b/module/VuFind/src/VuFind/Controller/AbstractBase.php
index 90e9961f4f173c2174b259b063776552f9b62379..454af22f3ede9514e85029a96f150c66e6dbce87 100644
--- a/module/VuFind/src/VuFind/Controller/AbstractBase.php
+++ b/module/VuFind/src/VuFind/Controller/AbstractBase.php
@@ -28,6 +28,7 @@
  */
 namespace VuFind\Controller;
 
+use VuFind\Exception\Auth as AuthException;
 use VuFind\Exception\ILS as ILSException;
 use Zend\Mvc\Controller\AbstractActionController;
 use Zend\Mvc\MvcEvent;
@@ -354,15 +355,33 @@ class AbstractBase extends AbstractActionController
                 $username = "$target.$username";
             }
             try {
-                $patron = $ilsAuth->newCatalogLogin($username, $password);
-
-                // If login failed, store a warning message:
-                if (!$patron) {
-                    $this->flashMessenger()->addErrorMessage('Invalid Patron Login');
+                if ('email' === $this->getILSLoginMethod($target)) {
+                    $routeMatch = $this->getEvent()->getRouteMatch();
+                    $routeName = $routeMatch ? $routeMatch->getMatchedRouteName()
+                        : 'myresearch-profile';
+                    $ilsAuth->sendEmailLoginLink($username, $routeName);
+                    $this->flashMessenger()
+                        ->addSuccessMessage('email_login_link_sent');
+                } else {
+                    $patron = $ilsAuth->newCatalogLogin($username, $password);
+
+                    // If login failed, store a warning message:
+                    if (!$patron) {
+                        $this->flashMessenger()
+                            ->addErrorMessage('Invalid Patron Login');
+                    }
                 }
             } catch (ILSException $e) {
                 $this->flashMessenger()->addErrorMessage('ils_connection_failed');
             }
+        } elseif ('ILS' === $this->params()->fromQuery('auth_method', false)
+            && ($hash = $this->params()->fromQuery('hash', false))
+        ) {
+            try {
+                $patron = $ilsAuth->processEmailLoginHash($hash);
+            } catch (AuthException $e) {
+                $this->flashMessenger()->addErrorMessage($e->getMessage());
+            }
         } else {
             try {
                 // If no credentials were provided, try the stored values:
@@ -716,4 +735,44 @@ class AbstractBase extends AbstractActionController
             ) === 'lightbox'
             || 'layout/lightbox' == $this->layout()->getTemplate();
     }
+
+    /**
+     * What login method does the ILS use (password, email, vufind)
+     *
+     * @param string $target Login target (MultiILS only)
+     *
+     * @return string
+     */
+    protected function getILSLoginMethod($target = '')
+    {
+        $config = $this->getILS()->checkFunction(
+            'patronLogin', ['patron' => ['cat_username' => "$target.login"]]
+        );
+        return $config['loginMethod'] ?? 'password';
+    }
+
+    /**
+     * Get settings required for displaying the catalog login form
+     *
+     * @return array
+     */
+    protected function getILSLoginSettings()
+    {
+        $targets = null;
+        $defaultTarget = null;
+        $loginMethod = null;
+        $loginMethods = [];
+        // Connect to the ILS and check if multiple target support is available:
+        $catalog = $this->getILS();
+        if ($catalog->checkCapability('getLoginDrivers')) {
+            $targets = $catalog->getLoginDrivers();
+            $defaultTarget = $catalog->getDefaultLoginDriver();
+            foreach ($targets as $t) {
+                $loginMethods[$t] = $this->getILSLoginMethod($t);
+            }
+        } else {
+            $loginMethod = $this->getILSLoginMethod();
+        }
+        return compact('targets', 'defaultTarget', 'loginMethod', 'loginMethods');
+    }
 }
diff --git a/module/VuFind/src/VuFind/Controller/LibraryCardsController.php b/module/VuFind/src/VuFind/Controller/LibraryCardsController.php
index dbc47d66067bc6bc0d92b5aa016f9a8066f914e7..4d249b208a59fb6cf830870cbda0b62b75e3bab0 100644
--- a/module/VuFind/src/VuFind/Controller/LibraryCardsController.php
+++ b/module/VuFind/src/VuFind/Controller/LibraryCardsController.php
@@ -5,7 +5,7 @@
  * PHP version 7
  *
  * Copyright (C) Villanova University 2010.
- * Copyright (C) The National Library of Finland 2015.
+ * Copyright (C) The National Library of Finland 2015-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,
@@ -99,6 +99,13 @@ class LibraryCardsController extends AbstractBase
             return $this->forceLogin();
         }
 
+        // Process email authentication:
+        if ($this->params()->fromQuery('auth_method') === 'Email'
+            && ($hash = $this->params()->fromQuery('hash'))
+        ) {
+            return $this->processEmailLink($user, $hash);
+        }
+
         // Process form submission:
         if ($this->formWasSubmitted('submit')) {
             if ($redirect = $this->processEditLibraryCard($user)) {
@@ -111,17 +118,13 @@ class LibraryCardsController extends AbstractBase
 
         $target = null;
         $username = $card->cat_username;
-        $targets = null;
-        $defaultTarget = null;
-        // Connect to the ILS and check if multiple target support is available:
-        $catalog = $this->getILS();
-        if ($catalog->checkCapability('getLoginDrivers')) {
-            $targets = $catalog->getLoginDrivers();
-            $defaultTarget = $catalog->getDefaultLoginDriver();
-            if (strstr($username, '.')) {
-                list($target, $username) = explode('.', $username, 2);
-            }
+
+        $loginSettings = $this->getILSLoginSettings();
+        // Split target and username if multiple login targets are available:
+        if ($loginSettings['targets'] && strstr($username, '.')) {
+            list($target, $username) = explode('.', $username, 2);
         }
+
         $cardName = $this->params()->fromPost('card_name', $card->card_name);
         $username = $this->params()->fromPost('username', $username);
         $target = $this->params()->fromPost('target', $target);
@@ -131,10 +134,12 @@ class LibraryCardsController extends AbstractBase
             [
                 'card' => $card,
                 'cardName' => $cardName,
-                'target' => $target ? $target : $defaultTarget,
+                'target' => $target ?: $loginSettings['defaultTarget'],
                 'username' => $username,
-                'targets' => $targets,
-                'defaultTarget' => $defaultTarget
+                'targets' => $loginSettings['targets'],
+                'defaultTarget' => $loginSettings['defaultTarget'],
+                'loginMethod' => $loginSettings['loginMethod'],
+                'loginMethods' => $loginSettings['loginMethods'],
             ]
         );
     }
@@ -266,13 +271,32 @@ class LibraryCardsController extends AbstractBase
         $card = $user->getLibraryCard($id == 'NEW' ? null : $id);
         if ($card->cat_username !== $username || trim($password)) {
             // Connect to the ILS and check that the credentials are correct:
+            $loginMethod = $this->getILSLoginMethod($target);
             $catalog = $this->getILS();
             $patron = $catalog->patronLogin($username, $password);
-            if (!$patron) {
+            if ('password' === $loginMethod && !$patron) {
                 $this->flashMessenger()
                     ->addMessage('authentication_error_invalid', 'error');
                 return false;
             }
+            if ('email' === $loginMethod) {
+                if ($patron) {
+                    $info = $patron;
+                    $info['cardID'] = $id;
+                    $info['cardName'] = $cardName;
+                    $emailAuthenticator = $this->serviceLocator
+                        ->get(\VuFind\Auth\EmailAuthenticator::class);
+                    $emailAuthenticator->sendAuthenticationLink(
+                        $info['email'],
+                        $info,
+                        ['auth_method' => 'Email'],
+                        'editLibraryCard'
+                    );
+                }
+                // Don't reveal the result
+                $this->flashMessenger()->addSuccessMessage('email_login_link_sent');
+                return $this->redirect()->toRoute('librarycards-home');
+            }
         }
 
         try {
@@ -286,4 +310,33 @@ class LibraryCardsController extends AbstractBase
 
         return $this->redirect()->toRoute('librarycards-home');
     }
+
+    /**
+     * Process library card addition via an email link
+     *
+     * @param User   $user User object
+     * @param string $hash Hash
+     *
+     * @return \Zend\Http\Response Response object
+     */
+    protected function processEmailLink($user, $hash)
+    {
+        $emailAuthenticator = $this->serviceLocator
+            ->get(\VuFind\Auth\EmailAuthenticator::class);
+        try {
+            $info = $emailAuthenticator->authenticate($hash);
+            $user->saveLibraryCard(
+                'NEW' === $info['cardID'] ? null : $info['cardID'],
+                $info['cardName'],
+                $info['cat_username'],
+                ' '
+            );
+        } catch (\VuFind\Exception\Auth $e) {
+            $this->flashMessenger()->addErrorMessage($e->getMessage());
+        } catch (\VuFind\Exception\LibraryCard $e) {
+            $this->flashMessenger()->addErrorMessage($e->getMessage());
+        }
+
+        return $this->redirect()->toRoute('librarycards-home');
+    }
 }
diff --git a/module/VuFind/src/VuFind/Controller/MyResearchController.php b/module/VuFind/src/VuFind/Controller/MyResearchController.php
index 19d2e2952f4284a1cf98c44e63cfe6c18df752e8..c00f2f9b43b99dc896bf1c1e7b58c4719427daf0 100644
--- a/module/VuFind/src/VuFind/Controller/MyResearchController.php
+++ b/module/VuFind/src/VuFind/Controller/MyResearchController.php
@@ -29,6 +29,7 @@ namespace VuFind\Controller;
 
 use VuFind\Exception\Auth as AuthException;
 use VuFind\Exception\AuthEmailNotVerified as AuthEmailNotVerifiedException;
+use VuFind\Exception\AuthInProgress as AuthInProgressException;
 use VuFind\Exception\Forbidden as ForbiddenException;
 use VuFind\Exception\ILS as ILSException;
 use VuFind\Exception\ListPermission as ListPermissionException;
@@ -92,6 +93,10 @@ class MyResearchController extends AbstractBase
     protected function processAuthenticationException(AuthException $e)
     {
         $msg = $e->getMessage();
+        if ($e instanceof AuthInProgressException) {
+            $this->flashMessenger()->addSuccessMessage($msg);
+            return;
+        }
         if ($e instanceof AuthEmailNotVerifiedException) {
             $this->sendFirstVerificationEmail($e->user);
             if ($msg == 'authentication_error_email_not_verified_html') {
@@ -548,13 +553,8 @@ class MyResearchController extends AbstractBase
      */
     public function catalogloginAction()
     {
-        // Connect to the ILS and check if multiple target support is available:
-        $targets = null;
-        $catalog = $this->getILS();
-        if ($catalog->checkCapability('getLoginDrivers')) {
-            $targets = $catalog->getLoginDrivers();
-        }
-        return $this->createViewModel(['targets' => $targets]);
+        $loginSettings = $this->getILSLoginSettings();
+        return $this->createViewModel($loginSettings);
     }
 
     /**
diff --git a/module/VuFind/src/VuFind/Exception/AuthInProgress.php b/module/VuFind/src/VuFind/Exception/AuthInProgress.php
new file mode 100644
index 0000000000000000000000000000000000000000..31d6da3e6533a9779e4804069900db56958adad1
--- /dev/null
+++ b/module/VuFind/src/VuFind/Exception/AuthInProgress.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Authentication in Progress Exception
+ *
+ * PHP version 7
+ *
+ * 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  Exceptions
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+namespace VuFind\Exception;
+
+/**
+ * Authentication in Progress Exception
+ *
+ * @category VuFind
+ * @package  Exceptions
+ * @author   Ere Maijala <ere.maijala@helsinki.fi>
+ * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
+ * @link     https://vufind.org/wiki/development Wiki
+ */
+class AuthInProgress extends Auth
+{
+}
diff --git a/module/VuFind/src/VuFind/ILS/Connection.php b/module/VuFind/src/VuFind/ILS/Connection.php
index 702baa849a89999871462695effcf07d3064832e..f52db679dd4ba42e50dabb37bde4cd5b7436427f 100644
--- a/module/VuFind/src/VuFind/ILS/Connection.php
+++ b/module/VuFind/src/VuFind/ILS/Connection.php
@@ -668,6 +668,24 @@ class Connection implements TranslatorAwareInterface, LoggerAwareInterface
         return false;
     }
 
+    /**
+     * Check Patron login
+     *
+     * A support method for checkFunction(). This is responsible for checking
+     * the driver configuration to determine if the system supports patron login.
+     * It is currently assumed that all drivers do.
+     *
+     * @param array $functionConfig The patronLogin configuration values
+     * @param array $params         An array of function-specific params (or null)
+     *
+     * @return mixed On success, an associative array with specific function keys
+     * and values for login; on failure, false.
+     */
+    protected function checkMethodpatronLogin($functionConfig, $params)
+    {
+        return $functionConfig;
+    }
+
     /**
      * Get proper help text from the function config
      *
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Alma.php b/module/VuFind/src/VuFind/ILS/Driver/Alma.php
index 9d9aaa4b37bf10b04f484e52991bf3fb4357880a..d9bc3db47a23610b20520e9dfb913b8c26ae40d6 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Alma.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Alma.php
@@ -630,15 +630,52 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
      *
      * This is responsible for authenticating a patron against the catalog.
      *
-     * @param string $barcode  The patrons barcode.
+     * @param string $username The patrons barcode or other username.
      * @param string $password The patrons password.
      *
      * @return string[]|NULL
      */
-    public function patronLogin($barcode, $password)
+    public function patronLogin($username, $password)
     {
         $loginMethod = $this->config['Catalog']['loginMethod'] ?? 'vufind';
-        if ('password' === $loginMethod) {
+
+        $patron = [];
+        $patronId = $username;
+        if ('email' === $loginMethod) {
+            // Create parameters for API call
+            $getParams = [
+                'q' => 'email~' . $username
+            ];
+
+            // Try to find the user in Alma
+            $response = $this->makeRequest(
+                '/users/',
+                $getParams
+            );
+
+            foreach (($response->user ?? []) as $user) {
+                if ((string)$user->status !== 'ACTIVE') {
+                    continue;
+                }
+                if ($patron) {
+                    // More than one match, cannot log in by email
+                    $this->debug(
+                        "Email $username matches more than one user, cannot login"
+                    );
+                    return null;
+                }
+                $patron = [
+                    'id' => (string)$user->primary_id,
+                    'cat_username' => trim($username),
+                    'email' => trim($username)
+                ];
+            }
+            if (!$patron) {
+                return null;
+            }
+            // Use primary id in further queries
+            $patronId = $patron['id'];
+        } elseif ('password' === $loginMethod) {
             // Create parameters for API call
             $getParams = [
                 'user_id_type' => 'all_unique',
@@ -648,7 +685,7 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
 
             // Try to authenticate the user with Alma
             list($response, $status) = $this->makeRequest(
-                '/users/' . urlencode($barcode),
+                '/users/' . urlencode($username),
                 $getParams,
                 [],
                 'POST',
@@ -662,27 +699,28 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
             }
         }
 
-        if ('password' === $loginMethod || 'vufind' === $loginMethod) {
-            // Create parameters for API call
-            $getParams = [
-                'user_id_type' => 'all_unique',
-                'view' => 'brief',
-                'expand' => 'none'
-            ];
+        // Create parameters for API call
+        $getParams = [
+            'user_id_type' => 'all_unique',
+            'view' => 'full',
+            'expand' => 'none'
+        ];
 
-            // Check for patron in Alma
-            $response = $this->makeRequest(
-                '/users/' . urlencode($barcode),
-                $getParams
-            );
+        // Check for patron in Alma
+        $response = $this->makeRequest(
+            '/users/' . urlencode($patronId),
+            $getParams
+        );
 
-            if ($response !== null) {
-                return [
-                    'id' => (string)$response->primary_id,
-                    'cat_username' => trim($barcode),
-                    'cat_password' => trim($password)
-                ];
-            }
+        if ($response !== null) {
+            // We may already have some information, so just fill the gaps
+            $patron['id'] = (string)$response->primary_id;
+            $patron['cat_username'] = trim($username);
+            $patron['cat_password'] = trim($password);
+            $patron['firstname'] = (string)$response->first_name ?? '';
+            $patron['lastname'] = (string)$response->last_name ?? '';
+            $patron['email'] = $this->getPreferredEmail($response);
+            return $patron;
         }
 
         return null;
@@ -746,6 +784,7 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
                                    ? (string)$contact->phones[0]->phone->phone_number
                                    : null;
             }
+            $profile['email'] = $this->getPreferredEmail($xml);
         }
 
         // Cache the user group code
@@ -1256,6 +1295,11 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
      */
     public function getConfig($function, $params = null)
     {
+        if ($function == 'patronLogin') {
+            return [
+                'loginMethod' => $this->config['Catalog']['loginMethod'] ?? 'vufind'
+            ];
+        }
         if (isset($this->config[$function])) {
             $functionConfig = $this->config[$function];
 
@@ -1640,6 +1684,30 @@ class Alma extends AbstractBase implements \VuFindHttp\HttpServiceAwareInterface
         return $results;
     }
 
+    /**
+     * Get the preferred email address for the user (or first one if no preferred one
+     * is found)
+     *
+     * @param SimpleXMLElement $user User data
+     *
+     * @return string|null
+     */
+    protected function getPreferredEmail($user)
+    {
+        if (!empty($user->contact_info->emails->email)) {
+            foreach ($user->contact_info->emails->email as $email) {
+                if ('true' === (string)$email['preferred']) {
+                    return isset($email->email_address)
+                        ? (string)$email->email_address : null;
+                }
+            }
+            $email = $user->contact_info->emails->email[0];
+            return isset($email->email_address)
+                ? (string)$email->email_address : null;
+        }
+        return null;
+    }
+
     // @codingStandardsIgnoreStart
 
     /**
diff --git a/module/VuFind/src/VuFind/ILS/Driver/Demo.php b/module/VuFind/src/VuFind/ILS/Driver/Demo.php
index ebe56c7d48052fd23551071f0ab00a765cace68a..25502891d02b92f998337a61b3bd2d3e1393207d 100644
--- a/module/VuFind/src/VuFind/ILS/Driver/Demo.php
+++ b/module/VuFind/src/VuFind/ILS/Driver/Demo.php
@@ -567,7 +567,7 @@ class Demo extends AbstractBase
         // when testing multiple accounts.
         $selectedPatron = empty($patron)
             ? (current(array_keys($this->session)) ?: 'default')
-            : $patron;
+            : md5($patron);
 
         // SessionContainer not defined yet? Build it now:
         if (!isset($this->session[$selectedPatron])) {
@@ -773,33 +773,42 @@ class Demo extends AbstractBase
      *
      * This is responsible for authenticating a patron against the catalog.
      *
-     * @param string $barcode  The patron barcode
+     * @param string $username The patron username
      * @param string $password The patron password
      *
      * @throws ILSException
      * @return mixed           Associative array of patron info on successful login,
      * null on unsuccessful login.
      */
-    public function patronLogin($barcode, $password)
+    public function patronLogin($username, $password)
     {
         $this->checkIntermittentFailure();
+
+        $user = [
+            'id'           => trim($username),
+            'firstname'    => 'Lib',
+            'lastname'     => 'Rarian',
+            'cat_username' => trim($username),
+            'cat_password' => trim($password),
+            'email'        => 'Lib.Rarian@library.not',
+            'major'        => null,
+            'college'      => null
+        ];
+
+        $loginMethod = $this->config['Catalog']['loginMethod'] ?? 'password';
+        if ('email' === $loginMethod) {
+            $user['email'] = $username;
+            $user['cat_password'] = '';
+            return $user;
+        }
+
         if (isset($this->config['Users'])) {
-            if (!isset($this->config['Users'][$barcode])
-                || $password !== $this->config['Users'][$barcode]
+            if (!isset($this->config['Users'][$username])
+                || $password !== $this->config['Users'][$username]
             ) {
                 return null;
             }
         }
-        $user = [];
-
-        $user['id']           = trim($barcode);
-        $user['firstname']    = trim("Lib");
-        $user['lastname']     = trim("Rarian");
-        $user['cat_username'] = trim($barcode);
-        $user['cat_password'] = trim($password);
-        $user['email']        = trim("Lib.Rarian@library.not");
-        $user['major']        = null;
-        $user['college']      = null;
 
         return $user;
     }
@@ -2361,6 +2370,12 @@ class Demo extends AbstractBase
                 'default_sort' => 'due asc'
             ];
         }
+        if ($function == 'patronLogin') {
+            return [
+                'loginMethod'
+                    => $this->config['Catalog']['loginMethod'] ?? 'password'
+            ];
+        }
 
         return [];
     }
diff --git a/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php b/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php
index 2cfca678a4c3374f92bf60afe51307dabb7a801b..ec66ea344099f020db77edbbc0cd6f08431175cb 100644
--- a/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php
+++ b/module/VuFind/tests/unit-tests/src/VuFindTest/Auth/ManagerTest.php
@@ -584,6 +584,8 @@ class ManagerTest extends \VuFindTest\Unit\TestCase
         $mockDb = $this->getMockBuilder(\VuFind\Auth\Database::class)
             ->disableOriginalConstructor()
             ->getMock();
+        $mockDb->expects($this->any())->method('needsCsrfCheck')
+            ->will($this->returnValue(true));
         $mockMulti = $this->getMockBuilder(\VuFind\Auth\MultiILS::class)
             ->disableOriginalConstructor()
             ->getMock();
@@ -622,6 +624,9 @@ class ManagerTest extends \VuFindTest\Unit\TestCase
         $post = new \Zend\Stdlib\Parameters();
         $mock->expects($this->any())->method('getPost')
             ->will($this->returnValue($post));
+        $get = new \Zend\Stdlib\Parameters();
+        $mock->expects($this->any())->method('getQuery')
+            ->will($this->returnValue($get));
         return $mock;
     }
 }
diff --git a/themes/bootstrap3/js/common.js b/themes/bootstrap3/js/common.js
index 5207d304455cb4afebb9089045083444025da12b..7af5a6f659cc28ea74b2222bcc142e5fcfeba98b 100644
--- a/themes/bootstrap3/js/common.js
+++ b/themes/bootstrap3/js/common.js
@@ -1,5 +1,5 @@
 /*global grecaptcha, isPhoneNumberValid */
-/*exported VuFind, htmlEncode, deparam, moreFacets, lessFacets, getUrlRoot, phoneNumberFormHandler, recaptchaOnLoad, resetCaptcha, bulkFormHandler */
+/*exported VuFind, htmlEncode, deparam, moreFacets, lessFacets, getUrlRoot, phoneNumberFormHandler, recaptchaOnLoad, resetCaptcha, bulkFormHandler, setupMultiILSLoginFields */
 
 // IE 9< console polyfill
 window.console = window.console || { log: function polyfillLog() {} };
@@ -355,6 +355,26 @@ function setupJumpMenus(_container) {
   container.find('select.jumpMenu').change(function jumpMenu(){ $(this).parent('form').submit(); });
 }
 
+function setupMultiILSLoginFields(loginMethods, idPrefix) {
+  var searchPrefix = idPrefix ? '#' + idPrefix : '#';
+  $(searchPrefix + 'target').change(function onChangeLoginTarget() {
+    var target = $(this).val();
+    var $usernameGroup = $(searchPrefix + 'username').closest('.form-group');
+    var $password = $(searchPrefix + 'password');
+    if (loginMethods[target] === 'email') {
+      $usernameGroup.find('label.password-login').addClass('hidden');
+      $usernameGroup.find('label.email-login').removeClass('hidden');
+      $password.closest('.form-group').addClass('hidden');
+      // Set password to a dummy value so that any checks for username+password work
+      $password.val('****');
+    } else {
+      $usernameGroup.find('label.password-login').removeClass('hidden');
+      $usernameGroup.find('label.email-login').addClass('hidden');
+      $password.closest('.form-group').removeClass('hidden');
+    }
+  }).change();
+}
+
 $(document).ready(function commonDocReady() {
   // Start up all of our submodules
   VuFind.init();
diff --git a/themes/bootstrap3/templates/Auth/Email/loginfields.phtml b/themes/bootstrap3/templates/Auth/Email/loginfields.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..20b9986ebaa0c096bdc90dc8f239048423aa91a3
--- /dev/null
+++ b/themes/bootstrap3/templates/Auth/Email/loginfields.phtml
@@ -0,0 +1,4 @@
+<div class="form-group">
+  <label class="control-label" for="login_<?=$this->escapeHtmlAttr($topClass)?>_username"><?=$this->transEsc('Email')?>:</label>
+  <input type="text" name="username" id="login_<?=$this->escapeHtmlAttr($topClass)?>_username" value="<?=$this->escapeHtmlAttr($this->request->get('username'))?>" class="form-control"/>
+</div>
diff --git a/themes/bootstrap3/templates/Auth/ILS/loginfields.phtml b/themes/bootstrap3/templates/Auth/ILS/loginfields.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..e8e0fb9c6164d3fbb0b78eb40c1bed1000e5d7d4
--- /dev/null
+++ b/themes/bootstrap3/templates/Auth/ILS/loginfields.phtml
@@ -0,0 +1,16 @@
+<?php $loginMethod = $this->auth()->getManager()->getILSLoginMethod(); ?>
+<?php if ('email' === $loginMethod): ?>
+  <div class="form-group">
+    <label class="control-label" for="login_<?=$this->escapeHtmlAttr($topClass)?>_username"><?=$this->transEsc('Email')?>:</label>
+    <input type="text" name="username" id="login_<?=$this->escapeHtmlAttr($topClass)?>_username" value="<?=$this->escapeHtmlAttr($this->request->get('username'))?>" class="form-control"/>
+  </div>
+<?php else: ?>
+  <div class="form-group">
+    <label class="control-label" for="login_<?=$this->escapeHtmlAttr($topClass)?>_username"><?=$this->transEsc('Username')?>:</label>
+    <input type="text" name="username" id="login_<?=$this->escapeHtmlAttr($topClass)?>_username" value="<?=$this->escapeHtmlAttr($this->request->get('username'))?>" class="form-control"/>
+  </div>
+  <div class="form-group">
+    <label class="control-label" for="login_<?=$this->escapeHtmlAttr($topClass)?>_password"><?=$this->transEsc('Password')?>:</label>
+    <input type="password" name="password" id="login_<?=$this->escapeHtmlAttr($topClass)?>_password" class="form-control"/>
+  </div>
+<?php endif; ?>
\ No newline at end of file
diff --git a/themes/bootstrap3/templates/Auth/MultiILS/loginfields.phtml b/themes/bootstrap3/templates/Auth/MultiILS/loginfields.phtml
index e7136a43d327f4895a8fda87acd5db459f83be33..a7b253261bd7c09bedf9c2f15c40c4c0e82e0b95 100644
--- a/themes/bootstrap3/templates/Auth/MultiILS/loginfields.phtml
+++ b/themes/bootstrap3/templates/Auth/MultiILS/loginfields.phtml
@@ -1,17 +1,32 @@
+<?php $loginTargets = $this->auth()->getManager()->getLoginTargets(); ?>
 <div class="form-group">
   <label class="control-label" for="login_target"><?=$this->transEsc('login_target')?>:</label>
   <?php $currentTarget = $this->request->get('target'); if (!$currentTarget) $currentTarget = $this->auth()->getManager()->getDefaultLoginTarget();?>
-  <select id="login_target" name="target" class="form-control">
-    <?php foreach ($this->auth()->getManager()->getLoginTargets() as $target):?>
+  <select id="login_<?=$this->escapeHtmlAttr($topClass)?>_target" name="target" class="form-control">
+    <?php foreach ($loginTargets as $target):?>
       <option value="<?=$this->escapeHtmlAttr($target)?>"<?=($target == $currentTarget ? ' selected="selected"' : '')?>><?=$this->transEsc("source_$target", null, $target)?></option>
     <?php endforeach ?>
   </select>
 </div>
 <div class="form-group">
-  <label class="control-label" for="login_<?=$this->escapeHtmlAttr($topClass)?>_username"><?=$this->transEsc('Username')?>:</label>
+  <label class="control-label password-login" for="login_<?=$this->escapeHtmlAttr($topClass)?>_username"><?=$this->transEsc('Username')?>:</label>
+  <label class="control-label email-login hidden" for="login_<?=$this->escapeHtmlAttr($topClass)?>_username"><?=$this->transEsc('Email')?>:</label>
   <input id="login_<?=$this->escapeHtmlAttr($topClass)?>_username" type="text" name="username" value="<?=$this->escapeHtmlAttr($this->request->get('username'))?>" class="form-control"/>
 </div>
 <div class="form-group">
   <label class="control-label" for="login_<?=$this->escapeHtmlAttr($topClass)?>_password"><?=$this->transEsc('Password')?>:</label>
   <input id="login_<?=$this->escapeHtmlAttr($topClass)?>_password" type="password" name="password" class="form-control"/>
 </div>
+
+<?php
+  $methods = [];
+  $authManager = $this->auth()->getManager();
+  foreach ($loginTargets as $target) {
+    $methods[$target] = $authManager->getILSLoginMethod($target);
+  }
+  $methods = json_encode($methods);
+  $script = "setupMultiILSLoginFields($methods, 'login_{$topClass}_');";
+
+  // Inline the script for lightbox compatibility
+  echo $this->inlineScript(\Zend\View\Helper\HeadScript::SCRIPT, $script, 'SET');
+?>
diff --git a/themes/bootstrap3/templates/librarycards/editcard.phtml b/themes/bootstrap3/templates/librarycards/editcard.phtml
index 1e241c594c59d15b963c18971ebd60e6cd3635dc..7d8e40684fe2a84ddb7818ea7087fc6550392515 100644
--- a/themes/bootstrap3/templates/librarycards/editcard.phtml
+++ b/themes/bootstrap3/templates/librarycards/editcard.phtml
@@ -1,6 +1,6 @@
 <?php
   // Set up page title:
-  $pageTitle = empty($this->card->id) ? 'Add a Library Card' : "Edit Library Card";
+  $pageTitle = empty($this->card->id) ? 'Add a Library Card' : 'Edit Library Card';
   $this->headTitle($this->translate($pageTitle));
 
   // Set up breadcrumbs:
@@ -30,14 +30,31 @@
   </div>
   <?php endif; ?>
   <div class="form-group">
-    <label class="control-label" for="login_username"><?=$this->transEsc('Username')?>:</label>
+    <?php if (null === $this->loginMethod || 'password' === $this->loginMethod): ?>
+      <label class="control-label password-login" for="login_username"><?=$this->transEsc('Username')?>:</label>
+    <?php endif; ?>
+    <?php if (null === $this->loginMethod || 'email' === $this->loginMethod): ?>
+      <label class="control-label email-login<?php if (null === $this->loginMethod): ?> hidden<?php endif; ?>" for="login_username"><?=$this->transEsc('Email')?>:</label>
+    <?php endif; ?>
     <input id="login_username" type="text" name="username" value="<?=$this->escapeHtmlAttr($this->username)?>" class="form-control"/>
   </div>
-  <div class="form-group">
-    <label class="control-label" for="login_password"><?=$this->transEsc('Password')?>:</label>
-    <input id="login_password" type="password" name="password" value="" placeholder="<?=!empty($this->card->id) ? $this->escapeHtmlAttr($this->translate('library_card_edit_password_placeholder')) : ''?>" class="form-control"/>
-  </div>
+  <?php if (null === $this->loginMethod || 'password' === $this->loginMethod): ?>
+    <div class="form-group">
+      <label class="control-label" for="login_password"><?=$this->transEsc('Password')?>:</label>
+      <input id="login_password" type="password" name="password" value="" placeholder="<?=!empty($this->card->id) ? $this->escapeHtmlAttr($this->translate('library_card_edit_password_placeholder')) : ''?>" class="form-control"/>
+    </div>
+  <?php endif; ?>
   <div class="form-group">
     <input class="btn btn-primary" type="submit" name="submit" value="<?=$this->transEsc('Save') ?>"/>
   </div>
 </form>
+
+<?php
+  if (null !== $targets) {
+    $methods = json_encode($this->loginMethods);
+    $script = "setupMultiILSLoginFields($methods, 'login_');";
+
+    // Inline the script for lightbox compatibility
+    echo $this->inlineScript(\Zend\View\Helper\HeadScript::SCRIPT, $script, 'SET');
+  }
+?>
diff --git a/themes/bootstrap3/templates/myresearch/cataloglogin.phtml b/themes/bootstrap3/templates/myresearch/cataloglogin.phtml
index 0cb7afbe7fa2ec9d08a1853ae61ec1cd7b4ee0e5..fe0d20c5823755bbd09d98699d4c5ef39eaaa4ca 100644
--- a/themes/bootstrap3/templates/myresearch/cataloglogin.phtml
+++ b/themes/bootstrap3/templates/myresearch/cataloglogin.phtml
@@ -20,24 +20,43 @@
   <form method="post" action="<?=$this->serverUrl(true)?>" class="form-catalog-login">
     <?php if ($this->targets !== null): ?>
       <div class="form-group">
-        <label class="control-label" for="login_target"><?=$this->transEsc('login_target')?>:</label>
-        <select id="login_target" name="target" class="form-control">
+        <label class="control-label" for="profile_cat_login_target"><?=$this->transEsc('login_target')?>:</label>
+        <select id="profile_cat_target" name="target" class="form-control">
           <?php foreach ($this->targets as $target): ?>
-            <option value="<?=$this->escapeHtmlAttr($target)?>"><?=$this->transEsc("source_$target", null, $target)?></option>
+            <option value="<?=$this->escapeHtmlAttr($target)?>"<?=($target === $this->defaultTarget ? ' selected="selected"' : '')?>><?=$this->transEsc("source_$target", null, $target)?></option>
           <?php endforeach; ?>
         </select>
       </div>
     <?php endif; ?>
     <div class="form-group">
-      <label class="control-label" for="profile_cat_username"><?=$this->transEsc('Library Catalog Username')?>:</label>
+      <?php if (null === $this->loginMethod || 'password' === $this->loginMethod): ?>
+        <label class="control-label password-login" for="profile_cat_username"><?=$this->transEsc('Library Catalog Username')?>:</label>
+      <?php endif; ?>
+      <?php if (null === $this->loginMethod || 'email' === $this->loginMethod): ?>
+        <label class="control-label email-login<?php if (null === $this->loginMethod): ?> hidden<?php endif; ?>" for="profile_cat_username"><?=$this->transEsc('Email')?>:</label>
+      <?php endif; ?>
       <input id="profile_cat_username" type="text" name="cat_username" value="" class="form-control"/>
     </div>
-    <div class="form-group">
-      <label class="control-label" for="profile_cat_password"><?=$this->transEsc('Library Catalog Password')?>:</label>
-      <input id="profile_cat_password" type="password" name="cat_password" value="" class="form-control"/>
-    </div>
+    <?php if (null === $this->loginMethod || 'password' === $this->loginMethod): ?>
+      <div class="form-group">
+        <label class="control-label" for="profile_cat_password"><?=$this->transEsc('Library Catalog Password')?>:</label>
+        <input id="profile_cat_password" type="password" name="cat_password" value="" class="form-control"/>
+      </div>
+    <?php else: ?>
+      <input type="hidden" name="cat_password" value="****">
+    <?php endif; ?>
     <div class="form-group">
       <input class="btn btn-primary" type="submit" name="processLogin" value="<?=$this->transEsc('Login')?>">
     </div>
   </form>
+
+  <?php
+    if (null !== $this->targets) {
+      $methods = json_encode($this->loginMethods);
+      $script = "setupMultiILSLoginFields($methods, 'profile_cat_');";
+
+      // Inline the script for lightbox compatibility
+      echo $this->inlineScript(\Zend\View\Helper\HeadScript::SCRIPT, $script, 'SET');
+    }
+  ?>
 <?php endif; ?>
diff --git a/themes/root/templates/Email/login-link.phtml b/themes/root/templates/Email/login-link.phtml
new file mode 100644
index 0000000000000000000000000000000000000000..83cc607865166e2c6ef0e50efc1b13439c4f9f6c
--- /dev/null
+++ b/themes/root/templates/Email/login-link.phtml
@@ -0,0 +1,8 @@
+<?php // This is a text-only email template; do not include HTML! ?>
+<?=$this->translate('email_login_requested', ['%%title%%' => $this->title])?>
+
+
+<?=$this->translate('email_login_desc')?>
+
+
+<?=$this->translate('email_login_link', ['%%url%%' => $this->url])?>