Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
44.80% covered (danger)
44.80%
56 / 125
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
LoginTokenManager
44.80% covered (danger)
44.80%
56 / 125
26.67% covered (danger)
26.67%
4 / 15
309.11
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
 tokenLogin
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
7
 createToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 themeIsReady
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 requestIsFinished
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 deleteTokenSeries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 deleteUserLoginTokens
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getCookieLifetime
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCookieName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deleteActiveToken
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 createOrRotateToken
11.11% covered (danger)
11.11%
3 / 27
0.00% covered (danger)
0.00%
0 / 1
31.28
 sendLoginTokenWarningEmail
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 setLoginTokenCookie
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getLoginTokenCookie
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 getBrowscap
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Persistent login token manager
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2023-2024.
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  VuFind\Auth
25 * @author   Jaro Ravila <jaro.ravila@helsinki.fi>
26 * @author   Ere Maijala <ere.maijala@helsinki.fi>
27 * @license  https://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org Main Page
29 */
30
31declare(strict_types=1);
32
33namespace VuFind\Auth;
34
35use BrowscapPHP\BrowscapInterface;
36use Laminas\Config\Config;
37use Laminas\Log\LoggerAwareInterface;
38use Laminas\Session\SessionManager;
39use Laminas\View\Renderer\RendererInterface;
40use VuFind\Cookie\CookieManager;
41use VuFind\Db\Entity\UserEntityInterface;
42use VuFind\Db\Service\LoginTokenServiceInterface;
43use VuFind\Db\Service\UserServiceInterface;
44use VuFind\Exception\Auth as AuthException;
45use VuFind\Exception\LoginToken as LoginTokenException;
46use VuFind\I18n\Translator\TranslatorAwareInterface;
47use VuFind\I18n\Translator\TranslatorAwareTrait;
48use VuFind\Log\LoggerAwareTrait;
49use VuFind\Mailer\Mailer;
50
51/**
52 * Class LoginTokenManager
53 *
54 * @category VuFind
55 * @package  VuFind\Auth
56 * @author   Jaro Ravila <jaro.ravila@helsinki.fi>
57 * @author   Ere Maijala <ere.maijala@helsinki.fi>
58 * @license  https://opensource.org/licenses/gpl-2.0.php GNU General Public License
59 * @link     https://vufind.org Main Page
60 */
61class LoginTokenManager implements LoggerAwareInterface, TranslatorAwareInterface
62{
63    use LoggerAwareTrait;
64    use TranslatorAwareTrait;
65
66    /**
67     * Callback for creating Browscap so that we can defer the cache access to when
68     * it's actually needed.
69     *
70     * @var callable
71     */
72    protected $browscapCallback;
73
74    /**
75     * Browscap
76     *
77     * @var BrowscapInterface
78     */
79    protected $browscap = null;
80
81    /**
82     * Has the theme been initialized yet?
83     *
84     * @var bool
85     */
86    protected $themeInitialized = false;
87
88    /**
89     * User that needs to receive a warning (or null for no warning needed)
90     *
91     * @var ?UserEntityInterface
92     */
93    protected $userToWarn = null;
94
95    /**
96     * Token data for deferred token update
97     *
98     * @var ?array
99     */
100    protected $tokenToUpdate = null;
101
102    /**
103     * LoginToken constructor.
104     *
105     * @param Config                     $config            Configuration
106     * @param UserServiceInterface       $userService       User database service
107     * @param LoginTokenServiceInterface $loginTokenService Login Token database service
108     * @param CookieManager              $cookieManager     Cookie manager
109     * @param SessionManager             $sessionManager    Session manager
110     * @param Mailer                     $mailer            Mailer
111     * @param RendererInterface          $viewRenderer      View Renderer
112     * @param callable                   $browscapCB        Callback for creating Browscap
113     */
114    public function __construct(
115        protected Config $config,
116        protected UserServiceInterface $userService,
117        protected LoginTokenServiceInterface $loginTokenService,
118        protected CookieManager $cookieManager,
119        protected SessionManager $sessionManager,
120        protected Mailer $mailer,
121        protected RendererInterface $viewRenderer,
122        callable $browscapCB
123    ) {
124        $this->browscapCallback = $browscapCB;
125    }
126
127    /**
128     * Authenticate user using a login token cookie
129     *
130     * @param string $sessionId Session identifier
131     *
132     * @return ?UserEntityInterface Object representing logged-in user.
133     */
134    public function tokenLogin(string $sessionId): ?UserEntityInterface
135    {
136        $user = null;
137        $cookie = $this->getLoginTokenCookie();
138        if ($cookie) {
139            try {
140                if (
141                    ($token = $this->loginTokenService->matchToken($cookie))
142                    && ($user = $token->getUser())
143                ) {
144                    // Queue token update to be done after everything else is
145                    // successfully processed:
146                    $this->tokenToUpdate = compact('user', 'token', 'sessionId');
147                    $this->debug(
148                        "Token login successful for user {$user->getId()}"
149                        . ", token {$token->getToken()} series {$token->getSeries()}"
150                    );
151                } else {
152                    $this->cookieManager->clear($this->getCookieName());
153                }
154            } catch (LoginTokenException $e) {
155                $this->logError(
156                    'Token login failure for user ' . $e->getUserId()
157                    . ", token {$cookie['token']} series {$cookie['series']}" . (string)$e
158                );
159                // Delete all login tokens for the user and all sessions
160                // associated with the tokens and send a warning email to user
161                $user = $this->userService->getUserById($e->getUserId());
162                if ($user) {
163                    $this->deleteUserLoginTokens($user->getId());
164                }
165                // We can't send an email until after the theme has initialized;
166                // if it's not ready yet, save the user for later.
167                if ($this->themeInitialized) {
168                    $this->sendLoginTokenWarningEmail($user);
169                } else {
170                    $this->userToWarn = $user;
171                }
172                return null;
173            }
174        }
175        return $user;
176    }
177
178    /**
179     * Create a new login token series
180     *
181     * @param UserEntityInterface $user      User
182     * @param string              $sessionId Session identifier
183     *
184     * @throws AuthException
185     * @return void
186     */
187    public function createToken(UserEntityInterface $user, string $sessionId = ''): void
188    {
189        $this->createOrRotateToken($user, $sessionId);
190    }
191
192    /**
193     * Event hook -- called after the theme has initialized.
194     *
195     * @return void
196     */
197    public function themeIsReady(): void
198    {
199        $this->themeInitialized = true;
200        // If we have queued a user warning, we can send it now!
201        if ($this->userToWarn) {
202            $this->sendLoginTokenWarningEmail($this->userToWarn);
203            $this->userToWarn = null;
204        }
205    }
206
207    /**
208     * Event hook -- called after the request has been processed.
209     *
210     * @return void
211     */
212    public function requestIsFinished(): void
213    {
214        // If we have queued a login token update, we can process it now!
215        if ($this->tokenToUpdate) {
216            $token = $this->tokenToUpdate['token'];
217            $this->createOrRotateToken(
218                $this->tokenToUpdate['user'],
219                $this->tokenToUpdate['sessionId'],
220                $token->getSeries(),
221                $token->getExpires(),
222                $token->getId()
223            );
224            $this->tokenToUpdate = null;
225        }
226    }
227
228    /**
229     * Delete a login token by series. Also destroys
230     * sessions associated with the login token.
231     *
232     * @param string $series Series to identify the token
233     *
234     * @return void
235     */
236    public function deleteTokenSeries(string $series)
237    {
238        $cookie = $this->getLoginTokenCookie();
239        if (!empty($cookie) && $cookie['series'] === $series) {
240            $this->cookieManager->clear($this->getCookieName());
241        }
242        $handler = $this->sessionManager->getSaveHandler();
243        foreach ($this->loginTokenService->getBySeries($series) as $token) {
244            $handler->destroy($token->getLastSessionId());
245        }
246        $this->loginTokenService->deleteBySeries($series);
247    }
248
249    /**
250     * Delete all login tokens for a user. Also destroys
251     * sessions associated with the tokens.
252     *
253     * @param int $userId User identifier
254     *
255     * @return void
256     */
257    public function deleteUserLoginTokens($userId)
258    {
259        $userTokens = $this->loginTokenService->getByUser($userId, false);
260        $handler = $this->sessionManager->getSaveHandler();
261        foreach ($userTokens as $t) {
262            $handler->destroy($t->getLastSessionId());
263        }
264        $this->loginTokenService->deleteByUser($userId);
265    }
266
267    /**
268     * Get login token cookie lifetime (days)
269     *
270     * @return int
271     */
272    public function getCookieLifetime(): int
273    {
274        return (int)($this->config->Authentication->persistent_login_lifetime ?? 14);
275    }
276
277    /**
278     * Get login token cookie name
279     *
280     * @return string
281     */
282    public function getCookieName(): string
283    {
284        return 'loginToken';
285    }
286
287    /**
288     * Delete a login token from cookies and database
289     *
290     * @return void
291     */
292    public function deleteActiveToken()
293    {
294        $cookie = $this->getLoginTokenCookie();
295        if (!empty($cookie) && $cookie['series']) {
296            $this->loginTokenService->deleteBySeries($cookie['series']);
297        }
298        $this->cookieManager->clear($this->getCookieName());
299    }
300
301    /**
302     * Create a new login token series or rotate login token in given series
303     *
304     * @param UserEntityInterface $user           User
305     * @param string              $sessionId      Session identifier
306     * @param string              $series         Login token series
307     * @param ?int                $expires        Token expiration timestamp or null for default
308     * @param ?int                $currentTokenId ID of current token to keep intact
309     *
310     * @throws AuthException
311     * @return void
312     */
313    protected function createOrRotateToken(
314        UserEntityInterface $user,
315        string $sessionId = '',
316        string $series = '',
317        ?int $expires = null,
318        ?int $currentTokenId = null
319    ): void {
320        try {
321            $browser = $this->getBrowscap()->getBrowser();
322        } catch (\Exception $e) {
323            throw new AuthException('Problem with browscap: ' . (string)$e);
324        }
325        if (null === $expires) {
326            $lifetime = $this->getCookieLifetime();
327            $expires = time() + $lifetime * 60 * 60 * 24;
328        }
329        $token = bin2hex(random_bytes(32));
330        $userId = $user->getId();
331        try {
332            if ($series) {
333                $lenient = ($this->config->Authentication->lenient_token_rotation ?? true);
334                $this->loginTokenService->deleteBySeries($series, $lenient ? $currentTokenId : null);
335                $this->debug("Updating login token $token series $series for user {$userId}");
336            } else {
337                $series = bin2hex(random_bytes(32));
338                $this->debug("Creating login token $token series $series for user {$userId}");
339            }
340            $this->loginTokenService->createAndPersistToken(
341                $user,
342                $token,
343                $series,
344                $browser->browser,
345                $browser->platform,
346                $expires,
347                $sessionId
348            );
349            $this->setLoginTokenCookie($token, $series, $expires);
350        } catch (\Exception $e) {
351            $this->logError("Failed to save login token $token series $series for user {$userId}" . (string)$e);
352            throw new AuthException('Failed to save token');
353        }
354    }
355
356    /**
357     * Send email warning to user
358     *
359     * @param UserEntityInterface $user User
360     *
361     * @return void
362     */
363    protected function sendLoginTokenWarningEmail(UserEntityInterface $user)
364    {
365        if (!($this->config->Authentication->send_login_warnings ?? true)) {
366            return;
367        }
368        $title = $this->config->Site->title ?? '';
369        if ($toAddr = $user->getEmail()) {
370            $message = $this->viewRenderer->render(
371                'Email/login-warning.phtml',
372                compact('title')
373            );
374            $subject = $this->config->Authentication->persistent_login_warning_email_subject
375                ?? 'persistent_login_warning_email_subject';
376
377            try {
378                $this->mailer->send(
379                    $toAddr,
380                    $this->config->Mail->default_from ?? $this->config->Site->email,
381                    $this->translate($subject, ['%%title%%' => $title]),
382                    $message
383                );
384            } catch (\Exception $e) {
385                $this->logError('Failed to send login token warning email: ' . (string)$e);
386            }
387        }
388    }
389
390    /**
391     * Set login token cookie
392     *
393     * @param string $token   Login token
394     * @param string $series  Series the token belongs to
395     * @param int    $expires Token expiration timestamp
396     *
397     * @return void
398     */
399    protected function setLoginTokenCookie(string $token, string $series, int $expires): void
400    {
401        $token = implode(';', [$series, $token]);
402        $this->cookieManager->set(
403            $this->getCookieName(),
404            $token,
405            $expires,
406            true
407        );
408    }
409
410    /**
411     * Get login token cookie in array format
412     *
413     * @return array
414     */
415    protected function getLoginTokenCookie(): array
416    {
417        if ($cookie = $this->cookieManager->get($this->getCookieName())) {
418            $parts = explode(';', $cookie);
419            // Account for tokens that have extra content in the middle:
420            if ($part2 = $parts[2] ?? null) {
421                return [
422                    'series' => $parts[0],
423                    'token' => $part2,
424                ];
425            }
426            return [
427                'series' => $parts[0],
428                'token' => $parts[1] ?? '',
429            ];
430        }
431        return [];
432    }
433
434    /**
435     * Get Browscap
436     *
437     * @return BrowscapInterface
438     */
439    protected function getBrowscap(): BrowscapInterface
440    {
441        if (null === $this->browscap) {
442            $this->browscap = ($this->browscapCallback)();
443        }
444        return $this->browscap;
445    }
446}