Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 217
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AlmaController
0.00% covered (danger)
0.00%
0 / 217
0.00% covered (danger)
0.00%
0 / 9
2162
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 webhookAction
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
240
 webhookUser
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 1
380
 webhookChallenge
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 sendSetPasswordEmail
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
6
 createJsonResponse
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 webhookNotImplemented
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 checkPermission
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 checkMessageSignature
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
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
30namespace VuFind\Controller;
31
32use Laminas\ServiceManager\ServiceLocatorInterface;
33use Laminas\Stdlib\RequestInterface;
34use Throwable;
35use VuFind\Account\UserAccountService;
36use VuFind\Db\Entity\UserEntityInterface;
37use 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 */
48class 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}