<?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 Psr\Http\Client\ClientExceptionInterface as HttpClientExceptionInterface; use Psr\Http\Client\ClientInterface as HttpClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\UriFactoryInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Serializer\SerializerInterface; use VuFind\Cookie\CookieManager; use Zend\Session\Container as Session; /** * FID client class * * @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 */ 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 HttpClientInterface */ protected $httpClient; /* @var UriFactoryInterface */ protected $uriFactory; /* @var StreamFactoryInterface */ protected $streamFactory; /* @var RequestFactoryInterface */ protected $requestFactory; /* @var string */ protected $locale = 'en'; /* @var Client */ public static $instance; /** * Client constructor. * * @param array $config config * @param Session $session session * @param CookieManager $cookies cookies * @param SerializerInterface $serializer serializer * @param HttpClientInterface $httpClient httpClient * @param UriFactoryInterface $uriFactory uriFactory * @param StreamFactoryInterface $streamFactory streamFactory * @param RequestFactoryInterface $requestFactory requestFactory */ public function __construct( array $config, Session $session, CookieManager $cookies, SerializerInterface $serializer, HttpClientInterface $httpClient, UriFactoryInterface $uriFactory, StreamFactoryInterface $streamFactory, RequestFactoryInterface $requestFactory ) { $this->config = $config; $this->session = $session; $this->cookies = $cookies; $this->serializer = $serializer; $this->httpClient = $httpClient; $this->uriFactory = $uriFactory; $this->streamFactory = $streamFactory; $this->requestFactory = $requestFactory; Client::$instance = $this; } /** * 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')); $request = $this->buildRequest('post', 'logons', $body); $response = $this->sendRequest($request); 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')); $request = $this->buildRequest('post', 'logons', $body); $response = $this->sendRequest($request); 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(); $request = $this->buildRequest('delete', "logons/$username"); $this->cookies->set('finc_fid_logon', null); $this->session->exchangeArray([]); $response = $this->sendRequest($request); 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'], ] ); $request = $this->buildRequest('post', 'orders', $body); $response = $this->sendAuthenticatedRequest($request); 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 { $request = $this->buildRequest('get', "records/$recordid"); $response = $this->sendAuthenticatedRequest($request); 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 { $request = $this->buildRequest('get', 'orders'); $response = $this->sendAuthenticatedRequest($request); 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 { $request = $this->buildRequest('get', "orders/$orderId"); $response = $this->sendAuthenticatedRequest($request); 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 { $request = $this->buildRequest('delete', "orders/{$orderId}"); $response = $this->sendAuthenticatedRequest($request); 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']] ); $request = $this->buildRequest('put', "orders/{$order->getId()}", $body); $response = $this->sendAuthenticatedRequest($request); 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' ) ); $request = $this->buildRequest('post', 'mail/registration', $body); $response = $this->sendRequest($request); 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')); $request = $this->buildRequest('post', 'mail/password', $body); $response = $this->sendRequest($request); 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')); $request = $this->buildRequest('post', 'mail/username', $body); $response = $this->sendAuthenticatedRequest($request); 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'); } $request = $this->buildRequest('get', "users/$userId"); $response = $this->sendAuthenticatedRequest($request); 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'] ] ); $request = $this->buildRequest('post', 'users', $body); $response = $this->sendAuthenticatedRequest($request); 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; } $request = $this->buildRequest('get', 'users'); $response = $this->sendAuthenticatedRequest($request); 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; } $request = $this->buildRequest('get', 'libraries'); $response = $this->sendAuthenticatedRequest($request); 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; } $request = $this->buildRequest('get', "libraries/$id"); $response = $this->sendAuthenticatedRequest($request); 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')); $request = $this->buildRequest('put', "users/{$user->getId()}", $body); $response = $this->sendAuthenticatedRequest($request); 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; } 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')); $request = $this->buildRequest('post', 'logons', $body); $response = $this->sendAuthenticatedRequest($request, 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 for authentication * * @param RequestInterface $request request * @param bool $retryOn401 retry if http status is Unauthorized * * @return ResponseInterface * * @throws ClientException */ protected function sendAuthenticatedRequest( RequestInterface $request, bool $retryOn401 = true ): ResponseInterface { $token = ($logon = $this->restoreLogon())->getToken(); $request = $request->withHeader('Authorization', "Bearer $token"); $response = $this->sendRequest($request); if ($response->getStatusCode() === 401 && $retryOn401) { $this->refreshLogon($logon); return $this->sendAuthenticatedRequest($request, false); } return $response; } /** * Send request * * @param RequestInterface $request request * * @return ResponseInterface * * @throws ClientException */ protected function sendRequest(RequestInterface $request): ResponseInterface { try { return $this->httpClient->sendRequest($request); } catch (HttpClientExceptionInterface $exception) { throw new ClientException(self::ERRMSG_HTTPCLIENT, 0, $exception); } } /** * Build request object * * @param string $verb verb * @param string $path path * @param string $body body * @param array $query query * * @return RequestInterface */ protected function buildRequest( string $verb, string $path, string $body = '', array $query = [] ): RequestInterface { $baseUrl = $this->config['baseUrl']; $uri = $this->uriFactory->createUri("$baseUrl/$path") ->withQuery(http_build_query($query)); $request = $this->requestFactory->createRequest($verb, $uri) ->withBody($this->streamFactory->createStream($body)) ->withHeader('Content-type', 'application/json') ->withHeader('Accept', 'application/json') ->withHeader('Accept-language', $this->locale); if (APPLICATION_ENV === 'production') { return $request; } Request::setTrustedProxies($this->config['xdebug_trusted_proxies'] ?? []); $xdebugSession = $this->config['xdebug_session'] ?? 'fidis'; $xdebugRemoteAddr = $this->config['xdebug_remote_addr'] ?? Request::createFromGlobals()->getClientIp(); return APPLICATION_ENV === 'production' ? $request : $request->withHeader('Cookie', "XDEBUG_SESSION=$xdebugSession") ->withHeader('X-xdebug-remote-addr', $xdebugRemoteAddr); } /** * Throw exception * * @param ResponseInterface $response response * * @return void * * @throws ClientException */ protected function throwException(ResponseInterface $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; } }