SwiftFactory.php 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl>
  5. *
  6. * @author Adrian Brzezinski <adrian.brzezinski@eo.pl>
  7. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  8. * @author Julien Lutran <julien.lutran@corp.ovh.com>
  9. * @author Morris Jobke <hey@morrisjobke.de>
  10. * @author Robin Appelman <robin@icewind.nl>
  11. * @author Roeland Jago Douma <roeland@famdouma.nl>
  12. * @author Volker <skydiablo@gmx.net>
  13. * @author William Pain <pain.william@gmail.com>
  14. *
  15. * @license GNU AGPL version 3 or any later version
  16. *
  17. * This program is free software: you can redistribute it and/or modify
  18. * it under the terms of the GNU Affero General Public License as
  19. * published by the Free Software Foundation, either version 3 of the
  20. * License, or (at your option) any later version.
  21. *
  22. * This program is distributed in the hope that it will be useful,
  23. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  24. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  25. * GNU Affero General Public License for more details.
  26. *
  27. * You should have received a copy of the GNU Affero General Public License
  28. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  29. *
  30. */
  31. namespace OC\Files\ObjectStore;
  32. use GuzzleHttp\Client;
  33. use GuzzleHttp\Exception\ClientException;
  34. use GuzzleHttp\Exception\ConnectException;
  35. use GuzzleHttp\Exception\RequestException;
  36. use GuzzleHttp\HandlerStack;
  37. use OCP\Files\StorageAuthException;
  38. use OCP\Files\StorageNotAvailableException;
  39. use OCP\ICache;
  40. use OpenStack\Common\Auth\Token;
  41. use OpenStack\Common\Error\BadResponseError;
  42. use OpenStack\Common\Transport\Utils as TransportUtils;
  43. use OpenStack\Identity\v2\Models\Catalog;
  44. use OpenStack\Identity\v2\Service as IdentityV2Service;
  45. use OpenStack\Identity\v3\Service as IdentityV3Service;
  46. use OpenStack\ObjectStore\v1\Models\Container;
  47. use OpenStack\OpenStack;
  48. use Psr\Http\Message\RequestInterface;
  49. use Psr\Log\LoggerInterface;
  50. class SwiftFactory {
  51. private $cache;
  52. private $params;
  53. /** @var Container|null */
  54. private $container = null;
  55. private LoggerInterface $logger;
  56. public const DEFAULT_OPTIONS = [
  57. 'autocreate' => false,
  58. 'urlType' => 'publicURL',
  59. 'catalogName' => 'swift',
  60. 'catalogType' => 'object-store'
  61. ];
  62. public function __construct(ICache $cache, array $params, LoggerInterface $logger) {
  63. $this->cache = $cache;
  64. $this->params = $params;
  65. $this->logger = $logger;
  66. }
  67. /**
  68. * Gets currently cached token id
  69. *
  70. * @return string
  71. * @throws StorageAuthException
  72. */
  73. public function getCachedTokenId() {
  74. if (!isset($this->params['cachedToken'])) {
  75. throw new StorageAuthException('Unauthenticated ObjectStore connection');
  76. }
  77. // Is it V2 token?
  78. if (isset($this->params['cachedToken']['token'])) {
  79. return $this->params['cachedToken']['token']['id'];
  80. }
  81. return $this->params['cachedToken']['id'];
  82. }
  83. private function getCachedToken(string $cacheKey) {
  84. $cachedTokenString = $this->cache->get($cacheKey . '/token');
  85. if ($cachedTokenString) {
  86. return json_decode($cachedTokenString, true);
  87. } else {
  88. return null;
  89. }
  90. }
  91. private function cacheToken(Token $token, string $serviceUrl, string $cacheKey) {
  92. if ($token instanceof \OpenStack\Identity\v3\Models\Token) {
  93. // for v3 the catalog is cached as part of the token, so no need to cache $serviceUrl separately
  94. $value = $token->export();
  95. } else {
  96. /** @var \OpenStack\Identity\v2\Models\Token $token */
  97. $value = [
  98. 'serviceUrl' => $serviceUrl,
  99. 'token' => [
  100. 'issued_at' => $token->issuedAt->format('c'),
  101. 'expires' => $token->expires->format('c'),
  102. 'id' => $token->id,
  103. 'tenant' => $token->tenant
  104. ]
  105. ];
  106. }
  107. $this->params['cachedToken'] = $value;
  108. $this->cache->set($cacheKey . '/token', json_encode($value));
  109. }
  110. /**
  111. * @return OpenStack
  112. * @throws StorageAuthException
  113. */
  114. private function getClient() {
  115. if (isset($this->params['bucket'])) {
  116. $this->params['container'] = $this->params['bucket'];
  117. }
  118. if (!isset($this->params['container'])) {
  119. $this->params['container'] = 'nextcloud';
  120. }
  121. if (isset($this->params['user']) && is_array($this->params['user'])) {
  122. $userName = $this->params['user']['name'];
  123. } else {
  124. if (!isset($this->params['username']) && isset($this->params['user'])) {
  125. $this->params['username'] = $this->params['user'];
  126. }
  127. $userName = $this->params['username'];
  128. }
  129. if (!isset($this->params['tenantName']) && isset($this->params['tenant'])) {
  130. $this->params['tenantName'] = $this->params['tenant'];
  131. }
  132. if (isset($this->params['domain'])) {
  133. $this->params['scope']['project']['name'] = $this->params['tenant'];
  134. $this->params['scope']['project']['domain']['name'] = $this->params['domain'];
  135. }
  136. $this->params = array_merge(self::DEFAULT_OPTIONS, $this->params);
  137. $cacheKey = $userName . '@' . $this->params['url'] . '/' . $this->params['container'];
  138. $token = $this->getCachedToken($cacheKey);
  139. $this->params['cachedToken'] = $token;
  140. $httpClient = new Client([
  141. 'base_uri' => TransportUtils::normalizeUrl($this->params['url']),
  142. 'handler' => HandlerStack::create()
  143. ]);
  144. if (isset($this->params['user']) && is_array($this->params['user']) && isset($this->params['user']['name'])) {
  145. if (!isset($this->params['scope'])) {
  146. throw new StorageAuthException('Scope has to be defined for V3 requests');
  147. }
  148. return $this->auth(IdentityV3Service::factory($httpClient), $cacheKey);
  149. } else {
  150. return $this->auth(SwiftV2CachingAuthService::factory($httpClient), $cacheKey);
  151. }
  152. }
  153. /**
  154. * @param IdentityV2Service|IdentityV3Service $authService
  155. * @param string $cacheKey
  156. * @return OpenStack
  157. * @throws StorageAuthException
  158. */
  159. private function auth($authService, string $cacheKey) {
  160. $this->params['identityService'] = $authService;
  161. $this->params['authUrl'] = $this->params['url'];
  162. $cachedToken = $this->params['cachedToken'];
  163. $hasValidCachedToken = false;
  164. if (\is_array($cachedToken)) {
  165. if ($authService instanceof IdentityV3Service) {
  166. $token = $authService->generateTokenFromCache($cachedToken);
  167. if (\is_null($token->catalog)) {
  168. $this->logger->warning('Invalid cached token for swift, no catalog set: ' . json_encode($cachedToken));
  169. } elseif ($token->hasExpired()) {
  170. $this->logger->debug('Cached token for swift expired');
  171. } else {
  172. $hasValidCachedToken = true;
  173. }
  174. } else {
  175. try {
  176. /** @var \OpenStack\Identity\v2\Models\Token $token */
  177. $token = $authService->model(\OpenStack\Identity\v2\Models\Token::class, $cachedToken['token']);
  178. $now = new \DateTimeImmutable("now");
  179. if ($token->expires > $now) {
  180. $hasValidCachedToken = true;
  181. $this->params['v2cachedToken'] = $token;
  182. $this->params['v2serviceUrl'] = $cachedToken['serviceUrl'];
  183. } else {
  184. $this->logger->debug('Cached token for swift expired');
  185. }
  186. } catch (\Exception $e) {
  187. $this->logger->error($e->getMessage(), ['exception' => $e]);
  188. }
  189. }
  190. }
  191. if (!$hasValidCachedToken) {
  192. unset($this->params['cachedToken']);
  193. try {
  194. [$token, $serviceUrl] = $authService->authenticate($this->params);
  195. $this->cacheToken($token, $serviceUrl, $cacheKey);
  196. } catch (ConnectException $e) {
  197. throw new StorageAuthException('Failed to connect to keystone, verify the keystone url', $e);
  198. } catch (ClientException $e) {
  199. $statusCode = $e->getResponse()->getStatusCode();
  200. if ($statusCode === 404) {
  201. throw new StorageAuthException('Keystone not found, verify the keystone url', $e);
  202. } elseif ($statusCode === 412) {
  203. throw new StorageAuthException('Precondition failed, verify the keystone url', $e);
  204. } elseif ($statusCode === 401) {
  205. throw new StorageAuthException('Authentication failed, verify the username, password and possibly tenant', $e);
  206. } else {
  207. throw new StorageAuthException('Unknown error', $e);
  208. }
  209. } catch (RequestException $e) {
  210. throw new StorageAuthException('Connection reset while connecting to keystone, verify the keystone url', $e);
  211. }
  212. }
  213. $client = new OpenStack($this->params);
  214. return $client;
  215. }
  216. /**
  217. * @return \OpenStack\ObjectStore\v1\Models\Container
  218. * @throws StorageAuthException
  219. * @throws StorageNotAvailableException
  220. */
  221. public function getContainer() {
  222. if (is_null($this->container)) {
  223. $this->container = $this->createContainer();
  224. }
  225. return $this->container;
  226. }
  227. /**
  228. * @return \OpenStack\ObjectStore\v1\Models\Container
  229. * @throws StorageAuthException
  230. * @throws StorageNotAvailableException
  231. */
  232. private function createContainer() {
  233. $client = $this->getClient();
  234. $objectStoreService = $client->objectStoreV1();
  235. $autoCreate = isset($this->params['autocreate']) && $this->params['autocreate'] === true;
  236. try {
  237. $container = $objectStoreService->getContainer($this->params['container']);
  238. if ($autoCreate) {
  239. $container->getMetadata();
  240. }
  241. return $container;
  242. } catch (BadResponseError $ex) {
  243. // if the container does not exist and autocreate is true try to create the container on the fly
  244. if ($ex->getResponse()->getStatusCode() === 404 && $autoCreate) {
  245. return $objectStoreService->createContainer([
  246. 'name' => $this->params['container']
  247. ]);
  248. } else {
  249. throw new StorageNotAvailableException('Invalid response while trying to get container info', StorageNotAvailableException::STATUS_ERROR, $ex);
  250. }
  251. } catch (ConnectException $e) {
  252. /** @var RequestInterface $request */
  253. $request = $e->getRequest();
  254. $host = $request->getUri()->getHost() . ':' . $request->getUri()->getPort();
  255. $this->logger->error("Can't connect to object storage server at $host", ['exception' => $e]);
  256. throw new StorageNotAvailableException("Can't connect to object storage server at $host", StorageNotAvailableException::STATUS_ERROR, $e);
  257. }
  258. }
  259. }