Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 217 |
|
0.00% |
0 / 9 |
CRAP | |
0.00% |
0 / 1 |
AlmaController | |
0.00% |
0 / 217 |
|
0.00% |
0 / 9 |
2162 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
webhookAction | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
240 | |||
webhookUser | |
0.00% |
0 / 82 |
|
0.00% |
0 / 1 |
380 | |||
webhookChallenge | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
12 | |||
sendSetPasswordEmail | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
6 | |||
createJsonResponse | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
webhookNotImplemented | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
checkPermission | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
checkMessageSignature | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | /** |
4 | * Alma controller |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) AK Bibliothek Wien für Sozialwissenschaften 2018. |
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 Controller |
25 | * @author Michael Birkner <michael.birkner@akwien.at> |
26 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
27 | * @link https://vufind.org/wiki/development:plugins:controllers Wiki |
28 | */ |
29 | |
30 | namespace VuFind\Controller; |
31 | |
32 | use Laminas\ServiceManager\ServiceLocatorInterface; |
33 | use Laminas\Stdlib\RequestInterface; |
34 | use Throwable; |
35 | use VuFind\Account\UserAccountService; |
36 | use VuFind\Db\Entity\UserEntityInterface; |
37 | use VuFind\Db\Service\UserServiceInterface; |
38 | |
39 | /** |
40 | * Alma controller, mainly for webhooks. |
41 | * |
42 | * @category VuFind |
43 | * @package Controller |
44 | * @author Michael Birkner <michael.birkner@akwien.at> |
45 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
46 | * @link https://vufind.org/wiki/development:plugins:controllers Wiki |
47 | */ |
48 | class AlmaController extends AbstractBase |
49 | { |
50 | /** |
51 | * Http service |
52 | * |
53 | * @var \VuFindHttp\HttpService |
54 | */ |
55 | protected $httpService; |
56 | |
57 | /** |
58 | * Http response |
59 | * |
60 | * @var \Laminas\Http\PhpEnvironment\Response |
61 | */ |
62 | protected $httpResponse; |
63 | |
64 | /** |
65 | * Http headers |
66 | * |
67 | * @var \Laminas\Http\Headers |
68 | */ |
69 | protected $httpHeaders; |
70 | |
71 | /** |
72 | * Configuration from config.ini |
73 | * |
74 | * @var \Laminas\Config\Config |
75 | */ |
76 | protected $config; |
77 | |
78 | /** |
79 | * Alma.ini config |
80 | * |
81 | * @var \Laminas\Config\Config |
82 | */ |
83 | protected $configAlma; |
84 | |
85 | /** |
86 | * User database service |
87 | * |
88 | * @var UserServiceInterface |
89 | */ |
90 | protected $userService; |
91 | |
92 | /** |
93 | * Alma Controller constructor. |
94 | * |
95 | * @param ServiceLocatorInterface $sm The ServiceLocatorInterface |
96 | */ |
97 | public function __construct(ServiceLocatorInterface $sm) |
98 | { |
99 | parent::__construct($sm); |
100 | $this->httpResponse = $this->getResponse(); |
101 | $this->httpHeaders = $this->httpResponse->getHeaders(); |
102 | $this->config = $this->getConfig('config'); |
103 | $this->configAlma = $this->getConfig('Alma'); |
104 | $this->userService = $this->getDbService(UserServiceInterface::class); |
105 | } |
106 | |
107 | /** |
108 | * Action that is executed when the webhook page is called. |
109 | * |
110 | * @return \Laminas\Http\Response|NULL |
111 | */ |
112 | public function webhookAction() |
113 | { |
114 | // Request from external |
115 | $request = $this->getRequest(); |
116 | |
117 | // Get request method (GET, POST, ...) |
118 | $requestMethod = $request->getMethod(); |
119 | |
120 | // Get request body if method is POST and is not empty |
121 | $requestBodyJson = null; |
122 | if ( |
123 | $request->getContent() != null |
124 | && !empty($request->getContent()) |
125 | && $requestMethod == 'POST' |
126 | ) { |
127 | try { |
128 | $this->checkMessageSignature($request); |
129 | } catch (\VuFind\Exception\Forbidden $ex) { |
130 | return $this->createJsonResponse( |
131 | 'Access to Alma Webhook is forbidden. ' . |
132 | 'The message signature is not correct.', |
133 | 403 |
134 | ); |
135 | } |
136 | $requestBodyJson = json_decode($request->getContent()); |
137 | } |
138 | |
139 | // Get webhook action |
140 | $webhookAction = $requestBodyJson->action ?? null; |
141 | |
142 | // Perform webhook action |
143 | switch ($webhookAction) { |
144 | case 'USER': |
145 | $accessPermission = 'access.alma.webhook.user'; |
146 | try { |
147 | $this->checkPermission($accessPermission); |
148 | } catch (\VuFind\Exception\Forbidden $ex) { |
149 | return $this->createJsonResponse( |
150 | 'Access to Alma Webhook \'' . $webhookAction . |
151 | '\' forbidden. Set permission \'' . $accessPermission . |
152 | '\' in \'permissions.ini\'.', |
153 | 403 |
154 | ); |
155 | } |
156 | |
157 | return $this->webhookUser($requestBodyJson); |
158 | break; |
159 | case 'JOB_END': |
160 | case 'NOTIFICATION': |
161 | case 'LOAN': |
162 | case 'REQUEST': |
163 | case 'BIB': |
164 | case 'ITEM': |
165 | return $this->webhookNotImplemented($webhookAction); |
166 | break; |
167 | default: |
168 | $accessPermission = 'access.alma.webhook.challenge'; |
169 | try { |
170 | $this->checkPermission($accessPermission); |
171 | } catch (\VuFind\Exception\Forbidden $ex) { |
172 | return $this->createJsonResponse( |
173 | 'Access to Alma Webhook challenge forbidden. Set ' . |
174 | 'permission \'' . $accessPermission . |
175 | '\' in \'permissions.ini\'.', |
176 | 403 |
177 | ); |
178 | } |
179 | return $this->webhookChallenge(); |
180 | break; |
181 | } |
182 | } |
183 | |
184 | /** |
185 | * Webhook actions related to a newly created, updated or deleted user in Alma. |
186 | * |
187 | * @param mixed $requestBodyJson A JSON string decode with json_decode() |
188 | * |
189 | * @return NULL|\Laminas\Http\Response |
190 | */ |
191 | protected function webhookUser($requestBodyJson) |
192 | { |
193 | // Initialize user variable that should hold the user table row |
194 | $user = null; |
195 | |
196 | // Initialize response variable |
197 | $jsonResponse = null; |
198 | |
199 | // Get method from webhook (e. g. "create" for "new user") |
200 | $method = $requestBodyJson->webhook_user->method ?? null; |
201 | |
202 | // Get primary ID |
203 | $primaryId = $requestBodyJson->webhook_user->user->primary_id ?? null; |
204 | |
205 | if ($method == 'CREATE' || $method == 'UPDATE') { |
206 | // Get username (could e. g. be the barcode) |
207 | $username = null; |
208 | $userIdentifiers |
209 | = $requestBodyJson->webhook_user->user->user_identifier ?? null; |
210 | $idTypeConfig = $this->configAlma->NewUser->idType ?? null; |
211 | foreach ($userIdentifiers as $userIdentifier) { |
212 | $idTypeHook = $userIdentifier->id_type->value ?? null; |
213 | if ( |
214 | $idTypeHook != null |
215 | && $idTypeHook == $idTypeConfig |
216 | && $username == null |
217 | ) { |
218 | $username = $userIdentifier->value ?? null; |
219 | } |
220 | } |
221 | |
222 | // Use primary ID as username as a fallback if no other |
223 | // username ID is available |
224 | $username = ($username == null) ? $primaryId : $username; |
225 | |
226 | // Get user details from Alma Webhook message |
227 | $firstname = $requestBodyJson->webhook_user->user->first_name ?? null; |
228 | $lastname = $requestBodyJson->webhook_user->user->last_name ?? null; |
229 | |
230 | $allEmails |
231 | = $requestBodyJson->webhook_user->user->contact_info->email ?? null; |
232 | $email = null; |
233 | foreach ($allEmails as $currentEmail) { |
234 | $preferred = $currentEmail->preferred ?? false; |
235 | if ($preferred && $email == null) { |
236 | $email = $currentEmail->email_address ?? null; |
237 | } |
238 | } |
239 | |
240 | if ($method == 'CREATE') { |
241 | $user = $this->userService->getUserByUsername($username) |
242 | ?? $this->userService->createEntityForUsername($username); |
243 | } elseif ($method == 'UPDATE') { |
244 | $user = $this->userService->getUserByCatId($primaryId); |
245 | } |
246 | |
247 | if ($user) { |
248 | $user->setUsername($username) |
249 | ->setFirstname($firstname) |
250 | ->setLastname($lastname) |
251 | ->setCatId($primaryId) |
252 | ->setCatUsername($username); |
253 | $this->userService->updateUserEmail($user, $email); |
254 | |
255 | try { |
256 | $this->userService->persistEntity($user); |
257 | if ($method == 'CREATE') { |
258 | $this->sendSetPasswordEmail($user, $this->config); |
259 | } |
260 | $jsonResponse = $this->createJsonResponse( |
261 | 'Successfully ' . strtolower($method) . |
262 | 'd user with primary ID \'' . $primaryId . |
263 | '\' | username \'' . $username . '\'.', |
264 | 200 |
265 | ); |
266 | } catch (\Exception $ex) { |
267 | $jsonResponse = $this->createJsonResponse( |
268 | 'Error when saving new user with primary ID \'' . |
269 | $primaryId . '\' | username \'' . $username . |
270 | '\' to VuFind database and sending the welcome email: ' . |
271 | $ex->getMessage() . '. ', |
272 | 400 |
273 | ); |
274 | } |
275 | } else { |
276 | $jsonResponse = $this->createJsonResponse( |
277 | 'User with primary ID \'' . $primaryId . '\' | username \'' . |
278 | $username . '\' was not found in VuFind database and ' . |
279 | 'therefore could not be ' . strtolower($method) . 'd.', |
280 | 404 |
281 | ); |
282 | } |
283 | } elseif ($method == 'DELETE') { |
284 | $user = $this->userService->getUserByCatId($primaryId); |
285 | if ($user) { |
286 | try { |
287 | $this->serviceLocator->get(UserAccountService::class)->purgeUserData($user); |
288 | $jsonResponse = $this->createJsonResponse( |
289 | 'Successfully deleted user with primary ID \'' . $primaryId . |
290 | '\' in VuFind.', |
291 | 200 |
292 | ); |
293 | } catch (Throwable) { |
294 | $jsonResponse = $this->createJsonResponse( |
295 | 'Problem when deleting user with \'' . $primaryId . |
296 | '\' in VuFind. Please check the status ' . |
297 | 'of the user in the VuFind database.', |
298 | 400 |
299 | ); |
300 | } |
301 | } else { |
302 | $jsonResponse = $this->createJsonResponse( |
303 | 'User with primary ID \'' . $primaryId . '\' was not found in ' . |
304 | 'VuFind database and therefore could not be deleted.', |
305 | 404 |
306 | ); |
307 | } |
308 | } |
309 | |
310 | return $jsonResponse; |
311 | } |
312 | |
313 | /** |
314 | * The webhook challenge. This is used to activate the webhook in Alma. Without |
315 | * activating it, Alma will not send its webhook messages to VuFind. |
316 | * |
317 | * @return \Laminas\Http\Response |
318 | */ |
319 | protected function webhookChallenge() |
320 | { |
321 | // Get challenge string from the get parameter that Alma sends us. We need to |
322 | // return this string in the return message. |
323 | $secret = $this->params()->fromQuery('challenge'); |
324 | |
325 | // Create the return array |
326 | $returnArray = []; |
327 | |
328 | if (isset($secret) && !empty(trim($secret))) { |
329 | $returnArray['challenge'] = $secret; |
330 | $this->httpResponse->setStatusCode(200); |
331 | } else { |
332 | $returnArray['error'] = 'GET parameter \'challenge\' is empty, not ' . |
333 | 'set or not available when receiving webhook challenge from Alma.'; |
334 | $this->httpResponse->setStatusCode(500); |
335 | } |
336 | |
337 | // Remove null from array |
338 | $returnArray = array_filter($returnArray); |
339 | |
340 | // Create return JSON value and set it to the response |
341 | $returnJson = json_encode($returnArray, JSON_PRETTY_PRINT); |
342 | $this->httpHeaders->addHeaderLine('Content-type', 'application/json'); |
343 | $this->httpResponse->setContent($returnJson); |
344 | |
345 | return $this->httpResponse; |
346 | } |
347 | |
348 | /** |
349 | * Send the "set password email" to a new user that was created in Alma and sent |
350 | * to VuFind via webhook. |
351 | * |
352 | * @param UserEntityInterface $user User entity object |
353 | * @param \Laminas\Config\Config $config A config object of config.ini |
354 | * |
355 | * @return void |
356 | */ |
357 | protected function sendSetPasswordEmail(UserEntityInterface $user, $config) |
358 | { |
359 | // Attempt to send the email |
360 | try { |
361 | // Create a fresh hash |
362 | $this->getAuthManager()->updateUserVerifyHash($user); |
363 | $config = $this->getConfig(); |
364 | $renderer = $this->getViewRenderer(); |
365 | $method = $this->getAuthManager()->getAuthMethod(); |
366 | |
367 | // Custom template for emails (text-only) |
368 | $message = $renderer->render( |
369 | 'Email/new-user-welcome.phtml', |
370 | [ |
371 | 'library' => $config->Site->title, |
372 | 'firstname' => $user->getFirstname(), |
373 | 'lastname' => $user->getLastname(), |
374 | 'username' => $user->getUsername(), |
375 | 'url' => $this->getServerUrl('myresearch-verify') . '?hash=' |
376 | . $user->getVerifyHash() . '&auth_method=' . $method, |
377 | ] |
378 | ); |
379 | // Send the email |
380 | $this->serviceLocator->get(\VuFind\Mailer\Mailer::class)->send( |
381 | $user->getEmail(), |
382 | $config->Site->email, |
383 | $this->translate( |
384 | 'new_user_welcome_subject', |
385 | ['%%library%%' => $config->Site->title] |
386 | ), |
387 | $message |
388 | ); |
389 | } catch (\VuFind\Exception\Mail $e) { |
390 | error_log( |
391 | 'Could not send the \'set-password-email\' to user with ' . |
392 | 'primary ID \'' . $user->getCatId() . '\' | username \'' . |
393 | $user->getUsername() . '\': ' . $e->getMessage() |
394 | ); |
395 | } |
396 | } |
397 | |
398 | /** |
399 | * Create a HTTP response with JSON content and HTTP status codes that Alma takes |
400 | * as "answer" to its webhook calls. |
401 | * |
402 | * @param string $text The text that should be sent back to Alma |
403 | * @param int $httpStatusCode The HTTP status code that should be sent back |
404 | * to Alma |
405 | * |
406 | * @return \Laminas\Http\Response |
407 | */ |
408 | protected function createJsonResponse($text, $httpStatusCode) |
409 | { |
410 | $returnArray = []; |
411 | $returnArray[] = $text; |
412 | $returnJson = json_encode($returnArray, JSON_PRETTY_PRINT); |
413 | $this->httpHeaders->addHeaderLine('Content-type', 'application/json'); |
414 | $this->httpResponse->setStatusCode($httpStatusCode); |
415 | $this->httpResponse->setContent($returnJson); |
416 | return $this->httpResponse; |
417 | } |
418 | |
419 | /** |
420 | * A default message to be sent back to Alma if an action for a certain webhook |
421 | * type is not implemented (yet). |
422 | * |
423 | * @param string $webhookType The type of the webhook |
424 | * |
425 | * @return \Laminas\Http\Response |
426 | */ |
427 | protected function webhookNotImplemented($webhookType) |
428 | { |
429 | return $this->createJsonResponse( |
430 | $webhookType . ' Alma Webhook is not (yet) implemented in VuFind.', |
431 | 400 |
432 | ); |
433 | } |
434 | |
435 | /** |
436 | * Helper function to check access permissions defined in permissions.ini. |
437 | * The function validateAccessPermission() will throw an exception that can be |
438 | * caught when the permission is denied. |
439 | * |
440 | * @param string $accessPermission The permission name from permissions.ini that |
441 | * should be checked. |
442 | * |
443 | * @return void |
444 | */ |
445 | protected function checkPermission($accessPermission) |
446 | { |
447 | $this->accessPermission = $accessPermission; |
448 | $this->accessDeniedBehavior = 'exception'; |
449 | $this->validateAccessPermission($this->getEvent()); |
450 | } |
451 | |
452 | /** |
453 | * Signing and hashing the body content of the Alma POST request with the |
454 | * webhook secret in Alma.ini. The calculated hash value must be the same as |
455 | * the 'X-Exl-Signature' in the request header. This is a security measure to |
456 | * be sure that the request comes from Alma. |
457 | * |
458 | * @param RequestInterface $request The request from Alma. |
459 | * |
460 | * @throws \VuFind\Exception\Forbidden Throws forbidden exception if hash values |
461 | * are not the same. |
462 | * |
463 | * @return void |
464 | */ |
465 | protected function checkMessageSignature(RequestInterface $request) |
466 | { |
467 | // Get request content |
468 | $requestBodyString = $request->getContent(); |
469 | |
470 | // Get hashed message signature from request header of Alma webhook request |
471 | $almaSignature = ($request->getHeaders()->get('X-Exl-Signature')) |
472 | ? $request->getHeaders()->get('X-Exl-Signature')->getFieldValue() |
473 | : null; |
474 | |
475 | // Get the webhook secret defined in Alma.ini |
476 | $secretConfig = $this->configAlma->Webhook->secret ?? null; |
477 | |
478 | // Calculate hmac-sha256 hash from request body we get from Alma webhook and |
479 | // sign it with the Alma webhook secret from Alma.ini |
480 | $calculatedHash = base64_encode( |
481 | hash_hmac( |
482 | 'sha256', |
483 | $requestBodyString, |
484 | $secretConfig, |
485 | true |
486 | ) |
487 | ); |
488 | |
489 | // Check for correct signature |
490 | if ($almaSignature != $calculatedHash) { |
491 | error_log( |
492 | '[Alma] Unauthorized: Signature value not correct! ' . |
493 | 'Hash from Alma: "' . $almaSignature . '". ' . |
494 | 'Calculated hash: "' . $calculatedHash . '". ' . |
495 | 'Body content for calculating the hash was: ' . |
496 | '"' . json_encode( |
497 | json_decode($requestBodyString), |
498 | JSON_UNESCAPED_UNICODE | |
499 | JSON_UNESCAPED_SLASHES |
500 | ) . '"' |
501 | ); |
502 | throw new \VuFind\Exception\Forbidden(); |
503 | } |
504 | } |
505 | } |