Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.23% covered (success)
96.23%
51 / 53
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailAuthenticator
96.23% covered (success)
96.23%
51 / 53
50.00% covered (danger)
50.00%
2 / 4
12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sendAuthenticationLink
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
4
 authenticate
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 isValidLoginRequest
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Class for managing email-based authentication.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2019.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Authentication
25 * @author   Ere Maijala <ere.maijala@helsinki.fi>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
28 */
29
30namespace VuFind\Auth;
31
32use Laminas\Http\PhpEnvironment\RemoteAddress;
33use Laminas\Http\Request;
34use Laminas\View\Renderer\PhpRenderer;
35use VuFind\Db\Service\AuthHashServiceInterface;
36use VuFind\Exception\Auth as AuthException;
37use VuFind\Validator\CsrfInterface;
38
39/**
40 * Class for managing email-based authentication.
41 *
42 * This class provides functionality for authentication based on a known-valid email
43 * address.
44 *
45 * @category VuFind
46 * @package  Authentication
47 * @author   Ere Maijala <ere.maijala@helsinki.fi>
48 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
49 * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
50 */
51class EmailAuthenticator implements \VuFind\I18n\Translator\TranslatorAwareInterface
52{
53    use \VuFind\I18n\Translator\TranslatorAwareTrait;
54
55    /**
56     * How long a login request is considered to be valid (seconds)
57     *
58     * @var int
59     */
60    protected $loginRequestValidTime = 600;
61
62    /**
63     * Constructor
64     *
65     * @param \Laminas\Session\SessionManager $sessionManager  Session Manager
66     * @param CsrfInterface                   $csrf            CSRF Validator
67     * @param \VuFind\Mailer\Mailer           $mailer          Mailer
68     * @param PhpRenderer                     $viewRenderer    View Renderer
69     * @param RemoteAddress                   $remoteAddress   Remote address
70     * @param \Laminas\Config\Config          $config          Configuration
71     * @param AuthHashServiceInterface        $authHashService AuthHash database service
72     */
73    public function __construct(
74        protected \Laminas\Session\SessionManager $sessionManager,
75        protected CsrfInterface $csrf,
76        protected \VuFind\Mailer\Mailer $mailer,
77        protected PhpRenderer $viewRenderer,
78        protected RemoteAddress $remoteAddress,
79        protected \Laminas\Config\Config $config,
80        protected AuthHashServiceInterface $authHashService
81    ) {
82    }
83
84    /**
85     * Send an email authentication link to the specified email address.
86     *
87     * Stores the required information in the session.
88     *
89     * @param string $email       Email address to send the link to
90     * @param array  $data        Information from the authentication request (such as user details)
91     * @param array  $urlParams   Default parameters for the generated URL
92     * @param string $linkRoute   The route to use as the base url for the login link
93     * @param array  $routeParams Route parameters
94     * @param string $subject     Email subject
95     * @param string $template    Email message template
96     *
97     * @return void
98     */
99    public function sendAuthenticationLink(
100        $email,
101        $data,
102        $urlParams,
103        $linkRoute = 'myresearch-home',
104        $routeParams = [],
105        $subject = 'email_login_subject',
106        $template = 'Email/login-link.phtml'
107    ) {
108        // Make sure we've waited long enough
109        $recoveryInterval = $this->config->Authentication->recover_interval ?? 60;
110        $sessionId = $this->sessionManager->getId();
111
112        if (
113            ($row = $this->authHashService->getLatestBySessionId($sessionId))
114            && time() - $row->getCreated()->getTimestamp() < $recoveryInterval
115        ) {
116            throw new AuthException('authentication_error_in_progress');
117        }
118
119        $this->csrf->trimTokenList(5);
120        $linkData = [
121            'timestamp' => time(),
122            'data' => $data,
123            'email' => $email,
124            'ip' => $this->remoteAddress->getIpAddress(),
125        ];
126        $hash = $this->csrf->getHash(true);
127
128        $row = $this->authHashService->getByHashAndType($hash, AuthHashServiceInterface::TYPE_EMAIL);
129
130        $row->setSessionId($sessionId)
131            ->setData(json_encode($linkData));
132        $this->authHashService->persistEntity($row);
133
134        $serverHelper = $this->viewRenderer->plugin('serverurl');
135        $urlHelper = $this->viewRenderer->plugin('url');
136        $urlParams['hash'] = $hash;
137        $viewParams = $linkData;
138        $viewParams['url'] = $serverHelper(
139            $urlHelper($linkRoute, $routeParams, ['query' => $urlParams])
140        );
141        $viewParams['title'] = $this->config->Site->title;
142
143        $message = $this->viewRenderer->render($template, $viewParams);
144        $from = !empty($this->config->Mail->user_email_in_from)
145            ? $email
146            : ($this->config->Mail->default_from ?? $this->config->Site->email);
147        $subject = $this->translator->translate($subject);
148        $subject = str_replace('%%title%%', $viewParams['title'], $subject);
149
150        $this->mailer->send($email, $from, $subject, $message);
151    }
152
153    /**
154     * Authenticate using a hash
155     *
156     * @param string $hash Hash
157     *
158     * @return array
159     * @throws AuthException
160     */
161    public function authenticate($hash)
162    {
163        $row = $this->authHashService->getByHashAndType($hash, AuthHashServiceInterface::TYPE_EMAIL, false);
164        if (!$row) {
165            // Assume the hash has already been used or has expired
166            throw new AuthException('authentication_error_expired');
167        }
168        $linkData = json_decode($row->getData(), true);
169
170        // Require same session id or IP address:
171        $sessionId = $this->sessionManager->getId();
172        if (
173            $row->getSessionId() !== $sessionId
174            && $linkData['ip'] !== $this->remoteAddress->getIpAddress()
175        ) {
176            throw new AuthException('authentication_error_session_ip_mismatch');
177        }
178
179        // Only delete the token now that we know the requester is correct. Otherwise
180        // it may end up deleted due to e.g. safe link check by the email server.
181        $this->authHashService->deleteAuthHash($row);
182
183        if (time() - $row->getCreated()->getTimestamp() > $this->loginRequestValidTime) {
184            throw new AuthException('authentication_error_expired');
185        }
186
187        return $linkData['data'];
188    }
189
190    /**
191     * Check if the given request is a valid login request
192     *
193     * @param Request $request Request object.
194     *
195     * @return bool
196     */
197    public function isValidLoginRequest(Request $request)
198    {
199        $hash = $request->getPost()->get(
200            'hash',
201            $request->getQuery()->get('hash', '')
202        );
203        if ($hash) {
204            $row = $this->authHashService->getByHashAndType($hash, AuthHashServiceInterface::TYPE_EMAIL, false);
205            return !empty($row);
206        }
207        return false;
208    }
209}