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