Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
44.80% |
56 / 125 |
|
26.67% |
4 / 15 |
CRAP | |
0.00% |
0 / 1 |
LoginTokenManager | |
44.80% |
56 / 125 |
|
26.67% |
4 / 15 |
309.11 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
tokenLogin | |
95.83% |
23 / 24 |
|
0.00% |
0 / 1 |
7 | |||
createToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
themeIsReady | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
requestIsFinished | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
2.00 | |||
deleteTokenSeries | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
deleteUserLoginTokens | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
getCookieLifetime | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCookieName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
deleteActiveToken | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
createOrRotateToken | |
11.11% |
3 / 27 |
|
0.00% |
0 / 1 |
31.28 | |||
sendLoginTokenWarningEmail | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
setLoginTokenCookie | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getLoginTokenCookie | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
getBrowscap | |
100.00% |
3 / 3 |
|
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 | |
31 | declare(strict_types=1); |
32 | |
33 | namespace VuFind\Auth; |
34 | |
35 | use BrowscapPHP\BrowscapInterface; |
36 | use Laminas\Config\Config; |
37 | use Laminas\Log\LoggerAwareInterface; |
38 | use Laminas\Session\SessionManager; |
39 | use Laminas\View\Renderer\RendererInterface; |
40 | use VuFind\Cookie\CookieManager; |
41 | use VuFind\Db\Entity\UserEntityInterface; |
42 | use VuFind\Db\Service\LoginTokenServiceInterface; |
43 | use VuFind\Db\Service\UserServiceInterface; |
44 | use VuFind\Exception\Auth as AuthException; |
45 | use VuFind\Exception\LoginToken as LoginTokenException; |
46 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
47 | use VuFind\I18n\Translator\TranslatorAwareTrait; |
48 | use VuFind\Log\LoggerAwareTrait; |
49 | use 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 | */ |
61 | class 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 | } |