<?php /** * Fidis client * * Copyright (C) 2019 Leipzig University Library * * PHP version 7 * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. * * @category VuFind * @package Service * @author Sebastian Kehr <kehr@ub.uni-leipzig.de> * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org/wiki/development Wiki */ namespace fid\Service; use fid\Service\DataTransferObject\Library; use fid\Service\DataTransferObject\Logon; use fid\Service\DataTransferObject\Order; use fid\Service\DataTransferObject\Record; use fid\Service\DataTransferObject\User; use InvalidArgumentException; use Laminas\Http\Response; use Laminas\Session\Container as Session; use Symfony\Component\Serializer\SerializerInterface; use VuFind\Cookie\CookieManager; use VuFindHttp\HttpService; /** * FID client class * * @category VuFind * @package Service * @author Sebastian Kehr <kehr@ub.uni-leipzig.de> * @author Robert Lange <lange@ub.uni-leipzig.de> * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License * @link https://vufind.org/wiki/development Wiki */ class Client { protected const ERRMSG_HTTPCLIENT = 'An unexpected http client error occured.'; protected const ERRMSG_HTTPRESPONSE = 'An unexcected http response occured.'; /* @var string */ protected $config; /* @var Session */ protected $session; /* @var CookieManager */ protected $cookies; /* @var SerializerInterface */ protected $serializer; /* @var HttpService */ protected $httpService; /* @var string */ protected $locale = 'en'; /** * Client constructor. * * @param array $config config * @param Session $session session * @param CookieManager $cookies cookies * @param SerializerInterface $serializer serializer * @param HttpService $httpService httpService */ public function __construct( array $config, Session $session, CookieManager $cookies, SerializerInterface $serializer, HttpService $httpService ) { $this->config = $config; $this->session = $session; $this->cookies = $cookies; $this->serializer = $serializer; $this->httpService = $httpService; } /** * Set local for translations * * @param string $locale locale * * @return void */ public function setLocale(string $locale): void { $this->locale = $locale; } /** * Get if user is logged in * * @return bool */ public function isLoggedOn(): bool { try { return !!$this->restoreLogon(); } catch (ClientException $exception) { return false; } } /** * Get user logon * * @param string ...$credentials credentials * * @return Logon * * @throws ClientException */ public function logon(string ...$credentials): Logon { $this->logoff(); switch (count($credentials)) { case 1: $credentials = base64_decode(urldecode($credentials[0])); $logon = $this->storeLogon($this->parseLogon($credentials)); return $this->refreshLogon($logon); case 2: [$username, $password] = $credentials; $body = json_encode(compact('username', 'password')); $response = $this->sendRequest(false, 'post', 'logons', $body); if ($response->getStatusCode() !== 201) { $this->throwException($response); } $logon = $this->parseLogon((string)$response->getBody()); return $this->storeLogon($logon); default: throw new InvalidArgumentException(); } } /** * Check credentials * * @param string $username username * @param string $password password * * @return bool */ public function checkCredentials(string $username, string $password): bool { $body = json_encode(compact('username', 'password')); $response = $this->sendRequest(false, 'post', 'logons', $body); if ($response->getStatusCode() !== 201) { return false; } $logon = $this->parseLogon((string)$response->getBody()); $this->storeLogon($logon); return true; } /** * Logout * * @return void * * @throws ClientException */ public function logoff(): void { try { $logon = $this->restoreLogon(); } catch (ClientException $exception) { return; } $username = $logon->getUsername(); $response = $this->sendRequest(false, 'delete', "logons/$username"); $this->cookies->set('finc_fid_logon', null); $this->session->exchangeArray([]); switch ($response->getStatusCode()) { case 404: case 204: break; default: $this->throwException($response); } } /** * Request creation of an order * * @param Order $order order * * @return Order * * @throws ClientException */ public function requestOrderCreation(Order $order): Order { $body = $this->serializer->serialize( $order, 'json', [ 'groups' => ['order:creation:request'], ] ); $response = $this->sendRequest(true, 'post', 'orders', $body); if ($response->getStatusCode() !== 201) { $this->throwException($response); } /* @var Order $result */ $result = $this->serializer->deserialize( (string)$response->getBody(), Order::class, 'json', ['groups' => ['order:creation:response']] ); /* refresh user details */ $this->requestUserDetails(); return $result; } /** * Get record by id * * @param string $recordid record_id * * @return Record * * @throws ClientException */ public function requestRecord(string $recordid) : Record { $response = $this->sendRequest(true, 'get', "records/$recordid"); if ($response->getStatusCode() !== 200) { $this->throwException($response); } /* @var Record $record */ $record = $this->serializer->deserialize( (string)$response->getBody(), Record::class, 'json' ); return $record; } /** * Get list of orders * * @return array|Order[] * * @throws ClientException */ public function requestOrderList(): array { $response = $this->sendRequest(true, 'get', 'orders'); if ($response->getStatusCode() !== 200) { $this->throwException($response); } /* @var Order[] $list */ $list = $this->serializer->deserialize( (string)$response->getBody(), Order::class . '[]', 'json' ); $keys = array_map( function (Order $order) { return $order->getId(); }, $list ); return array_combine($keys, $list); } /** * Get order by id * TODO: Why can id be null? * * @param null $orderId order identifier * * @return Order|null * * @throws ClientException * @throws UserNotAuthorizedException */ public function requestOrder($orderId = null): ?Order { $response = $this->sendRequest(true, 'get', "orders/$orderId"); if ($response->getStatusCode() !== 200) { $this->throwException($response); } /* @var Order $order */ $order = $this->serializer->deserialize( (string)$response->getBody(), Order::class, 'json' ); return $order; } /** * Delete order * * @param int $orderId order id * * @return bool * * @throws ClientException */ public function requestOrderDelete(int $orderId) : bool { $response = $this->sendRequest(true, 'delete', "orders/{$orderId}"); if ($response->getStatusCode() !== 204) { $this->throwException($response); } return true; } /** * Update order * * @param Order $order order * * @return bool * * @throws ClientException */ public function requestOrderUpdate(Order $order) : bool { $body = $this->serializer->serialize( $order, 'json', ['groups' => ['order:update:request']] ); $response = $this->sendRequest( true, 'put', "orders/{$order->getId()}", $body ); if ($response->getStatusCode() !== 200) { $this->throwException($response); return false; } return true; } /** * Request registration link * * @param string $baseUrl baseUrl * @param string $username username * @param string $firstname firstname * @param string $lastname lastname * * @return void * * @throws ClientException */ public function requestRegistrationLink( string $baseUrl, string $username, string $firstname, string $lastname ): void { $body = json_encode( compact( 'baseUrl', 'username', 'firstname', 'lastname' ) ); $response = $this->sendRequest(false, 'post', 'mail/registration', $body); if ($response->getStatusCode() !== 204) { $this->throwException($response); } } /** * Request (new) password link by mail * * @param string $baseUrl base url * @param string $username username * * @return void * * @throws ClientException */ public function requestPasswordLink(string $baseUrl, string $username): void { $body = json_encode(compact('baseUrl', 'username')); $response = $this->sendRequest(false, 'post', 'mail/password', $body); if ($response->getStatusCode() !== 204) { $this->throwException($response); } } /** * Request (new) username link by mail * * @param string $baseUrl base url * @param string $username username * * @return void * * @throws ClientException */ public function requestUsernameLink(string $baseUrl, string $username): void { $body = json_encode(compact('baseUrl', 'username')); $response = $this->sendRequest(true, 'post', 'mail/username', $body); if ($response->getStatusCode() !== 204) { $this->throwException($response); } } /** * Get user details * * @param null $userId user identifier * @param bool $forceReload force reload * * @return User|null * * @throws ClientException * @throws UserNotAuthorizedException * @throws UserNotLoggedinException */ public function requestUserDetails($userId = null, $forceReload = false): ?User { $logon = $this->restoreLogon(); /* @var User $user */ $user = $this->session['user'] ?? null; $ownId = $logon->getOwnerId(); if (null === $userId || $userId === $ownId) { // user asks for own profile data if (!empty($user) && !$forceReload) { return $user; } $userId = $ownId; } else { // user asks for another user's profile // this shall only be possible for authorized admins $this->authorize('edit_user'); } $response = $this->sendRequest(true, 'get', "users/$userId"); if ($response->getStatusCode() === 401) { throw new UserNotAuthorizedException(); } if ($response->getStatusCode() !== 200) { $this->throwException($response); } /* @var User $user */ $user = $this->serializer->deserialize( (string)$response->getBody(), User::class, 'json', ['groups' => ['user:details:response']] ); if ($ownId === $userId) { $this->session['user'] = $user; } return $user; } /** * Create new user * * @param User $user user data * * @return User * * @throws ClientException */ public function requestUserCreation(User $user): User { $body = $this->serializer->serialize( $user, 'json', [ 'groups' => ['user:creation:request'] ] ); $response = $this->sendRequest(true, 'post', 'users', $body); if ($response->getStatusCode() !== 201) { $this->throwException($response); } /* @var User $result */ $result = $this->serializer->deserialize( (string)$response->getBody(), User::class, 'json', ['groups' => ['user:creation:response']] ); return $result; } /** * Update user * * @param User $user user * * @return User * * @throws ClientException */ public function requestUserUpdate(User $user): User { return $this->doRequestUserUpdate($user, ['user:update:request']); } /** * Update password * * @param User $user user * * @return User * * @throws ClientException */ public function requestUserPasswordUpdate(User $user): User { return $this->doRequestUserUpdate( $user, ['user:update-password:request'] ); } /** * Update username * * @param User $user user * * @return User * * @throws ClientException */ public function requestUserUsernameUpdate(User $user): User { return $this->doRequestUserUpdate( $user, [ 'user:update-username:request' ] ); } /** * Get list of users * * @return User[] * * @throws ClientException * @throws UserNotAuthorizedException */ public function requestUserList(): array { // user asks for another users' profiles // this shall only be possible for authorized admins $this->authorize('read_user_list'); if ($list = $this->session['users'] ?? null) { return $list; } $response = $this->sendRequest(true, 'get', 'users'); if ($response->getStatusCode() !== 200) { $this->throwException($response); } /* @var Library[] $list */ $list = $this->serializer->deserialize( (string)$response->getBody(), User::class . '[]', 'json' ); $keys = array_map( function (User $libary) { return $libary->getId(); }, $list ); return $this->session['users'] = array_combine($keys, $list); } /** * Empty user list from session * * @return void */ public function flushUserList() { unset($this->session['users']); } /** * Authorize user * * Throws an Exception in case the user does not have the requested permission * or the permission cannot be verified * * @param String $permission Name of the permission * @param User|null $user user or null for the currently logged in user * * @return void * * @throws ClientException * @throws UserNotLoggedinException * @throws UserNotAuthorizedException */ protected function authorize(String $permission, User $user = null) { try { $logon = $this->restoreLogon(); } catch (ClientException $exception) { throw new UserNotLoggedinException(); } if (!$logon || !$logon->getOwnerId()) { throw new UserNotLoggedinException(); } $user = $this->requestUserDetails(); if (!$user->hasPermission($permission)) { throw new UserNotAuthorizedException(); } } /** * Check if user is authorized * * @param String $permission name of permission * * @return bool * * @throws ClientException */ public function isAuthorized(String $permission) { try { $this->authorize($permission); } catch (UserAuthorizationException $exception) { return false; } return true; } /** * Get list of libraries * * @return Library[] * * @throws ClientException */ public function requestLibraryList(): array { if ($list = $this->session->libraries[$this->locale] ?? null) { return $list; } $response = $this->sendRequest(true, 'get', 'libraries'); if ($response->getStatusCode() !== 200) { $this->throwException($response); } /* @var Library[] $list */ $list = $this->serializer->deserialize( (string)$response->getBody(), Library::class . '[]', 'json' ); $keys = array_map( function (Library $libary) { return $libary->getId(); }, $list ); if (empty($this->session->libraries)) { $this->session->libraries = []; } return $this->session->libraries[$this->locale] = array_combine( $keys, $list ); } /** * Get library by id * * @param $id identifier * * @return Library * * @throws ClientException */ public function requestLibraryById($id): Library { if ($library = $this->session->homeLibrary[$this->locale] ?? null) { return $library; } $response = $this->sendRequest(true, 'get', "libraries/$id"); if ($response->getStatusCode() !== 200) { $this->throwException($response); } /* @var Library $list */ $library = $this->serializer->deserialize( (string)$response->getBody(), Library::class, 'json' ); $phpTypeCasting = array_map( function (Library $library) { return $library->getId(); }, [$library] ); if (empty($this->session->homeLibrary)) { $this->session->homeLibrary = []; } return $this->session->homeLibrary[$this->locale] = array_combine($phpTypeCasting, [$library])[$id]; } /** * Do update user * * @param User $user user * @param array $groups groups * * @return User * * @throws ClientException */ protected function doRequestUserUpdate(User $user, array $groups): User { $body = $this->serializer->serialize($user, 'json', compact('groups')); $response = $this->sendRequest(true, 'put', "users/{$user->getId()}", $body); if ($response->getStatusCode() !== 200) { $this->throwException($response); } /* @var User $result */ $result = $this->serializer->deserialize( (string)$response->getBody(), User::class, 'json', ['groups' => ['user:update:response']] ); $logon = $this->restoreLogon(); if ($logon->getOwnerId() === $user->getId()) { // refresh user data $this->session['user'] = $result; $this->session->homeLibrary = null; } return $result; } /** * Refresh logon * * @param Logon $logon logon * * @return Logon * * @throws ClientException */ protected function refreshLogon(Logon $logon): Logon { if (time() < $logon->getStalesAt()) { return $logon; } $username = $logon->getUsername(); $password = $logon->getPassword(); $body = json_encode(compact('username', 'password')); $response = $this->sendRequest(true, 'post', 'logons', $body, [], false); if ($response->getStatusCode() === 401) { throw new UserNotAuthorizedException(); } if ($response->getStatusCode() !== 201) { $this->throwException($response); } $logon = $this->parseLogon((string)$response->getBody()); return $this->storeLogon($logon); } /** * Send request - retry after 401 by recursive call * * @param bool $auth auth tocken * @param string $verb verb * @param string $path path * @param string $body body * @param array $query query * @param bool $retry retry if http status is Unauthorized * * @return Response */ protected function sendRequest( bool $auth, string $verb, string $path, string $body = '', array $query = [], bool $retry = true ): Response { $baseUrl = $this->config['baseUrl']; $client = $this->httpService->createClient( "$baseUrl/$path?" . http_build_query($query), $verb ); $client->getAdapter()->setOptions( [ 'sslverifypeer' => false, 'sslverifypeername' => false, 'sslallowselfsigned' => true ] ); $headers["Content-Type"] = "application/json"; $headers["Accept"] = "application/json"; $headers["Accept-language"] = $this->locale; if ($auth && $token = ($logon = $this->restoreLogon())->getToken()) { $headers["Authorization"] = "Bearer $token"; } if (APPLICATION_ENV !== 'production') { $xdebugSession = $this->config['xdebug_session'] ?? 'fidis'; $xdebugRemoteAddr = $this->config['xdebug_remote_addr'] ?? $_SERVER['REMOTE_ADDR']; $headers["Cookie"] = "XDEBUG_SESSION=$xdebugSession"; $headers["X-xdebug-remote-addr"] = "$xdebugRemoteAddr"; } $client->setRawBody($body); $client->setHeaders($headers); try { $response = $client->send(); if ($response->getStatusCode() === 401 && $retry) { if ($logon) { $this->refreshLogon($logon); } return $this->sendRequest( $auth, $verb, $path, $body, $query, false ); } return $response; } catch (\Exception $exception) { throw new ClientException(self::ERRMSG_HTTPCLIENT, 0, $exception); } } /** * Throw exception * * @param Response $response response * * @return void * * @throws ClientException */ protected function throwException(Response $response) { $errorCode = $response->getStatusCode(); $error = json_decode((string)$response->getBody(), true); throw new ClientException($error['detail'], $errorCode); } /** * Restore logon * * @return Logon * * @throws ClientException */ protected function restoreLogon(): Logon { /* @var Logon $logon */ if ($logon = $this->session['logon'] ?? null) { $logon->setToken($this->cookies->get('finc_fid_logon')); if (time() < $logon->getExpiresAt()) { return $logon; } } $this->session->exchangeArray([]); $this->cookies->set('finc_fid_logon', null); throw new ClientException('Missing or expired logon.', 401); } /** * Store logon * * @param Logon $logon logon * * @return Logon */ protected function storeLogon(Logon $logon): Logon { $this->cookies->set('finc_fid_logon', $token = $logon->getToken()); $logon->setToken(null); $this->session->exchangeArray(compact('logon')); $logon->setToken($token); return $logon; } /** * Parse logon * * @param string $logon logon * * @return Logon */ protected function parseLogon(string $logon): Logon { $logon = $this->serializer->deserialize($logon, Logon::class, 'json'); /* @var Logon $logon */ return $logon; } }